Skip to content

feat(npm): make @deepgram/cli the real Deepgram CLI - #146

Open
dg-coreylweathers wants to merge 14 commits into
mainfrom
feat/npm-launcher
Open

dg-coreylweathers wants to merge 14 commits into
mainfrom
feat/npm-launcher

Conversation

@dg-coreylweathers

@dg-coreylweathers dg-coreylweathers commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Why

@deepgram/cli on npm is the obvious name for "the Deepgram CLI", and it's broken:

  • npm i @deepgram/cli fails with notarget. The only versions are two old betas (0.3.0-beta.0, 0.4.0-beta.0) from lukeocodes/deepgram-cli.
  • npm i @deepgram/cli@0.4.0-beta.0 installs, then crashes on start: MODULE_NOT_FOUND …/src/hooks/init/log.

Coding agents try npm first. In our build-journey evals, agents asked to "use the Deepgram CLI" went to @deepgram/cli, hit both failures, and gave up on the CLI.

This PR makes @deepgram/cli the real CLI.

What it does

New npm/ package. @deepgram/cli has no dependencies and no install scripts. It provides dg and deepgram, and runs the Python deepctl at the same version as the npm package:

  1. A cached private venv (~/.cache/deepgram-cli/venvs/<V>) if one is already built. This is fast and works offline.
  2. Otherwise uv tool run --from deepctl==<V>. No local Python is needed.
  3. Otherwise, a Python 3.10+ with venv gets a private venv, built in place under a lock. All bootstrap output goes to stderr, so dg -o json and dg mcp stdout stay clean on the first run.
  4. Otherwise, a clear error with the three fixes: install uv, install python3-venv, or use install.sh/brew.

DEEPCTL_NPM_RUNNER=uvx|venv|path forces a strategy. path uses a deepctl already on PATH, and only when asked.

Python side. InstallMethod.NPM is detected through DEEPCTL_LAUNCHER, which deepctl_core.launcher reads once and removes from the environment so it doesn't leak to child processes. With it:

  • dg update and the update banner tell npm and npx users the npm command.
  • Plugins go into the isolated plugin venv, so they survive npm version bumps.
  • The skills prompt says dg.

Release.

  • release-please bumps npm/package.json along with the root package (extra-files).
  • A new publish-npm job runs after verify-published, so npm never points at a version PyPI can't install.
  • It uses npm trusted publishing (OIDC, environment npm-publish, npm pinned to 11.21.0) and sets provenance.
  • It's safe to re-run: it skips a version that's already published, and it fails closed if the dist-tag lookup errors.
  • If the version is lower than the current latest, it publishes under v<maj>.<min>-maint instead of moving latest.
  • Recovery is "Re-run failed jobs".

CI. A new launcher job on Linux, macOS and Windows, added to all-checks:

  • Node unit tests.
  • A real uv smoke test and a real venv smoke test.
  • A check that stdin stays blocking for deepctl.
  • cmp LICENSE npm/LICENSE.

Docs. npm/npx install lines in README.md and web/public/llms-full.txt. The latter also fixes the deepgram/tap/deepctl → deepgram/tap/deepgram typo.

The first npm version will be 0.4.1, from the next release PR. 0.4.1 is newer than 0.4.0-beta.0, so latest moves and plain npm i @deepgram/cli works.

Hardening

This went through five rounds of independent review, each followed by a fix round. Every finding was reproduced in Docker before it was fixed, and each fix was checked against its repro. Highlights:

Cache security.

  • Every cache ancestor must be owned by you or root.
  • Group/other-writable ancestors must be sticky.
  • Group-write is trusted only for a user-private group (gid == uid).
  • The /tmp fallback is 0700 and owner-checked.

These checks block another user from planting or swapping a cached venv. Tested by pre-creating the cache, with a symlink, and with a 150-iteration swap loop.

Locking.

  • mkdir lock with owner, pid and host.
  • A lock is stale when its pid is dead, it's older than 60 min, or it was taken by our own pid after a pid reuse.
  • Stale locks are renamed aside and their owner verified before deletion.
  • Release is ownership-checked.

Signals and stdio.

  • SIGTERM and SIGHUP are forwarded.
  • With a terminal attached, SIGINT and SIGQUIT come from the process group, so each Ctrl-C reaches deepctl exactly once and cancel still exits 2.
  • With no terminal (IDEs, agent hosts), a SIGINT sent only to the launcher is forwarded after 1s.
  • The launcher never constructs a Node std stream. That stream would put the inherited stdin into non-blocking mode and break dg mcp over stdio, ffmpeg | dg listen, and similar pipes.
  • Launcher messages to stderr are best effort, so a full or closed stderr can't break a run.

Self-repair and pruning.

  • A venv whose base Python was removed gets rebuilt.
  • Old version venvs are pruned only after 30 days unused, and never while locked or in use.

Testing

Docker (linux/arm64; the uvx, venv, signal and stdin cases also on amd64): 43/43 cases pass. They cover:

  • no Python;
  • uv only;
  • Python 3.12 venv: cold, warm, and offline warm and cold;
  • Debian with and without python3-venv (PEP 668);
  • Python 3.9 rejected;
  • Alpine/musl;
  • two concurrent first runs;
  • SIGTERM, SIGINT and SIGHUP, with and without a TTY;
  • unwritable HOME and an arbitrary uid;
  • OpenShift-style uid with gid 0;
  • shared primary group;
  • the npx and npm labels;
  • npx ./tgz;
  • clean JSON stdout on a cold cache;
  • an MCP initialize sent 1s late.

Unit and lint:

  • Node: 74/74 on Node 18, 20, 22 and 24.
  • Python: 2231 passed, 12 skipped.
  • ruff, mypy, check_dependency_floors.py and actionlint are clean.

Not yet run: real macOS and Windows runs, which the new launcher job does on this PR, and the publish-npm job, which runs on the first release now that the owner setup below is in place.

Before the first release, owner steps (done 2026-10-23)

  • Trusted publisher on @deepgram/cli (set as deepgramai): GitHub Actions, repo deepgram/cli, workflow release.yml, environment npm-publish, permission publish. Note for anyone redoing this: npm now separates publish from stage publish; the first attempt from the web UI granted only stage publish, which a plain npm publish is rejected under, so it was revoked and re-added with npm trust github @deepgram/cli --repo deepgram/cli --file release.yml --env npm-publish --allow-publish. Verify with npm trust list @deepgram/cli.
  • GitHub environment npm-publish created on this repo, deployment branches limited to main, no required reviewer.
  • Both 2023 betas carry a deprecation message pointing at the new install: Unsupported 2023 beta. Install the Deepgram CLI with: npm i -g @deepgram/cli@latest (0.4.1 or newer). (npm Support had deprecated them on 2026-09-11 with its generic "no longer supported" text; the flag is per version and does not block publishing.)
  • After 0.4.1 publishes: confirm npm view @deepgram/cli dist-tags.latest is 0.4.1, then turn on "Require 2FA and disallow tokens" for the package.

Until the first publish, npx @deepgram/cli still runs the old 2023 beta (now with the pointer message), so the README line goes live slightly ahead of the package.

Release-PR checklist (floors can't be raised on this branch)

No published deepctl-core contains deepctl_core.launcher yet, and the floors check rejects a floor above the workspace version. Before merging the release PR that ships this:

  • packages/deepctl-cmd-update/pyproject.toml: change deepctl-core>=0.1.10 to deepctl-core>=<new core version>.
  • packages/deepctl-cmd-skills/pyproject.toml: change deepctl-core>=0.2.19 to deepctl-core>=<new core version>.
  • packages/deepctl-cmd-plugin/pyproject.toml: add deepctl-cmd-update>=<new cmd-update version>. It imports InstallMethod, including NPM, but has never declared the dependency.
  • Re-run uv lock and scripts/check_dependency_floors.py.

A normal pip install -U deepctl is already safe, because the root floors pin everything.

Known limits and follow-ups (not in this PR)

Accepted limits:

  • A pip install deepctl into the same prefix overwrites npm's dg, and brew and npm both want dg. The npm README says to use one install method.
  • Group-writable caches on shared-group systems (macOS staff, SUSE users) fall back to a private temp dir. Any user-private-group member could interfere, which is a limit of that convention.
  • nohup dg … doesn't survive SIGHUP the way nohup deepctl … does, because of how Node handles it. Use setsid or deepctl directly for detached runs.
  • With a TTY attached, a SIGINT sent only to the launcher's pid (not the process group) isn't forwarded.

Existing bugs found along the way:

  • dg update tells Homebrew users to run brew upgrade deepctl, but the formula is deepgram.
  • deepctl 0.4.0 crashes when HOME is unreadable (PermissionError on ~/.deepctl/plugins/venv).
  • The actions/checkout pins are commented # v4 but resolve to v7.0.1.

Phase 2: PyInstaller per-platform binaries through optionalDependencies, so npm installs need no Python or uv at all.

The history includes a manual re-publish path that was added and later removed, so squash on merge.

@dg-coreylweathers
dg-coreylweathers marked this pull request as ready for review October 23, 2026 11:38
GregHolmes added a commit that referenced this pull request Oct 23, 2026
Replaces `@lukeocodes`, who has left Deepgram, with the current DX
owners: `@dg-coreylweathers`, `@GregHolmes` and `@deepgram-kiley`. All
three are admins on this repo, so the rule takes effect on merge.

Kept separate from #146 so that PR isn't waiting on a code owner who's
no longer here.

@GregHolmes GregHolmes left a comment

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.

This PR makes @deepgram/cli a Node launcher for the Python CLI, adds npm/npx-aware update behavior, and publishes npm only after PyPI verification.

Requesting changes for two release-critical issues:

  1. The next root release can still resolve published sibling packages that lack deepctl_core.launcher and InstallMethod.NPM. Raise the sibling dependency floors on the generated release PR, add the plugin-to-update dependency, regenerate uv.lock, and enforce that work before PyPI publishing.

  2. On Windows, the launcher treats every cache root as trusted before executing cached deepctl.exe. Validate ownership, ACLs, and reparse points, or disable cache reuse when equivalent validation is unavailable.

I also found first-run signal forwarding, stale llms version, and trailing-whitespace follow-ups.

Comment thread npm/lib/launcher.js Outdated
// Windows has no uid and a per-user LOCALAPPDATA.
function rootTrusted(spec, opts = {}) {
const platform = opts.platform || process.platform;
if (isWindows(platform)) return true;

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.

This treats every Windows root as safe, then the launcher executes cached deepctl.exe from it. The %TEMP%\\deepgram-cli-user fallback is predictable and can be shared or reparse-point-backed. Validate ownership, ACLs, and reparse points before reuse, or disable cache reuse/fallback where equivalent validation is unavailable.

Comment thread .gitignore
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.

from enum import Enum
from pathlib import Path

from deepctl_core.launcher import get_launcher, launched_via_npm

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.

This import makes the next deepctl release require the first published deepctl-core that contains launcher.py, but this package still declares deepctl-core>=0.1.10. PyPI deepctl-core==0.3.0 satisfies the current root floor and raises ModuleNotFoundError for this module. Raise the update and skills floors on the release PR, regenerate uv.lock, and make that dependency update an enforced release gate.

Comment thread npm/lib/launcher.js Outdated
stderr.write(`Setting up Deepgram CLI ${version} (one-time, ~1 min)...\n`);
const pip = [layout.python, '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-q'];
try {
runStep(python.command, [...python.args, '-m', 'venv', layout.dir], 'Creating the Python venv', VENV_HINT, spawnSync);

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.

The signal handlers are installed only after resolveRunner() returns, but this synchronous setup runs inside it. A supervisor-only SIGTERM or SIGHUP during venv creation can terminate dg while the setup child continues and leaves its lock behind. Install forwarding before runner resolution and supervise setup children asynchronously; add a regression test that signals the launcher during each setup step.

@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Replies to Greg's review on #146

installation.py:10 (sibling floors)

Fixed in 64ed2cb. check_dependency_floors.py now pins update → core, skills → core and plugin → update to the sibling's workspace version. --fix raises them, plugin declares deepctl-cmd-update, and the lock is regenerated. Today's floor is deepctl-core>=0.3.0, and the 0.3.0 on PyPI has no launcher.py. What makes it safe is the release bumping core: this fix triggers a core release, and the check then requires that new version before anything publishes. There's also a new release gate. It fails if any package's src/ or dependencies changed since its last tag without a version bump, which covers a chore: commit that release-please wouldn't bump. On the release PR it shows up as a release-bump-gate status. sync-release-pr posts it on the bot's commits, and a test.yml job covers anything we push by hand, so it flags a problem before merge. It only blocks the merge once it's required, so could one of us add release-bump-gate as a required status check on main's ruleset? The build job runs it again before the PyPI upload either way. verify-published now does a real PyPI-only install, runs deepctl --version, and imports the launcher. Tested in Docker: floors and gate tests plus make floors-check.

launcher.js:298 (Windows cache roots)

Fixed in f130238. I went with your option: Windows never reuses a cache. No root is trusted, the %TEMP% fallback is gone, and a relative LOCALAPPDATA is ignored. With uv it runs uv tool run. Without uv, Windows pays about 1 min on every run, since it builds a fresh venv each time and deletes it afterwards. The hint gives the uv install command. Console close and Ctrl-Break also clean up now, and leftovers from a crash get pruned after an hour. The Windows CI smoke checks the one-run venv is gone afterwards.

launcher.js:625 (signals during setup)

Fixed in 161dd87. Signal handlers go in before setup, setup is async (no spawnSync or sleepSync), and each setup step runs in its own session (setsid), so pip's children die with it. On a signal, the launcher removes the partial venv, releases the lock in finally, and re-raises the signal. There's a SIGTERM test for each setup step (venv, pip upgrade, pip install) that checks: no orphaned pids, no lock, no partial venv, and death by SIGTERM. The lock-wait test checks the exit signal and that the other run's lock is untouched; it doesn't check for orphans. If a launcher gets SIGKILLed, the next run on Linux kills the pip it left behind before rebuilding. The lock file is always replaced atomically, so nobody reads half of it. The signal tests pass 20/20 runs on a starved CPU (--cpus=0.4 plus load) and fail on the old launcher. One thing I couldn't fix: nohup dg and trap '' INT don't carry over to deepctl, because Node resets ignored signals before our code runs. It's documented in the npm README with workarounds (setsid, & disown, tmux).

.gitignore:18 (trailing CR)

main's .gitignore is already CRLF, so I left the file alone and added .gitignore whitespace=cr-at-eol to .gitattributes, next to the existing output.py entry. git diff --check is clean. e8fbe8d

Stale llms version (review summary)

llms-full.txt now gets its version from pyproject.toml at build time, like index.astro does, and the build fails if it can't read it. A Docker web build shows 0.4.0. e1734dd

Also merged main, bumped engines to Node >=20, and added a Linux job that runs the launcher tests on Node 20.0.0, 20, 22 and 24. c1e6e87

The @deepgram/cli npm package runs deepctl with DEEPCTL_LAUNCHER set.
deepctl-core gains deepctl_core.launcher, which reads that variable once
and drops it from the environment. deepctl-cmd-update reports the npm
install method and gives npm-shaped update advice. deepctl-cmd-plugin
installs plugins into its isolated venv, because the launcher's venv is
replaced on every npm version bump. deepctl-cmd-skills shows its one-time
prompt even when the launcher runs deepctl through uv. Those packages
now need the new core API, so their floors move to deepctl-core>=0.3.0,
and plugin declares deepctl-cmd-update>=0.2.7 (with a workspace source)
for InstallMethod.NPM. uv.lock is regenerated.
A zero-dependency Node launcher (Node >=20) that runs deepctl at the
npm package's own version: a cached private venv, then
`uv tool run --from deepctl==<version>`, then a venv it builds under a
lock, then a clear error. First-run setup is async and supervised.
Signal handlers go in before setup starts, and each setup step runs in
its own session. A signal kills the step, removes the partial venv,
releases the lock and re-raises the signal. The lock's owner.json is
always replaced atomically and records the running step's process
group, so on Linux a run that breaks a stale lock can stop a pip left
behind by a SIGKILLed launcher. Cached venvs are reused only from roots
that pass an ownership check. Windows can't verify ACLs or reparse
points, so it never reuses a cache: it runs through uv, or builds a
one-run venv and deletes it afterwards. The README documents one known
limitation: Node resets inherited SIG_IGN before any JS runs, so
`nohup dg` and `trap '' INT` can't protect deepctl. .gitignore stops
ignoring npm/lib, and .gitattributes accepts that file's CRLF line
endings so `git diff --check` stays clean.
…n bumps

release-please now bumps npm/package.json with the root version. After
PyPI serves that exact version, a publish-npm job publishes the package
through trusted publishing. CI runs the launcher's tests on Node 20.0.0,
20, 22 and 24, and a cross-OS smoke test runs on Node 22.
check_dependency_floors.py gains SIBLING_FLOORS: update and skills pin
deepctl-core, and plugin pins deepctl-cmd-update, to the workspace
version, and --fix raises them. Rule 2 now evaluates whole specifiers.
Floors only hold if every changed package is actually released. So
check_release_bumps.py fails when a package's src/ or non-workspace
dependencies changed since its last release tag without a version bump.
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 a
test.yml job covers commits people push. It runs again in the build job
before PyPI. verify-published installs from PyPI alone, then runs
deepctl and imports the launcher.
The README and llms-full.txt document `npm install -g @deepgram/cli` and
`npx @deepgram/cli`. llms-full.txt still said 0.2.4, so it moves to
web/src/data/ and a prerendered endpoint now builds it with the version
from pyproject.toml, the same way index.astro already reads it. The
build fails if that version can't be read. The doc-command test follows
the file to its new path.
@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Replies to Greg's review on #146

Top-level note

Heads up: I squashed the branch to 4 commits on top of current main. The final code is identical to what you reviewed, there's no merge commit, and each commit builds and passes on its own:

  • dbd3a33 feat(cli): detect npm launcher installs in update, plugin and skills
  • 4e8a7c0 feat(npm): add the @deepgram/cli launcher package
  • 3a5ea7d ci(release): publish @deepgram/cli to npm and gate releases on version bumps
  • e90e1dd docs: document the npm install

installation.py:10 (sibling floors)

Fixed in 3a5ea7d (the floors pins themselves are in dbd3a33). check_dependency_floors.py now pins update → core, skills → core and plugin → update to the sibling's workspace version. --fix raises them, plugin declares deepctl-cmd-update, and the lock is regenerated. Today's floor is deepctl-core>=0.3.0, and the 0.3.0 on PyPI has no launcher.py. What makes it safe is the release bumping core: this fix triggers a core release, and the check then requires that new version before anything publishes. There's also a new release gate. It fails if any package's src/ or dependencies changed since its last tag without a version bump, which covers a chore: commit that release-please wouldn't bump. On the release PR it shows up as a release-bump-gate status. sync-release-pr posts it on the bot's commits, and a test.yml job covers anything we push by hand, so it flags a problem before merge. It only blocks the merge once it's required, so could one of us add release-bump-gate as a required status check on main's ruleset? The build job runs it again before the PyPI upload either way. verify-published now does a real PyPI-only install, runs deepctl --version, and imports the launcher. Tested in Docker: floors and gate tests plus make floors-check.

launcher.js:298 (Windows cache roots)

Fixed in 4e8a7c0. I went with your option: Windows never reuses a cache. No root is trusted, the %TEMP% fallback is gone, and a relative LOCALAPPDATA is ignored. With uv it runs uv tool run. Without uv, Windows pays about 1 min on every run, since it builds a fresh venv each time and deletes it afterwards. The hint gives the uv install command. Console close and Ctrl-Break also clean up now, and leftovers from a crash get pruned after an hour. The Windows CI smoke checks the one-run venv is gone afterwards.

launcher.js:625 (signals during setup)

Fixed in 4e8a7c0. Signal handlers go in before setup, setup is async (no spawnSync or sleepSync), and each setup step runs in its own session (setsid), so pip's children die with it. On a signal, the launcher removes the partial venv, releases the lock in finally, and re-raises the signal. There's a SIGTERM test for each setup step (venv, pip upgrade, pip install) that checks: no orphaned pids, no lock, no partial venv, and death by SIGTERM. The lock-wait test checks the exit signal and that the other run's lock is untouched; it doesn't check for orphans. If a launcher gets SIGKILLed, the next run on Linux kills the pip it left behind before rebuilding. The lock file is always replaced atomically, so nobody reads half of it. The signal tests pass 20/20 runs on a starved CPU (--cpus=0.4 plus load) on Linux, pass on macOS too, and fail on the old launcher. One thing I couldn't fix: nohup dg and trap '' INT don't carry over to deepctl, because Node resets ignored signals before our code runs. It's documented in the npm README with workarounds (setsid, & disown, tmux).

.gitignore:18 (trailing CR)

main's .gitignore is already CRLF, so I left the file alone and added .gitignore whitespace=cr-at-eol to .gitattributes, next to the existing output.py entry. git diff --check is clean. 4e8a7c0

Stale llms version (review summary)

llms-full.txt now gets its version from pyproject.toml at build time, like index.astro does, and the build fails if it can't read it. A Docker web build shows 0.4.0. e90e1dd

Also bumped engines to Node >=20 and added a Linux job that runs the launcher tests on Node 20.0.0, 20, 22 and 24. 3a5ea7d

Launcher failures printed as `dg: <message>` with the hint on a bare
second line. They now print as `ERROR: <message>` and `HINT: <hint>`,
the same two-line form the CLI uses, so a script or a person can tell
the problem from the fix. That covers setup failures, an interrupted
setup, a deepctl that can't start, and a deepctl that crashed.
…jobs

The launcher and release jobs this branch adds pin actions/checkout to a
commit whose tag is v7.0.1, but their comments said v4. They now name the
tag the SHA points at, and the jobs that install uv pin its version
(0.13.6) instead of taking the newest release.
The launcher used to install deepctl==<version> and let pip or uv
resolve everything else from the index. Each release now ships
requirements-<version>.txt in the npm package: deepctl and its workspace
packages with the hashes of the files PyPI serves for them (read from its
JSON API after the release is verified there), and every other
dependency pinned with uv.lock's hashes. The release's publish-npm job
writes it with npm/scripts/write_requirements.py, cross-checks it against
the files that run built, and checks that the tarball contains it.

Setup installs only from that file, with --require-hashes, --no-deps and
--only-binary :all:, so a download that differs from the release is
refused, nothing unlisted is installed, and no build backend runs.
`uv tool run --with-requirements` ignores hashes, so with uv the launcher
builds the same cached venv with `uv venv` and `uv pip install`, which
check them. Setup runs in a directory the launcher owns, uv skips
project configuration discovery (--no-config, with the user's or the
machine's uv.toml passed back explicitly) and Python runs with -I, so
running dg inside a repository can't change what gets installed, while
pip.conf, uv.toml and PIP_*/UV_* mirror settings still apply. The pip
fallback no longer upgrades pip first, since that download would be
unhashed. Python is limited to 3.10 to 3.14, the versions the lock has
wheels for. A package without its requirements file installs nothing.
DEEPCTL_NPM_PYPI_SPEC, which CI uses to test against another deepctl,
keeps the unhashed path.

CI gains a Windows and macOS job that runs the hash-locked install from a
local index (npm/scripts/local_index.py).
…rols

A cache root is reused only when rootTrusted shows it is ours: a real
directory, not a symlink, owned by this uid and not writable by group or
others. When no root passes, the launcher used to stop with "No writable
cache directory". It now builds a venv for that run in a fresh 0700
directory under the system temp directory, whose ancestors get the same
ownership check, deletes it afterwards, and says on stderr which
directory it would not trust and how to fix it. Nothing is written into
the untrusted root.

uv installs the files it extracted into its cache without hashing them
again, and its default cache ($XDG_CACHE_HOME/uv) can sit in a directory
others can write to. So uv always gets a launcher-owned cache: next to
the venvs under the trusted root, inside the run's own directory
(removed after setup, with pip running cacheless) when no root is
trusted, and next to the one-run venvs on Windows. An existing cache
directory that another user owns, or that group or others can write to,
is not used: that run gets a one-run venv, and the hint says to remove
the directory. A UV_CACHE_DIR the user set is left alone.

Windows never reuses a venv. Its one-run venvs go under
%LOCALAPPDATA%\deepgram-cli\venvs, and the directories the launcher
creates there must be plain directories, not symlinks or junctions,
before anything is built in them.
deepctl_core.launcher becomes deepctl_core._launcher, with no public
re-export, so plugins don't build on it. deepctl-cmd-update,
deepctl-cmd-skills, deepctl.main and the release's post-publish import
check use the new name.

capture_launcher() now takes the value from deepctl.main when that module
has already captured DEEPCTL_LAUNCHER at startup (LAUNCHER_CAPTURED), and
reads and removes the variable itself otherwise, so an entry point that
captures it first keeps working.
Comment thread npm/test/launcher.test.js Fixed
Run the two real-filesystem tests for the Linux cache path only on Linux and
macOS, as the other real-filesystem tests already are, and compare package
paths against the realpath of the temp directory, which macOS reports under
/private/var. Add a Windows test with a short-name (8.3) LOCALAPPDATA to show
only link and junction flags decide whether a directory is safe.
… tests

The launcher now tries python3.14 first, but the end-to-end and signal
tests only installed fake python3.13 to python3 executables. On a machine
with a real python3.14 on PATH, such as GitHub's ubuntu runners, the
launcher found that one instead, built a real venv and ran a real pip, so
those tests failed or timed out waiting for the fake step. The fakes now
cover every name the launcher probes, from one list.

Regular-expression escaping in the tests also goes through one helper
that escapes backslashes and every other special character.
@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Thanks Greg. I've pushed seven commits on top; nothing you already reviewed was rewritten.

  • Windows cache (chore: release main #2): Windows never reuses a venv. Each run builds a fresh one under %LOCALAPPDATA%\deepgram-cli\venvs and deletes it afterwards. The launcher refuses folders that are symlinks or junctions. It doesn't check owners or ACLs; it relies on %LOCALAPPDATA%'s per-user defaults. 41b1127
  • Linux/macOS cache: if no cache dir passes the ownership check, dg uses a private temp venv. uv always gets a cache the launcher owns, never the shared default.
  • Correction: the uv path no longer uses uv tool run, because it skips hash checks. It now runs uv pip install, the same as the pip path.
  • Hash-locked installs: each npm release ships requirements-<version>.txt with PyPI hashes, and setup installs only from it (--require-hashes --no-deps --only-binary :all:). A tampered wheel fails cleanly. New Windows and macOS CI jobs cover this, and they're green. e6cfd81
  • Platforms: because installs are wheels-only, Intel Macs and Windows on ARM64 can't install via npm yet (cryptography has no wheels there). The npm README lists other install options.
  • Smaller fixes:
  • Sibling floors (feat: workspace setup #1): lockstep versioning replaces these in a follow-up. release-bump-gate stays advisory; ignore my earlier ask to make it required.
  • Windows venv reuse: I recommend we keep it strict for this PR. Safe reuse needs a real ACL check on Windows. I'll measure the cost on a Windows runner and follow up if it's bad.

@GregHolmes GregHolmes left a comment

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.

This PR makes @deepgram/cli a Node launcher for the Python CLI and adds npm publishing after matching PyPI verification.

Requesting changes for three release-critical issues:

  • the POSIX cache can trust a group-writable ancestor based only on matching numeric UID/GID;
  • Windows still reuses an unverified uv cache;
  • the release gate does not watch the npm package files it publishes.

I verified the earlier sibling-floor and cached-venv fixes, and ran 109 Node launcher tests plus 204 targeted Python tests in Docker; they pass. All GitHub checks are green, but the current tests encode these remaining unsafe paths rather than rejecting them.

Comment thread npm/lib/launcher.js Outdated
const link = st.isSymbolicLink ? st.isSymbolicLink() : false;
if (link ? !allowLinks : !st.isDirectory()) return false;
if (st.uid !== 0 && st.uid !== uid) return false;
const privateGroup = st.uid === uid && st.gid === gid && gid === uid;

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.

[B1] Numeric UID/GID equality does not prove a group-writable cache is private

A cached deepctl must run only from a directory no other account can modify. This accepts a group-writable ancestor whenever its numeric GID equals the current UID, but POSIX does not guarantee that group has only one member; an administrator can add another account to it, which can replace the cache after this check.

Reject every group-writable cache ancestor unless group membership can be established safely. The minimal safe path is to use the existing one-run private-venv fallback, with a regression test for a matching numeric GID that includes another user.


@property
def src(self) -> str:
return "src" if self.path == "." else f"{self.path}/src"

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.

[B3] The release gate ignores the npm package’s published code

Every file shipped by @deepgram/cli must trigger a root version bump before publication. The root package checks only src/, but the npm package ships npm/bin/ and npm/lib/; a future chore: or refactor: to the launcher passes this gate without a release.

Model the root package’s published paths explicitly, including the npm tarball inputs, and add a fixture changing npm/lib/launcher.js that fails the root release-bump gate.

Comment thread npm/lib/launcher.js Outdated
dir = fs.mkdtempSync(p.join(base, `${prefix}${key}-`));
// Windows: a uv cache kept beside the one-run venvs, held to the same
// plain-directory check (oneRunBase), so runs after the first are quick.
if (!untrusted && installer.uv) uvCache = ownedDir(p.join(p.dirname(base), 'uv-cache'), opts);

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.

[B2] Windows reuses an unverified uv cache

Windows must not reuse a default uv cache unless the launcher validates its owner, ACLs, and reparse-point ancestors. ownedDir() skips those checks on Windows, so this persistent cache can contain a package artifact written by another account; uv then reuses extracted cache entries without re-hashing them.

Disable the default persistent uv cache on Windows until equivalent ACL validation exists. Use no-cache mode or a per-run cache that is deleted after setup, and add a Windows regression test proving that a pre-existing cache is never reused.

dg-coreylweathers and others added 3 commits November 2, 2026 21:23
A group id equal to our uid (the user-private-group convention) does not
prove the group has no other members: an administrator can add one, and
Node cannot list them reliably. Any ancestor writable by group or others
now has to be sticky, like /tmp. A umask-002 ~/.cache (0775) therefore
fails, and the venvs go to the $TMPDIR/deepgram-cli-<uid> root, which is
held to the same checks and still caches across runs. The npm README says
where the cache goes in that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uv installs files it extracted into its cache without hashing them again,
so a cache that outlives the run has to be as trusted as a venv. Windows
cannot check owners, ACLs or reparse points from Node, so it no longer
keeps %LOCALAPPDATA%\deepgram-cli\uv-cache: uv gets a cache inside the
run's own directory, removed once setup is done. pip keeps its own cache,
since with --require-hashes it hashes every file it installs.

A UV_CACHE_DIR in the environment no longer switches the launcher's cache
off: tools set it without the user choosing to (astral-sh/setup-uv exports
it on GitHub's runners), so the launcher always passes --cache-dir, which
uv puts ahead of UV_CACHE_DIR.

The hash-locked Windows CI job plants a uv cache where the launcher used
to keep it and fails if uv writes to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release bump gate watched only each package's src/. The root package
also publishes @deepgram/cli, so a chore: or refactor: to npm/lib/ or
npm/bin/ passed without a release. The root now also watches every entry
in npm/package.json's `files`, read from HEAD so new entries are covered,
with a glob matching at any depth as npm does, and package.json itself.
npm/README.md and npm/LICENSE stay out, like the packages' READMEs: a
docs change should not force a release.

pyproject.toml is now compared on the metadata that reaches the wheel, not
just dependencies: the [project] table without its version, [build-system],
[tool.setuptools], and [tool.hatch]'s build and metadata tables. An entry
point or requires-python change without a bump is caught too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants