Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c2ab7bd
feat(npm): add @deepgram/cli launcher package for the Python CLI
dg-coreylweathers Oct 21, 2026
7e793eb
feat(update): detect the npm launcher and point updates at npm
dg-coreylweathers Oct 21, 2026
762aa4b
feat(plugin): use the isolated plugin venv for npm installs
dg-coreylweathers Oct 21, 2026
576e1b5
fix(skills): show the skills prompt under the npm launcher and name dg
dg-coreylweathers Oct 21, 2026
505735b
ci(release): publish @deepgram/cli to npm after PyPI verifies
dg-coreylweathers Oct 21, 2026
185d1ed
ci(test): run the npm launcher tests on Linux, macOS and Windows
dg-coreylweathers Oct 21, 2026
85ff356
docs: document the npm install and fix the Homebrew formula name in l…
dg-coreylweathers Oct 21, 2026
c497a05
chore(npm): ship the MIT license in the npm package
dg-coreylweathers Oct 21, 2026
15989c4
fix(npm): harden the launcher's cache, lock, pruning and signals
dg-coreylweathers Oct 21, 2026
50a4fba
fix: read DEEPCTL_LAUNCHER once and keep it out of child processes
dg-coreylweathers Oct 21, 2026
bdeb904
ci(test): check npm/LICENSE and smoke-test the launcher's venv path
dg-coreylweathers Oct 21, 2026
abe80e5
ci(release): add a manual npm re-publish and keep older lines off latest
dg-coreylweathers Oct 21, 2026
1310ec3
docs: note that the npm package installs only dg and deepgram
dg-coreylweathers Oct 21, 2026
92015f7
fix(npm): close the remaining launcher cache, lock and signal gaps
dg-coreylweathers Oct 21, 2026
9c04a03
docs(npm): recommend one install method instead of npm install --force
dg-coreylweathers Oct 21, 2026
e0eb1a3
ci(release): inline the npm publish steps and fail closed on the dist…
dg-coreylweathers Oct 21, 2026
0710ea1
fix(npm): keep deepctl's stdin blocking and accept a private-group ~/…
dg-coreylweathers Oct 21, 2026
05e7bb1
ci(test): check that the launcher leaves deepctl's stdin blocking
dg-coreylweathers Oct 21, 2026
e32ac05
fix(npm): trust group-write only for a user-private group, and make s…
dg-coreylweathers Oct 21, 2026
253685e
test(npm): shadow every probed python name in the stdin test
dg-coreylweathers Oct 21, 2026
746e61b
fix(npm): name the signal when deepctl or a setup step crashes
dg-coreylweathers Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
"include-component-in-tag": false,
"make-latest": true,
"extra-files": [
"src/deepctl/__init__.py"
"src/deepctl/__init__.py",
{
"type": "json",
"path": "npm/package.json",
"jsonpath": "$.version"
}
]
},
"packages/deepctl-core": {
Expand Down
162 changes: 162 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,165 @@ jobs:
--head "${BRANCH}" \
--title "Bump deepctl to ${VERSION}" \
--body-file "${BODY_FILE}"

publish-npm:
name: Publish @deepgram/cli to npm
needs: [release-please, verify-published]
# The npm package is a launcher that runs `deepctl==<its own version>`
# from PyPI, so it must never go out before that exact version installs.
# verify-published is that gate. Root tags only, same as mark-latest.
#
# Recovery: if this job fails (npm outage, trusted publisher not set up
# yet), fix the cause and use "Re-run failed jobs" on this workflow run.
# Job outputs from release-please are preserved across re-runs, and every
# step here is safe to repeat: an already-published version is skipped.
if: |
needs.release-please.outputs.release_created == 'true' &&
startsWith(needs.release-please.outputs.tag_name, 'v')
runs-on: ubuntu-latest
# Trusted publishing: npm exchanges this job's OIDC token for a one-time
# publish credential, so no long-lived npm token exists to leak. The
# trusted publisher on npmjs.com is bound to this workflow file and this
# environment; renaming either breaks publishing until it is updated.
environment: npm-publish
permissions:
id-token: write
contents: read
timeout-minutes: 30
env:
TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4

# No registry-url: it writes an .npmrc expecting NODE_AUTH_TOKEN, and an
# empty token there makes npm skip the OIDC exchange.
- name: Set up Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "22"

# Trusted publishing needs npm 11.5.1+; Node 22 bundles npm 10. Pinned
# exactly so a new npm release cannot change how a release publishes.
- name: Install an npm that supports trusted publishing
run: npm install -g npm@11.21.0

# release-please bumps npm/package.json through extra-files. If that
# ever drifts, publishing would ship a launcher pinned to a deepctl
# version that verify-published never checked.
- name: Check the package version matches the tag
id: version
run: |
set -euo pipefail
VERSION="${TAG_NAME#v}"
PKG_VERSION="$(node -p 'require("./npm/package.json").version')"
if [ "${PKG_VERSION}" != "${VERSION}" ]; then
echo "::error::npm/package.json is ${PKG_VERSION} but the tag is ${TAG_NAME}"
exit 1
fi
if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::${VERSION} is not a plain X.Y.Z version"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"

# npm refuses to republish a version, so a re-run after a later step
# failed would otherwise fail here forever.
- name: Check whether this version is already on npm
id: existing
run: |
set -euo pipefail
if [ "$(npm view "@deepgram/cli@${VERSION}" version 2>/dev/null || true)" = "${VERSION}" ]; then
echo "@deepgram/cli@${VERSION} is already published; skipping publish"
echo "published=true" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
fi
env:
VERSION: ${{ steps.version.outputs.version }}

# `npm publish` moves `latest` by default. A patch for an older line
# (0.4.x after 0.5.0 shipped) must not drag every new install back, so
# it goes out under a maintenance tag instead. Fail closed: only "the
# package has no latest yet" (E404) may fall through to latest.
- name: Choose the dist-tag
id: tag
run: |
set -euo pipefail
ERR="$(mktemp)"
if LATEST="$(npm view @deepgram/cli dist-tags.latest 2>"${ERR}")"; then
:
elif grep -q E404 "${ERR}"; then
LATEST=""
else
cat "${ERR}"
echo "::error::could not read the current latest dist-tag; refusing to guess"
exit 1
fi
TAG=latest
if [ -n "${LATEST}" ]; then
ORDER="$(node -e '
const parse = (v) => {
const [core, pre] = v.split("+")[0].split("-");
return { n: core.split(".").map(Number), pre: pre !== undefined };
};
const a = parse(process.argv[1]);
const b = parse(process.argv[2]);
let c = 0;
for (let i = 0; i < 3 && c === 0; i++) c = Math.sign(a.n[i] - b.n[i]);
if (c === 0 && a.pre !== b.pre) c = a.pre ? -1 : 1;
console.log(c < 0 ? "lower" : "not-lower");
' "${VERSION}" "${LATEST}")"
if [ "${ORDER}" = "lower" ]; then
TAG="v$(printf '%s' "${VERSION}" | cut -d. -f1-2)-maint"
fi
fi
echo "latest is ${LATEST:-unset}; publishing ${VERSION} with dist-tag ${TAG}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ steps.version.outputs.version }}

- name: Publish
if: steps.existing.outputs.published != 'true'
working-directory: npm
run: npm publish --access public --provenance --tag "${DIST_TAG}"
env:
DIST_TAG: ${{ steps.tag.outputs.tag }}

- name: Wait until the registry serves the version
run: |
set -euo pipefail
for i in $(seq 1 40); do
if [ "$(npm view "@deepgram/cli@${VERSION}" version 2>/dev/null || true)" = "${VERSION}" ]; then
echo "@deepgram/cli@${VERSION} is visible"
exit 0
fi
echo "Waiting for @deepgram/cli@${VERSION} (${i}/40)..."
sleep 15
done
echo "::error::@deepgram/cli@${VERSION} was not visible on the registry after 10 minutes"
exit 1
env:
VERSION: ${{ steps.version.outputs.version }}

# The end-to-end check a user would do: npx resolves the published
# tarball, the launcher hands off to uv, uv installs deepctl from PyPI.
- name: Install uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0

- name: Run the published package
run: |
set -euo pipefail
for i in $(seq 1 10); do
if OUT="$(npx -y "@deepgram/cli@${VERSION}" --version 2>/dev/null)" && printf '%s' "${OUT}" | grep -qF "${VERSION}"; then
echo "${OUT}"
exit 0
fi
echo "npx @deepgram/cli@${VERSION} not runnable yet (${i}/10)..."
sleep 30
done
echo "Final attempt, with output:"
npx -y "@deepgram/cli@${VERSION}" --version || true
echo "::error::npx @deepgram/cli@${VERSION} --version did not report ${VERSION}"
exit 1
env:
VERSION: ${{ steps.version.outputs.version }}
84 changes: 83 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,87 @@ jobs:
- name: Verify packages
run: make verify-packages

launcher:
name: npm launcher / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4

- name: Set up Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "22"

# A glob, not the directory: Node 22+ treats --test arguments as file
# patterns, and Node expands it itself where the shell does not.
- name: Unit tests
run: node --test "npm/test/*.test.js"

# npm/LICENSE is what npmjs.com and the tarball show; it must not drift
# from the repository's.
- name: npm/LICENSE matches LICENSE
run: cmp LICENSE npm/LICENSE

# Regression: the launcher once made the stdin it hands to deepctl
# non-blocking, so a reader that had to wait (an MCP host, `ffmpeg |
# dg listen`) failed. A stand-in deepctl checks fds 0-2 and reads input
# that arrives a second late.
- name: stdin stays blocking for deepctl
if: runner.os != 'Windows'
run: |
set -euo pipefail
FAKE="$(mktemp -d)"
cat > "${FAKE}/deepctl" <<'PY'
#!/usr/bin/env python3
import fcntl, os, sys
flags = [bool(fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_NONBLOCK) for fd in (0, 1, 2)]
print("nonblock=%s read=%r" % (flags, sys.stdin.read()))
PY
chmod +x "${FAKE}/deepctl"
OUT="$( (sleep 1; echo hello) | PATH="${FAKE}:${PATH}" DEEPCTL_NPM_RUNNER=path node npm/bin/dg.js)"
echo "${OUT}"
[ "${OUT}" = "nonblock=[False, False, False] read='hello\n'" ]

- name: Install uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0

# npm/package.json is already bumped on a release PR, before PyPI has
# that version, so run the launcher against the newest deepctl that
# PyPI actually serves. This checks the launcher, not the release.
- name: Smoke test against the latest deepctl on PyPI
run: |
set -euo pipefail
LATEST="$(node -e 'fetch("https://pypi.org/pypi/deepctl/json").then(r => r.json()).then(d => console.log(d.info.version))')"
echo "deepctl on PyPI: ${LATEST}"
echo "latest=${LATEST}" >> "$GITHUB_ENV"
OUT="$(DEEPCTL_NPM_PYPI_SPEC="deepctl==${LATEST}" node npm/bin/dg.js --version)"
echo "${OUT}"
printf '%s' "${OUT}" | grep -qF "${LATEST}"

# The fallback for machines without uv: find Python, build a private
# venv, run from it. Windows exercises Scripts\deepctl.exe and the py
# launcher here.
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

- name: Smoke test the venv strategy
run: |
set -euo pipefail
# tr: Python on Windows ends lines with CRLF.
OUT="$(DEEPCTL_NPM_RUNNER=venv DEEPCTL_NPM_PYPI_SPEC="deepctl==${latest}" node npm/bin/dg.js --version | tr -d '\r')"
printf 'stdout: %s\n' "${OUT}"
# Exactly the version line: setup chatter must stay on stderr.
[ "${OUT}" = "deepctl, version ${latest}" ]

# Single rollup so branch protection needs exactly one required check.
# Requiring the matrix contexts directly means editing repo settings every
# time a Python version or OS is added or dropped, and a required context
Expand All @@ -123,7 +204,7 @@ jobs:
# cancelled and skipped prerequisites are failures here too.
all-checks:
name: All checks
needs: [test, lint, floors, build-test]
needs: [test, lint, floors, build-test, launcher]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -133,6 +214,7 @@ jobs:
echo "lint: ${{ needs.lint.result }}"
echo "floors: ${{ needs.floors.result }}"
echo "build-test: ${{ needs.build-test.result }}"
echo "launcher: ${{ needs.launcher.result }}"

- name: Fail unless every prerequisite succeeded
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ downloads/
eggs/
.eggs/
lib/
# The npm launcher's sources, not a build output.
!/npm/lib/
lib64/
parts/
sdist/
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,19 @@ iwr https://deepgram.com/install.ps1 -useb | iex
pip install deepctl # pip
uv tool install deepctl # uv
pipx install deepctl # pipx
npm install -g @deepgram/cli # npm (installs dg and deepgram)
```

The npm package is a launcher for the same Python CLI. It needs
[uv](https://docs.astral.sh/uv/) or Python 3.10+ with `venv`, and sets the CLI
up on first run. See [npm/README.md](npm/README.md).

### Try Without Installing

```bash
uvx deepctl --help
pipx run deepctl --help
npx @deepgram/cli --help
```

## Getting Started
Expand Down
10 changes: 10 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ def _clean_deepgram_env(monkeypatch):
for key in list(os.environ):
if key.startswith("DEEPGRAM_"):
monkeypatch.delenv(key, raising=False)


@pytest.fixture(autouse=True)
def _fresh_launcher_capture():
"""Re-read DEEPCTL_LAUNCHER in each test, so env patches take effect."""
from deepctl_core.launcher import reset_launcher_for_tests

reset_launcher_for_tests()
yield
reset_launcher_for_tests()
21 changes: 21 additions & 0 deletions npm/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025-present Deepgram, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading