Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
dbd3a33
feat(cli): detect npm launcher installs in update, plugin and skills
dg-coreylweathers Oct 30, 2026
4e8a7c0
feat(npm): add the @deepgram/cli launcher package
dg-coreylweathers Oct 30, 2026
3a5ea7d
ci(release): publish @deepgram/cli to npm and gate releases on versio…
dg-coreylweathers Oct 30, 2026
e90e1dd
docs: document the npm install
dg-coreylweathers Oct 30, 2026
c9d6140
fix(npm): print launcher errors as ERROR and HINT lines
dg-coreylweathers Oct 31, 2026
57cb1de
ci: correct the checkout pin comments and pin uv in the npm launcher …
dg-coreylweathers Oct 31, 2026
e6cfd81
fix(npm): install the CLI only from hash-locked requirements
dg-coreylweathers Oct 31, 2026
41b1127
fix(npm): keep venvs and uv's cache in directories only the user cont…
dg-coreylweathers Oct 31, 2026
2993b03
refactor(core): keep the launcher module internal
dg-coreylweathers Oct 31, 2026
ba14589
test(npm): keep launcher tests independent of the host filesystem
dg-coreylweathers Oct 31, 2026
7191c5e
test(npm): shadow every Python the launcher probes in the fake-Python…
dg-coreylweathers Oct 31, 2026
fac168e
fix(npm): never trust a group-writable cache ancestor
dg-coreylweathers Nov 3, 2026
a9bb967
fix(npm): never reuse a uv cache the launcher can't check
dg-coreylweathers Nov 3, 2026
ea0f37f
ci(release): gate the npm package and wheel metadata on a root bump
dg-coreylweathers Nov 3, 2026
e6232e5
ci(release): deploy web only after npm publish and fix npm prerequisi…
dg-coreylweathers Nov 10, 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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# output.py is CRLF on main and ruff format keeps it that way; let git diff --check accept it.
packages/deepctl-core/src/deepctl_core/output.py whitespace=cr-at-eol
# .gitignore is CRLF on main too; same treatment so new entries match the file.
.gitignore whitespace=cr-at-eol
packages/deepctl-core/tests/unit/fixtures/legacy_v03/** -text
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
296 changes: 286 additions & 10 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

226 changes: 225 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,227 @@ jobs:
- name: Verify packages
run: make verify-packages

# The launcher's unit tests on every Node version package.json's engines
# field admits. Linux only: the cross-OS job below covers platforms.
launcher-node:
name: npm launcher unit tests / Node ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 20.0.0 is the engines floor itself.
node: ["20.0.0", "20", "22", "24"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node }}

# Unquoted: Node 20 does not expand --test globs itself, bash does.
- name: Unit tests
run: node --test npm/test/*.test.js

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 # v7.0.1

- 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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with:
version: "0.13.6"

# 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@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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}" ]
# Windows never reuses a venv: the one-run venv must be gone.
if [ "${RUNNER_OS}" = "Windows" ]; then
LEFT="$(ls -A "$(cygpath "${LOCALAPPDATA}")/deepgram-cli/venvs")"
[ -z "${LEFT}" ] || { echo "::error::one-run venv left behind: ${LEFT}"; exit 1; }
fi

# The release path, end to end, without PyPI: build this commit's
# packages, serve them and their locked dependencies from a local index
# (simple pages plus the JSON API write_requirements.py reads), write the
# hash-locked requirements file against it, and run the launcher through
# uv and through the pip fallback. The `launcher` job's smoke tests use
# the unhashed DEEPCTL_NPM_PYPI_SPEC override; this one does not.
launcher-hashed:
name: npm launcher hash-locked install / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
defaults:
run:
shell: bash
env:
DEEPCTL_TELEMETRY_DISABLED: "1"
DEEPCTL_TELEMETRY_DSN: "http://k@127.0.0.1:9/1"
UV_PYTHON_DOWNLOADS: never
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with:
version: "0.13.6"

# One step: the index server only lives as long as the step's shell.
- name: Install through the launcher from a local index
run: |
set -euo pipefail
uv build --all-packages -q -o dist
uv export --frozen --no-dev --no-emit-workspace --no-header --no-annotate > third.txt
# Wheels for every Python the launcher may pick (it prefers the
# newest supported one, and runners have several installed).
for v in 3.12 3.13 3.14; do
python -m pip download -q --disable-pip-version-check --only-binary :all: --no-deps --require-hashes --python-version "${v}" -r third.txt -d wheels
done
python npm/scripts/local_index.py --out idx dist wheels
python -m http.server -b 127.0.0.1 -d idx 8765 > /dev/null 2>&1 &
SERVER=$!
trap 'kill "${SERVER}"' EXIT
for _ in $(seq 1 30); do curl -sf http://127.0.0.1:8765/simple/ > /dev/null && break; sleep 1; done
python npm/scripts/write_requirements.py --index-url http://127.0.0.1:8765 --dist dist
VERSION="$(node -p 'require("./npm/package.json").version')"
export UV_INDEX_URL=http://127.0.0.1:8765/simple PIP_INDEX_URL=http://127.0.0.1:8765/simple
# Windows never reuses a uv cache: plant one where earlier versions
# kept it, and check below that uv neither read nor wrote it.
if [ "${RUNNER_OS}" = "Windows" ]; then
PLANTED="$(cygpath "${LOCALAPPDATA}")/deepgram-cli/uv-cache"
mkdir -p "${PLANTED}"
echo planted > "${PLANTED}/marker"
fi
for runner in uvx venv; do
# A fresh HOME per runner, so the venv run cannot reuse the venv
# the uv run cached (macOS keeps it under ~/Library/Caches).
# tr: Python on Windows ends lines with CRLF.
mkdir -p "${RUNNER_TEMP}/home-${runner}"
OUT="$(HOME="${RUNNER_TEMP}/home-${runner}" DEEPCTL_NPM_RUNNER="${runner}" node npm/bin/dg.js --version | tr -d '\r')"
printf '%s: %s\n' "${runner}" "${OUT}"
[ "${OUT}" = "deepctl, version ${VERSION}" ]
done
# Windows never reuses a venv: the one-run venvs must be gone.
if [ "${RUNNER_OS}" = "Windows" ]; then
LEFT="$(ls -A "$(cygpath "${LOCALAPPDATA}")/deepgram-cli/venvs")"
[ -z "${LEFT}" ] || { echo "::error::one-run venv left behind: ${LEFT}"; exit 1; }
CACHED="$(ls -A "${PLANTED}")"
[ "${CACHED}" = "marker" ] || { echo "::error::the uv cache in ${PLANTED} was used: ${CACHED}"; exit 1; }
fi

# The release PR's bump gate (scripts/check_release_bumps.py), for commits
# people push to the release PR, such as a CHANGELOG edit. Commits from
# release-please and the release workflow's sync job use GITHUB_TOKEN and
# trigger no workflows; release.yml's sync-release-pr posts the same
# `release-bump-gate` context as a commit status on those. Skipped on
# every other PR, which a required check treats as passing. Deliberately
# not in all-checks: on non-release PRs it never runs.
release-bump-gate:
name: release-bump-gate
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please--')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The PR head, not the merge commit, and every release tag.
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"

- name: Check every changed package is being released
run: |
pip install packaging
python scripts/check_release_bumps.py

# 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 +344,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-node, launcher, launcher-hashed]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -133,6 +354,9 @@ jobs:
echo "lint: ${{ needs.lint.result }}"
echo "floors: ${{ needs.floors.result }}"
echo "build-test: ${{ needs.build-test.result }}"
echo "launcher-node: ${{ needs.launcher-node.result }}"
echo "launcher: ${{ needs.launcher.result }}"
echo "launcher-hashed: ${{ needs.launcher-hashed.result }}"

- name: Fail unless every prerequisite succeeded
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ downloads/
eggs/
.eggs/
lib/
# The npm launcher's sources, not a build output.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git diff --check origin/main...HEAD fails because these added lines carry trailing CR characters. Save them with the file expected line endings so whitespace validation stays clean.

!/npm/lib/
# Written by the release (npm/scripts/write_requirements.py).
/npm/requirements-*.txt
lib64/
parts/
sdist/
Expand Down
33 changes: 27 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,29 @@ only installs what root depends on, so a published package missing from that
list never reaches anyone. `make floors-check` fails on both that omission and
a floor left below the workspace version; run `make floors-fix` to pin floors.

When a package starts importing a sibling API that only the sibling's newest
release has, add the pair to `SIBLING_FLOORS` in
`scripts/check_dependency_floors.py`. That floor then tracks the sibling's
workspace version the same way root floors do, and the release workflow checks
it again before building for PyPI.

Floors only hold if every package whose code changed is released.
`scripts/check_release_bumps.py` fails when a package's `src/` or dependencies
changed since its last release tag but its version did not move (a `chore:` or
`refactor:` commit does not bump it). On the release PR it reports as the
`release-bump-gate` check: `sync-release-pr` posts it as a commit status on the
bot's commits, and `test.yml` runs it for commits people push. It blocks the
merge only while `release-bump-gate` is a required check on `main`. The build
job runs it again before anything reaches PyPI. Land a `fix:` commit
touching that package, or a `Release-As:` footer, and release again.

### Releasing

Releases are driven by release-please. Two things are worth knowing before you
run one:

**Land your release-notes edits as a commit, not a PR description edit.**
`sync-release-pr` regenerates `uv.lock` and the root dependency floors on the
`sync-release-pr` regenerates `uv.lock` and the root and sibling dependency floors on the
release branch automatically, but it pushes with `GITHUB_TOKEN`, which by
design retriggers nothing — so the PR's checks still reflect the bot's first
commit and stay red on content that is now correct. Editing `CHANGELOG.md` on
Expand All @@ -87,11 +103,16 @@ dedicated PAT or GitHub App token.

**Nothing advertises a release until it is installable.** `verify-published`
polls PyPI until `pip install deepctl==X` resolves its full closure, then
installs it for real and runs the CLI. `mark-latest`, `deploy-web`, and the
Homebrew bump all wait on it. If it times out, PyPI has not propagated or a
sub-package failed to publish — re-run all jobs on that workflow run once
PyPI has caught up; re-running only the failed job leaves the three
downstream jobs skipped. Re-running all jobs is safe: the publish step uses
installs it for real and runs the CLI. `mark-latest`, `deploy-web`, the
Homebrew bump, and `publish-npm` all wait on it. `deploy-web` also waits on
`publish-npm`, which publishes `@deepgram/cli` and smoke-tests it with `npx`,
so the site never shows npm install instructions before the package works.
If `verify-published` times out, PyPI has not propagated or a sub-package
failed to publish — re-run all jobs on that workflow run once PyPI has caught
up; re-running only the failed job leaves the downstream jobs skipped. If
only `publish-npm` fails, fix the cause and re-run failed jobs: `deploy-web`
stays skipped until it passes, and the npm publish step skips an
already-published version. Re-running all jobs is safe: the publish step uses
`skip-existing`.

### Testing
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,10 @@ readmes: ## Generate sub-package READMEs from pyproject.toml metadata
readmes-check: ## Check sub-package READMEs are up to date
python3 scripts/generate_readmes.py --check

floors-check: ## Check intra-workspace dependency floors (root must pin workspace versions)
floors-check: ## Check intra-workspace dependency floors (root and SIBLING_FLOORS must pin workspace versions)
uv run python scripts/check_dependency_floors.py

floors-fix: ## Pin root dependency floors to the current workspace versions
floors-fix: ## Pin root and SIBLING_FLOORS dependency floors to the current workspace versions
uv run python scripts/check_dependency_floors.py --fix

# ===================================================================
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
Node.js 20+ and either [uv](https://docs.astral.sh/uv/) or Python 3.10-3.14
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