From c2ab7bd16ecd6f5c652c296b72eeffbd99aee5fd Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:47:43 -0400 Subject: [PATCH 01/21] feat(npm): add @deepgram/cli launcher package for the Python CLI A zero-dependency Node launcher that runs deepctl from PyPI at the same version as the npm package: a cached venv first, then uv tool run, then a venv built from Python 3.10+, with a clear error when none is available. Launcher output stays on stderr, exit codes and signals pass through, and the child sees DEEPCTL_LAUNCHER=npm. --- .gitignore | 2 + npm/README.md | 72 +++++ npm/bin/dg.js | 4 + npm/lib/launcher.js | 543 ++++++++++++++++++++++++++++++++++++++ npm/package.json | 42 +++ npm/test/launcher.test.js | 417 +++++++++++++++++++++++++++++ 6 files changed, 1080 insertions(+) create mode 100644 npm/README.md create mode 100755 npm/bin/dg.js create mode 100644 npm/lib/launcher.js create mode 100644 npm/package.json create mode 100644 npm/test/launcher.test.js diff --git a/.gitignore b/.gitignore index 77c7229..388075d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ downloads/ eggs/ .eggs/ lib/ +# The npm launcher's sources, not a build output. +!/npm/lib/ lib64/ parts/ sdist/ diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..d53691e --- /dev/null +++ b/npm/README.md @@ -0,0 +1,72 @@ +# @deepgram/cli + +The official [Deepgram CLI](https://github.com/deepgram/cli): speech-to-text, +text-to-speech, voice agents, audio intelligence and project management from +your terminal. + +```bash +npx @deepgram/cli --help +``` + +or install it globally: + +```bash +npm install -g @deepgram/cli +dg login +dg listen recording.wav +dg speak "Hello from Deepgram" -o hello.wav +``` + +The package installs two commands, `dg` and `deepgram`. They are the same. + +## How it works + +The Deepgram CLI is written in Python and published to PyPI as +[`deepctl`](https://pypi.org/project/deepctl/). This npm package is a small +launcher with no dependencies and no install scripts. It runs `deepctl` at the +same version as the npm package: + +1. If an earlier run already set up a private Python environment for this + version, it runs that. This is fast and works offline. +2. Otherwise, if [uv](https://docs.astral.sh/uv/) is installed, it runs + `uv tool run --from deepctl== deepctl`. uv brings its own Python. +3. Otherwise it finds Python 3.10 or newer and builds a private venv under + your cache directory (`~/.cache/deepgram-cli`, `~/Library/Caches/deepgram-cli` + or `%LOCALAPPDATA%\deepgram-cli`). The first run takes about a minute. + +Launcher messages go to stderr, so stdout carries only the CLI's own output +(`dg ... -o json`, `dg mcp`). + +## Prerequisites + +One of: + +- [uv](https://docs.astral.sh/uv/getting-started/installation/) (recommended): + `curl -LsSf https://astral.sh/uv/install.sh | sh`, or on Windows + `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"` +- Python 3.10 or newer with `venv`. On Debian and Ubuntu that means + `sudo apt install python3-venv`. + +Node.js 18 or newer runs the launcher. + +## Already installed the CLI another way? + +If you installed the CLI with Homebrew, pip, pipx, uv or the install script, +you don't need this package. Homebrew and npm both want to own `dg`; if you +do want the npm version to take it over, run `npm install -g --force @deepgram/cli`. + +Update the npm version with `npm install -g @deepgram/cli@latest`, or run +`npx @deepgram/cli@latest`. + +## Environment variables + +| Variable | Effect | +| --- | --- | +| `DEEPCTL_NPM_RUNNER=uvx\|venv\|path` | Force one strategy. `path` runs a `deepctl` you installed yourself from your `PATH`. | +| `DEEPCTL_NPM_PYPI_SPEC` | Replace the requirement installed from PyPI (default `deepctl==`). Meant for testing. | +| `XDG_CACHE_HOME` | Cache directory base on Linux. | + +## Documentation + +- CLI reference and source: +- Deepgram docs: diff --git a/npm/bin/dg.js b/npm/bin/dg.js new file mode 100755 index 0000000..761c717 --- /dev/null +++ b/npm/bin/dg.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +'use strict'; + +require('../lib/launcher.js').run(); diff --git a/npm/lib/launcher.js b/npm/lib/launcher.js new file mode 100644 index 0000000..a33c264 --- /dev/null +++ b/npm/lib/launcher.js @@ -0,0 +1,543 @@ +'use strict'; + +// Runs the Python Deepgram CLI (deepctl, from PyPI) at the same version as +// this npm package. Nothing here is downloaded at install time: the first +// `dg` either hands off to uv, reuses a cached venv, or builds one. + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const PKG_DIR = path.resolve(__dirname, '..'); +const MIN_PYTHON = [3, 10]; +const LOCK_WAIT_MS = 5 * 60 * 1000; +const LOCK_STALE_MS = 10 * 60 * 1000; +const LOCK_POLL_MS = 250; +const PROBE_TIMEOUT_MS = 10 * 1000; +const RUNNERS = ['uvx', 'venv', 'path']; + +// Prints the version first so a Python without venv/ensurepip (Debian's +// python3 without python3-venv) still reports what it is. +const PROBE_CODE = 'import sys;print(tuple(sys.version_info[:2]));import venv,ensurepip'; + +class LauncherError extends Error { + constructor(message, hint = '') { + super(message); + this.hint = hint; + } +} + +function packageVersion(pkgDir = PKG_DIR) { + return JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')).version; +} + +function pypiSpec(env, version) { + return env.DEEPCTL_NPM_PYPI_SPEC || `deepctl==${version}`; +} + +// Venvs are keyed by version. An overridden spec gets its own directory so +// a CI run against a different deepctl never poisons the real cache entry. +function venvKey(env, version) { + if (!env.DEEPCTL_NPM_PYPI_SPEC) return version; + const hash = crypto.createHash('sha256').update(env.DEEPCTL_NPM_PYPI_SPEC).digest('hex'); + return `${version}+${hash.slice(0, 12)}`; +} + +// --- PATH resolution ------------------------------------------------------ + +function isWindows(platform) { + return platform === 'win32'; +} + +function exeNames(name, platform) { + // No PATHEXT and no .cmd/.bat: those need a shell to run, and a shell + // re-parses the arguments we pass through. + return isWindows(platform) ? [`${name}.exe`, `${name}.com`] : [name]; +} + +function pathDirs(env, platform) { + const raw = (isWindows(platform) ? env.Path || env.PATH : env.PATH) || ''; + const p = isWindows(platform) ? path.win32 : path.posix; + // Empty and relative entries resolve against the current directory, and + // Windows searches it implicitly. A launcher run from inside an arbitrary + // project must not pick up an executable that happens to sit there. + return raw.split(p.delimiter).filter((dir) => dir && p.isAbsolute(dir)); +} + +function isExecutableFile(file, platform = process.platform) { + try { + const stat = fs.statSync(file); + if (!stat.isFile()) return false; + if (isWindows(platform)) return true; + fs.accessSync(file, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function realpathOrSelf(file) { + try { + return fs.realpathSync(file); + } catch { + return file; + } +} + +function isInside(child, parent, platform) { + const p = isWindows(platform) ? path.win32 : path.posix; + const rel = p.relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !p.isAbsolute(rel)); +} + +// Every match for `name` on PATH, in order, minus anything that resolves +// into this package (npm's own `dg` shim must never be mistaken for deepctl). +function whichAll(name, opts = {}) { + const env = opts.env || process.env; + const platform = opts.platform || process.platform; + const isExec = opts.isExecutable || ((f) => isExecutableFile(f, platform)); + const realpath = opts.realpath || realpathOrSelf; + const pkgDir = realpath(opts.pkgDir || PKG_DIR); + const join = isWindows(platform) ? path.win32.join : path.posix.join; + const found = []; + for (const dir of pathDirs(env, platform)) { + for (const exe of exeNames(name, platform)) { + const candidate = join(dir, exe); + if (!isExec(candidate)) continue; + if (isInside(realpath(candidate), pkgDir, platform)) continue; + if (!found.includes(candidate)) found.push(candidate); + } + } + return found; +} + +function which(name, opts) { + return whichAll(name, opts)[0] || null; +} + +function isWindowsAppsStub(file) { + return /[\\/]WindowsApps[\\/]/i.test(file); +} + +// --- uv ------------------------------------------------------------------- + +function findUv(opts = {}) { + const platform = opts.platform || process.platform; + const home = opts.home || os.homedir(); + const isExec = opts.isExecutable || ((f) => isExecutableFile(f, platform)); + const onPath = which('uv', opts); + if (onPath) return onPath; + // The uv installer drops it here but only edits shell profiles, so a + // fresh install is often not on PATH in the shell npm was started from. + const join = isWindows(platform) ? path.win32.join : path.posix.join; + const exe = isWindows(platform) ? 'uv.exe' : 'uv'; + for (const dir of [join(home, '.local', 'bin'), join(home, '.cargo', 'bin')]) { + const candidate = join(dir, exe); + if (isExec(candidate)) return candidate; + } + return null; +} + +// --- Python --------------------------------------------------------------- + +function pythonCandidates(opts = {}) { + const platform = opts.platform || process.platform; + const candidates = []; + if (isWindows(platform)) { + const py = whichAll('py', opts).filter((f) => !isWindowsAppsStub(f))[0]; + if (py) { + for (const minor of [13, 12, 11, 10]) candidates.push({ command: py, args: [`-3.${minor}`] }); + } + for (const exe of whichAll('python', opts)) { + if (!isWindowsAppsStub(exe)) candidates.push({ command: exe, args: [] }); + } + return candidates; + } + for (const name of ['python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3']) { + for (const exe of whichAll(name, opts)) { + // Running Apple's stub pops the "install command line developer + // tools" dialog instead of failing quietly. + if (platform === 'darwin' && exe === '/usr/bin/python3') continue; + if (!candidates.some((c) => c.command === exe)) candidates.push({ command: exe, args: [] }); + } + } + return candidates; +} + +function parseProbeOutput(stdout) { + const match = /\((\d+),\s*(\d+)\)/.exec(String(stdout || '')); + return match ? [Number(match[1]), Number(match[2])] : null; +} + +function versionAtLeast(version, minimum) { + return version[0] > minimum[0] || (version[0] === minimum[0] && version[1] >= minimum[1]); +} + +function probePython(candidate, spawnSync = childProcess.spawnSync) { + const result = spawnSync(candidate.command, [...candidate.args, '-c', PROBE_CODE], { + encoding: 'utf8', + timeout: PROBE_TIMEOUT_MS, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const version = parseProbeOutput(result.stdout); + if (!version) return { version: null, ok: false }; + return { version, ok: result.status === 0 && versionAtLeast(version, MIN_PYTHON), hasVenv: result.status === 0 }; +} + +// Returns the first usable Python plus what was seen on the way, so the +// failure message can say "found 3.9" or "found 3.12 without venv". +function findPython(opts = {}) { + const probe = opts.probe || probePython; + const seen = []; + for (const candidate of pythonCandidates(opts)) { + const info = probe(candidate); + if (!info.version) continue; + seen.push({ ...candidate, ...info }); + if (info.ok) return { python: candidate, seen }; + } + return { python: null, seen }; +} + +// --- Cache directory ------------------------------------------------------ + +function cacheRoots(opts = {}) { + const env = opts.env || process.env; + const platform = opts.platform || process.platform; + const home = opts.home || os.homedir(); + const tmpdir = opts.tmpdir || os.tmpdir(); + const uid = opts.uid !== undefined ? opts.uid : process.getuid ? process.getuid() : 'user'; + const p = isWindows(platform) ? path.win32 : path.posix; + let base; + if (isWindows(platform)) base = env.LOCALAPPDATA || p.join(home, 'AppData', 'Local'); + else if (platform === 'darwin') base = p.join(home, 'Library', 'Caches'); + else base = env.XDG_CACHE_HOME && p.isAbsolute(env.XDG_CACHE_HOME) ? env.XDG_CACHE_HOME : p.join(home, '.cache'); + // The fallback covers containers run as an arbitrary UID with a HOME that + // does not exist or is read-only. + return [p.join(base, 'deepgram-cli', 'venvs'), p.join(tmpdir, `deepgram-cli-${uid}`, 'venvs')]; +} + +function ensureWritableDir(dir) { + try { + fs.mkdirSync(dir, { recursive: true }); + fs.accessSync(dir, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +function venvLayout(dir, platform = process.platform) { + const p = isWindows(platform) ? path.win32 : path.posix; + const bin = isWindows(platform) ? p.join(dir, 'Scripts') : p.join(dir, 'bin'); + const ext = isWindows(platform) ? '.exe' : ''; + return { + dir, + marker: p.join(dir, '.complete'), + python: p.join(bin, `python${ext}`), + deepctl: p.join(bin, `deepctl${ext}`), + }; +} + +function findCachedVenv(key, opts = {}) { + const exists = opts.exists || fs.existsSync; + const p = isWindows(opts.platform || process.platform) ? path.win32 : path.posix; + for (const root of cacheRoots(opts)) { + const layout = venvLayout(p.join(root, key), opts.platform); + if (exists(layout.marker) && exists(layout.deepctl)) return layout; + } + return null; +} + +// --- Lock ----------------------------------------------------------------- + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function pidAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err.code === 'EPERM'; + } +} + +function lockIsStale(lockDir, now = Date.now(), alive = pidAlive) { + let owner = null; + try { + owner = JSON.parse(fs.readFileSync(path.join(lockDir, 'owner.json'), 'utf8')); + } catch { + // The owner may be between mkdir and writing owner.json; fall back to + // the directory's age. + } + if (owner && typeof owner.pid === 'number') { + if (now - owner.time > LOCK_STALE_MS) return true; + return owner.host === os.hostname() && !alive(owner.pid); + } + try { + return now - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS; + } catch { + return false; + } +} + +// mkdir is atomic on every platform and filesystem npm runs on, unlike +// O_EXCL files on some network mounts. +function acquireLock(lockDir, opts = {}) { + const waitMs = opts.waitMs !== undefined ? opts.waitMs : LOCK_WAIT_MS; + const pollMs = opts.pollMs || LOCK_POLL_MS; + const onWait = opts.onWait || (() => {}); + const deadline = Date.now() + waitMs; + let announced = false; + for (;;) { + try { + fs.mkdirSync(lockDir); + fs.writeFileSync( + path.join(lockDir, 'owner.json'), + JSON.stringify({ pid: process.pid, host: os.hostname(), time: Date.now() }), + ); + return () => fs.rmSync(lockDir, { recursive: true, force: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + if (lockIsStale(lockDir)) { + fs.rmSync(lockDir, { recursive: true, force: true }); + continue; + } + if (Date.now() >= deadline) { + throw new LauncherError(`Timed out waiting for another dg setup to finish (lock: ${lockDir}).`); + } + if (!announced) { + onWait(); + announced = true; + } + sleepSync(pollMs); + } +} + +// --- Venv bootstrap ------------------------------------------------------- + +function runStep(command, args, what, hint, spawnSync) { + // stdout goes to stderr: `dg -o json` and `dg mcp` own stdout. + const result = spawnSync(command, args, { stdio: ['ignore', 2, 2], windowsHide: true }); + if (result.error) throw new LauncherError(`${what} failed: ${result.error.message}.`, hint); + if (result.status !== 0) throw new LauncherError(`${what} failed (exit ${result.status}).`, hint); +} + +const VENV_HINT = 'On Debian or Ubuntu, install python3-venv (sudo apt install python3-venv), or install uv instead.'; +const NETWORK_HINT = 'Check your network connection and that PyPI is reachable, then run dg again.'; + +function pruneSiblings(root, keep) { + try { + for (const entry of fs.readdirSync(root)) { + if (entry === keep || entry.endsWith('.lock')) continue; + if (fs.existsSync(path.join(root, `${entry}.lock`))) continue; + fs.rmSync(path.join(root, entry), { recursive: true, force: true }); + } + } catch { + // Best effort: an old version left behind only costs disk space. + } +} + +function buildVenv({ python, key, spec, version, opts = {} }) { + const stderr = opts.stderr || process.stderr; + const spawnSync = opts.spawnSync || childProcess.spawnSync; + const root = cacheRoots(opts).find(ensureWritableDir); + if (!root) throw new LauncherError('No writable cache directory for the Deepgram CLI.', 'Set XDG_CACHE_HOME to a writable directory, or install uv.'); + // Built in place: venv console scripts carry absolute shebangs, so a venv + // built elsewhere and renamed into place would not run. + const layout = venvLayout(path.join(root, key), opts.platform); + const release = acquireLock(`${layout.dir}.lock`, { + ...opts.lock, + onWait: () => stderr.write('Waiting for another dg setup to finish...\n'), + }); + const onExit = () => release(); + process.once('exit', onExit); + try { + if (fs.existsSync(layout.marker)) return layout; + fs.rmSync(layout.dir, { recursive: true, force: true }); + 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); + runStep(pip[0], [...pip.slice(1), '-U', 'pip'], 'Upgrading pip', NETWORK_HINT, spawnSync); + runStep(pip[0], [...pip.slice(1), spec], `Installing ${spec}`, NETWORK_HINT, spawnSync); + fs.writeFileSync(layout.marker, `${spec}\n`); + } catch (err) { + fs.rmSync(layout.dir, { recursive: true, force: true }); + throw err; + } + pruneSiblings(root, key); + return layout; + } finally { + process.removeListener('exit', onExit); + release(); + } +} + +// --- Child environment and exit mapping ------------------------------------ + +function childEnv(env, strategy) { + const out = { ...env }; + out.DEEPCTL_NPM_LAUNCHER_DEPTH = String((parseInt(env.DEEPCTL_NPM_LAUNCHER_DEPTH, 10) || 0) + 1); + // A deepctl the user installed themselves knows its own install method; + // telling it "npm" would make `dg update` give the wrong advice. + if (strategy === 'path') delete out.DEEPCTL_LAUNCHER; + else out.DEEPCTL_LAUNCHER = 'npm'; + return out; +} + +function exitAction(code, signal, platform = process.platform) { + if (code !== null && code !== undefined) return { exit: code }; + if (signal && !isWindows(platform)) { + const num = os.constants.signals[signal]; + return { signal, exit: num ? 128 + num : 1 }; + } + return { exit: 1 }; +} + +// --- Plan ----------------------------------------------------------------- + +function installHelp(seen) { + const lines = ['', 'The Deepgram CLI needs uv or Python 3.10+ (with venv) to run. Fix one of:', '']; + lines.push(' 1. Install uv (recommended), then run dg again:'); + lines.push(' curl -LsSf https://astral.sh/uv/install.sh | sh'); + lines.push(' powershell -c "irm https://astral.sh/uv/install.ps1 | iex" (Windows)'); + const noVenv = seen.find((s) => s.version && versionAtLeast(s.version, MIN_PYTHON) && !s.hasVenv); + if (noVenv) { + lines.push(` 2. Python ${noVenv.version.join('.')} was found without venv/ensurepip. Install it:`); + lines.push(' sudo apt install python3-venv (Debian/Ubuntu)'); + } else { + const old = seen.find((s) => s.version && !versionAtLeast(s.version, MIN_PYTHON)); + const found = old ? ` (found ${old.version.join('.')})` : ''; + lines.push(` 2. Install Python 3.10 or newer${found}.`); + } + lines.push(' 3. Or install the CLI directly:'); + lines.push(' curl -fsSL https://deepgram.com/install.sh | sh'); + lines.push(' brew install deepgram/tap/deepgram'); + return `${lines.join('\n')}\n`; +} + +// Decides how to run deepctl. Returns {command, args, strategy}; may build a +// venv as a side effect. +function resolveRunner(argv, opts = {}) { + const env = opts.env || process.env; + const version = opts.version || packageVersion(); + const spec = pypiSpec(env, version); + const key = venvKey(env, version); + const forced = env.DEEPCTL_NPM_RUNNER || ''; + if (forced && !RUNNERS.includes(forced)) { + throw new LauncherError(`DEEPCTL_NPM_RUNNER must be one of ${RUNNERS.join(', ')} (got "${forced}").`); + } + + if (forced === 'path') { + const deepctl = which('deepctl', opts); + if (!deepctl) throw new LauncherError('DEEPCTL_NPM_RUNNER=path, but no deepctl was found on PATH.'); + return { command: deepctl, args: argv, strategy: 'path' }; + } + + if (!forced || forced === 'venv') { + const cached = findCachedVenv(key, opts); + if (cached) return { command: cached.deepctl, args: argv, strategy: 'venv' }; + } + + if (!forced || forced === 'uvx') { + const uv = findUv(opts); + if (uv) return { command: uv, args: ['tool', 'run', '--from', spec, 'deepctl', ...argv], strategy: 'uvx' }; + if (forced) throw new LauncherError('DEEPCTL_NPM_RUNNER=uvx, but uv was not found.'); + } + + const { python, seen } = findPython(opts); + if (!python) throw new LauncherError(installHelp(seen)); + const layout = buildVenv({ python, key, spec, version, opts }); + return { command: layout.deepctl, args: argv, strategy: 'venv' }; +} + +function run(argv = process.argv.slice(2), opts = {}) { + const env = opts.env || process.env; + const stderr = opts.stderr || process.stderr; + if ((parseInt(env.DEEPCTL_NPM_LAUNCHER_DEPTH, 10) || 0) >= 1) { + stderr.write('dg: the npm launcher started itself recursively; aborting. Check DEEPCTL_NPM_RUNNER and PATH.\n'); + process.exit(1); + } + + let runner; + try { + runner = resolveRunner(argv, opts); + } catch (err) { + if (!(err instanceof LauncherError)) throw err; + const hint = err.hint ? `\n${err.hint}` : ''; + stderr.write(`dg: ${err.message.trimStart()}${hint}\n`); + process.exit(1); + } + + const child = childProcess.spawn(runner.command, runner.args, { + stdio: 'inherit', + env: childEnv(env, runner.strategy), + }); + + // Ctrl-C and Ctrl-\ reach the child straight from the terminal (same + // process group); the parent only has to survive them. TERM and HUP come + // from whoever is managing *this* process, so pass them on. + const ignore = () => {}; + const forward = (sig) => () => child.kill(sig); + const handlers = { SIGINT: ignore, SIGTERM: forward('SIGTERM') }; + if (!isWindows(process.platform)) { + handlers.SIGQUIT = ignore; + handlers.SIGHUP = forward('SIGHUP'); + } + for (const [sig, fn] of Object.entries(handlers)) process.on(sig, fn); + + child.on('error', (err) => { + stderr.write(`dg: could not start ${runner.command}: ${err.message}\n`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + for (const [sig, fn] of Object.entries(handlers)) process.removeListener(sig, fn); + const action = exitAction(code, signal); + if (action.signal) { + // Die the same way the child did, so shells and supervisors see the + // signal rather than a plain exit code. + process.removeAllListeners(action.signal); + try { + process.kill(process.pid, action.signal); + } catch { + // Fall through to the numeric exit below. + } + setTimeout(() => process.exit(action.exit), 100); + return; + } + process.exit(action.exit); + }); +} + +module.exports = { + LauncherError, + acquireLock, + cacheRoots, + childEnv, + exitAction, + exeNames, + findCachedVenv, + findPython, + findUv, + installHelp, + lockIsStale, + packageVersion, + parseProbeOutput, + pathDirs, + pypiSpec, + pythonCandidates, + resolveRunner, + run, + venvKey, + venvLayout, + versionAtLeast, + which, + whichAll, +}; diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..35f9c1c --- /dev/null +++ b/npm/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepgram/cli", + "version": "0.4.0", + "description": "The official Deepgram CLI: speech-to-text, text-to-speech, voice agents and audio intelligence from your terminal", + "type": "commonjs", + "bin": { + "dg": "bin/dg.js", + "deepgram": "bin/dg.js" + }, + "files": [ + "bin/", + "lib/" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "keywords": [ + "deepgram", + "cli", + "speech-to-text", + "text-to-speech", + "transcription", + "voice-agent" + ], + "homepage": "https://github.com/deepgram/cli#readme", + "bugs": { + "url": "https://github.com/deepgram/cli/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepgram/cli.git", + "directory": "npm" + }, + "license": "MIT", + "author": "Deepgram ", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js new file mode 100644 index 0000000..7bfbc22 --- /dev/null +++ b/npm/test/launcher.test.js @@ -0,0 +1,417 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test, describe } = require('node:test'); + +const launcher = require('../lib/launcher.js'); + +const PKG_DIR = path.resolve(__dirname, '..'); +const BIN = path.join(PKG_DIR, 'bin', 'dg.js'); + +function tmpdir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dg-launcher-test-')); +} + +function fakeExe(dir, name, body = '#!/bin/sh\nexit 0\n') { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, name); + fs.writeFileSync(file, body); + fs.chmodSync(file, 0o755); + return file; +} + +const posixOnly = { skip: process.platform === 'win32' && 'POSIX shell scripts' }; + +describe('package.json', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8')); + + test('every bin points at one target, so `npx @deepgram/cli` resolves', () => { + assert.deepEqual(new Set(Object.values(pkg.bin)), new Set(['bin/dg.js'])); + assert.deepEqual(Object.keys(pkg.bin).sort(), ['deepgram', 'dg']); + }); + + test('ships no deepctl bin, dependencies or install scripts', () => { + assert.equal(pkg.bin.deepctl, undefined); + assert.equal(pkg.dependencies, undefined); + for (const hook of ['preinstall', 'install', 'postinstall', 'prepare']) { + assert.equal(pkg.scripts && pkg.scripts[hook], undefined); + } + }); + + test('version is a plain release version', () => { + assert.match(launcher.packageVersion(PKG_DIR), /^\d+\.\d+\.\d+$/); + }); +}); + +describe('PyPI spec', () => { + test('defaults to the package version', () => { + assert.equal(launcher.pypiSpec({}, '1.2.3'), 'deepctl==1.2.3'); + assert.equal(launcher.venvKey({}, '1.2.3'), '1.2.3'); + }); + + test('DEEPCTL_NPM_PYPI_SPEC overrides it and gets its own venv key', () => { + const env = { DEEPCTL_NPM_PYPI_SPEC: 'deepctl==1.0.0' }; + assert.equal(launcher.pypiSpec(env, '1.2.3'), 'deepctl==1.0.0'); + assert.match(launcher.venvKey(env, '1.2.3'), /^1\.2\.3\+[0-9a-f]{12}$/); + }); +}); + +describe('PATH resolution', () => { + test('skips empty and relative entries', () => { + assert.deepEqual(launcher.pathDirs({ PATH: ':/usr/bin::./bin:bin:/opt/x' }, 'linux'), ['/usr/bin', '/opt/x']); + }); + + test('Windows reads Path, splits on ; and only accepts .exe/.com', () => { + const env = { Path: 'C:\\Tools;;.\\local;D:\\bin' }; + assert.deepEqual(launcher.pathDirs(env, 'win32'), ['C:\\Tools', 'D:\\bin']); + assert.deepEqual(launcher.exeNames('uv', 'win32'), ['uv.exe', 'uv.com']); + const seen = []; + const found = launcher.whichAll('uv', { + env, + platform: 'win32', + pkgDir: 'C:\\npm\\node_modules\\@deepgram\\cli', + realpath: (f) => f, + isExecutable: (f) => { + seen.push(f); + return f === 'D:\\bin\\uv.exe'; + }, + }); + assert.deepEqual(found, ['D:\\bin\\uv.exe']); + assert.ok(seen.every((f) => /\.(exe|com)$/.test(f))); + }); + + test('finds executables in PATH order', posixOnly, () => { + const dir = tmpdir(); + const a = fakeExe(path.join(dir, 'a'), 'tool'); + const b = fakeExe(path.join(dir, 'b'), 'tool'); + fs.writeFileSync(path.join(dir, 'c-tool'), 'not executable'); + const env = { PATH: [path.join(dir, 'a'), path.join(dir, 'b')].join(':') }; + assert.deepEqual(launcher.whichAll('tool', { env, platform: 'linux' }), [a, b]); + }); + + test('never resolves into the launcher package (recursion guard)', posixOnly, () => { + const dir = tmpdir(); + const pkgDir = path.join(dir, 'node_modules', '@deepgram', 'cli'); + const inside = fakeExe(path.join(pkgDir, 'bin'), 'deepctl'); + const shims = path.join(dir, 'shims'); + fs.mkdirSync(shims); + fs.symlinkSync(inside, path.join(shims, 'deepctl')); + const real = fakeExe(path.join(dir, 'real'), 'deepctl'); + const env = { PATH: [shims, path.join(pkgDir, 'bin'), path.join(dir, 'real')].join(':') }; + assert.deepEqual(launcher.whichAll('deepctl', { env, pkgDir, platform: 'linux' }), [real]); + }); +}); + +describe('uv discovery', () => { + test('prefers PATH, then ~/.local/bin, then ~/.cargo/bin', () => { + const present = new Set(['/home/u/.local/bin/uv', '/home/u/.cargo/bin/uv']); + const opts = { env: { PATH: '/usr/bin' }, platform: 'linux', home: '/home/u', realpath: (f) => f, isExecutable: (f) => present.has(f) }; + assert.equal(launcher.findUv(opts), '/home/u/.local/bin/uv'); + present.add('/usr/bin/uv'); + assert.equal(launcher.findUv(opts), '/usr/bin/uv'); + present.clear(); + assert.equal(launcher.findUv(opts), null); + }); + + test('Windows fallbacks use uv.exe', () => { + const opts = { + env: { Path: 'C:\\Windows' }, + platform: 'win32', + pkgDir: 'C:\\npm\\node_modules\\@deepgram\\cli', + home: 'C:\\Users\\u', + realpath: (f) => f, + isExecutable: (f) => f === 'C:\\Users\\u\\.cargo\\bin\\uv.exe', + }; + assert.equal(launcher.findUv(opts), 'C:\\Users\\u\\.cargo\\bin\\uv.exe'); + }); +}); + +describe('Python discovery', () => { + test('parses the probe output', () => { + assert.deepEqual(launcher.parseProbeOutput('(3, 12)\n'), [3, 12]); + assert.deepEqual(launcher.parseProbeOutput('(3, 9)\nTraceback...'), [3, 9]); + assert.equal(launcher.parseProbeOutput(''), null); + assert.equal(launcher.parseProbeOutput(undefined), null); + }); + + test('compares versions', () => { + assert.ok(launcher.versionAtLeast([3, 10], [3, 10])); + assert.ok(launcher.versionAtLeast([4, 0], [3, 10])); + assert.ok(!launcher.versionAtLeast([3, 9], [3, 10])); + }); + + test('POSIX probe order, skipping the macOS /usr/bin/python3 stub', () => { + const present = new Set(['/usr/bin/python3', '/usr/local/bin/python3.11', '/usr/local/bin/python3']); + const opts = { env: { PATH: '/usr/local/bin:/usr/bin' }, realpath: (f) => f, isExecutable: (f) => present.has(f) }; + const darwin = launcher.pythonCandidates({ ...opts, platform: 'darwin' }).map((c) => c.command); + assert.deepEqual(darwin, ['/usr/local/bin/python3.11', '/usr/local/bin/python3']); + const linux = launcher.pythonCandidates({ ...opts, platform: 'linux' }).map((c) => c.command); + assert.deepEqual(linux, ['/usr/local/bin/python3.11', '/usr/local/bin/python3', '/usr/bin/python3']); + }); + + test('Windows tries py -3.13..-3.10, then python, skipping the Store stub', () => { + const present = new Set([ + 'C:\\Windows\\py.exe', + 'C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe', + 'C:\\Python312\\python.exe', + ]); + const opts = { + env: { Path: 'C:\\Windows;C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps;C:\\Python312' }, + platform: 'win32', + pkgDir: 'C:\\npm\\node_modules\\@deepgram\\cli', + realpath: (f) => f, + isExecutable: (f) => present.has(f), + }; + const got = launcher.pythonCandidates(opts).map((c) => [c.command, ...c.args].join(' ')); + assert.deepEqual(got, [ + 'C:\\Windows\\py.exe -3.13', + 'C:\\Windows\\py.exe -3.12', + 'C:\\Windows\\py.exe -3.11', + 'C:\\Windows\\py.exe -3.10', + 'C:\\Python312\\python.exe', + ]); + }); + + test('findPython returns the first usable one and records the rest', () => { + const present = new Set(['/usr/bin/python3.12', '/usr/bin/python3']); + const probes = { + '/usr/bin/python3.12': { version: [3, 12], ok: false, hasVenv: false }, + '/usr/bin/python3': { version: [3, 11], ok: true, hasVenv: true }, + }; + const opts = { + env: { PATH: '/usr/bin' }, + platform: 'linux', + realpath: (f) => f, + isExecutable: (f) => present.has(f), + probe: (c) => probes[c.command], + }; + const { python, seen } = launcher.findPython(opts); + assert.equal(python.command, '/usr/bin/python3'); + assert.equal(seen.length, 2); + }); + + test('the help text names the fix for what was found', () => { + const noVenv = launcher.installHelp([{ version: [3, 12], ok: false, hasVenv: false }]); + assert.match(noVenv, /python3-venv/); + assert.match(noVenv, /astral\.sh\/uv\/install\.sh/); + assert.match(noVenv, /brew install deepgram\/tap\/deepgram/); + const old = launcher.installHelp([{ version: [3, 9], ok: false, hasVenv: true }]); + assert.match(old, /found 3\.9/); + assert.doesNotMatch(old, /python3-venv/); + }); +}); + +describe('cache directory', () => { + test('Linux honours an absolute XDG_CACHE_HOME', () => { + const roots = launcher.cacheRoots({ env: { XDG_CACHE_HOME: '/xdg' }, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 7 }); + assert.deepEqual(roots, ['/xdg/deepgram-cli/venvs', '/tmp/deepgram-cli-7/venvs']); + const relative = launcher.cacheRoots({ env: { XDG_CACHE_HOME: 'rel' }, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 7 }); + assert.equal(relative[0], '/home/u/.cache/deepgram-cli/venvs'); + }); + + test('macOS uses ~/Library/Caches', () => { + const roots = launcher.cacheRoots({ env: {}, platform: 'darwin', home: '/Users/u', tmpdir: '/tmp', uid: 501 }); + assert.equal(roots[0], '/Users/u/Library/Caches/deepgram-cli/venvs'); + }); + + test('Windows uses LOCALAPPDATA', () => { + const roots = launcher.cacheRoots({ env: { LOCALAPPDATA: 'C:\\Users\\u\\AppData\\Local' }, platform: 'win32', home: 'C:\\Users\\u', tmpdir: 'C:\\Temp', uid: 'user' }); + assert.deepEqual(roots, ['C:\\Users\\u\\AppData\\Local\\deepgram-cli\\venvs', 'C:\\Temp\\deepgram-cli-user\\venvs']); + const layout = launcher.venvLayout('C:\\v', 'win32'); + assert.equal(layout.deepctl, 'C:\\v\\Scripts\\deepctl.exe'); + }); + + test('a cached venv is used only when complete, from either root', () => { + const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1 }; + const files = new Set(['/tmp/deepgram-cli-1/venvs/1.0.0/.complete', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl']); + const hit = launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }); + assert.equal(hit.deepctl, '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl'); + files.delete('/tmp/deepgram-cli-1/venvs/1.0.0/.complete'); + assert.equal(launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }), null); + }); +}); + +describe('lock', () => { + test('acquire, then release', () => { + const lock = path.join(tmpdir(), 'v.lock'); + const release = launcher.acquireLock(lock); + const owner = JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8')); + assert.equal(owner.pid, process.pid); + release(); + assert.equal(fs.existsSync(lock), false); + }); + + test('a lock held by a live process times out and announces the wait once', () => { + const lock = path.join(tmpdir(), 'v.lock'); + const release = launcher.acquireLock(lock); + let waits = 0; + assert.throws( + () => launcher.acquireLock(lock, { waitMs: 300, pollMs: 50, onWait: () => waits++ }), + /Timed out waiting/, + ); + assert.equal(waits, 1); + release(); + }); + + test('a lock whose pid is dead is stale and is taken over', () => { + const lock = path.join(tmpdir(), 'v.lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: 999999, host: os.hostname(), time: Date.now() })); + assert.equal(launcher.lockIsStale(lock, Date.now(), () => false), true); + assert.equal(launcher.lockIsStale(lock, Date.now(), () => true), false); + const release = launcher.acquireLock(lock, { waitMs: 0 }); + release(); + }); + + test('an old lock is stale even if its pid looks alive', () => { + const lock = path.join(tmpdir(), 'v.lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, host: os.hostname(), time: 0 })); + assert.equal(launcher.lockIsStale(lock), true); + }); + + test('a lock from another host is not judged by local pids', () => { + const lock = path.join(tmpdir(), 'v.lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: 999999, host: 'elsewhere', time: Date.now() })); + assert.equal(launcher.lockIsStale(lock, Date.now(), () => false), false); + }); +}); + +describe('child environment', () => { + test('marks the launcher and increments the depth', () => { + const env = launcher.childEnv({ HOME: '/h' }, 'uvx'); + assert.equal(env.DEEPCTL_LAUNCHER, 'npm'); + assert.equal(env.DEEPCTL_NPM_LAUNCHER_DEPTH, '1'); + assert.equal(env.HOME, '/h'); + assert.equal(launcher.childEnv({}, 'venv').DEEPCTL_LAUNCHER, 'npm'); + }); + + test('the path strategy removes DEEPCTL_LAUNCHER', () => { + const env = launcher.childEnv({ DEEPCTL_LAUNCHER: 'npm' }, 'path'); + assert.equal('DEEPCTL_LAUNCHER' in env, false); + }); +}); + +describe('exit mapping', () => { + test('passes exit codes through', () => { + assert.deepEqual(launcher.exitAction(0, null, 'linux'), { exit: 0 }); + assert.deepEqual(launcher.exitAction(2, null, 'linux'), { exit: 2 }); + }); + + test('re-raises signals on POSIX with a 128+n fallback', () => { + assert.deepEqual(launcher.exitAction(null, 'SIGTERM', 'linux'), { signal: 'SIGTERM', exit: 128 + os.constants.signals.SIGTERM }); + assert.deepEqual(launcher.exitAction(null, 'SIGINT', 'darwin'), { signal: 'SIGINT', exit: 128 + os.constants.signals.SIGINT }); + }); + + test('Windows has no signal to re-raise', () => { + assert.deepEqual(launcher.exitAction(null, 'SIGTERM', 'win32'), { exit: 1 }); + }); +}); + +describe('resolveRunner', () => { + test('rejects an unknown DEEPCTL_NPM_RUNNER', () => { + assert.throws(() => launcher.resolveRunner([], { env: { DEEPCTL_NPM_RUNNER: 'pipx' }, version: '1.0.0' }), /must be one of/); + }); + + test('uses uv with the pinned spec when no venv is cached', () => { + const r = launcher.resolveRunner(['--version'], { + env: { PATH: '/bin' }, + version: '1.2.3', + platform: 'linux', + home: '/nonexistent', + tmpdir: '/nonexistent', + realpath: (f) => f, + isExecutable: (f) => f === '/bin/uv', + exists: () => false, + }); + assert.deepEqual(r, { command: '/bin/uv', args: ['tool', 'run', '--from', 'deepctl==1.2.3', 'deepctl', '--version'], strategy: 'uvx' }); + }); + + test('a cached venv wins over uv', () => { + const r = launcher.resolveRunner(['x'], { + env: { PATH: '/bin' }, + version: '1.2.3', + platform: 'linux', + home: '/h', + tmpdir: '/t', + uid: 0, + realpath: (f) => f, + isExecutable: (f) => f === '/bin/uv', + exists: (f) => f.startsWith('/h/.cache/deepgram-cli/venvs/1.2.3/'), + }); + assert.equal(r.strategy, 'venv'); + assert.equal(r.command, '/h/.cache/deepgram-cli/venvs/1.2.3/bin/deepctl'); + }); + + test('DEEPCTL_NPM_RUNNER=path requires a real deepctl', () => { + const base = { version: '1.0.0', platform: 'linux', realpath: (f) => f }; + assert.throws( + () => launcher.resolveRunner([], { ...base, env: { PATH: '/bin', DEEPCTL_NPM_RUNNER: 'path' }, isExecutable: () => false }), + /no deepctl was found/, + ); + const r = launcher.resolveRunner(['a'], { + ...base, + env: { PATH: '/bin', DEEPCTL_NPM_RUNNER: 'path' }, + isExecutable: (f) => f === '/bin/deepctl', + }); + assert.deepEqual(r, { command: '/bin/deepctl', args: ['a'], strategy: 'path' }); + }); + + test('DEEPCTL_NPM_RUNNER=uvx without uv is an error', () => { + assert.throws( + () => launcher.resolveRunner([], { + env: { PATH: '/bin', DEEPCTL_NPM_RUNNER: 'uvx' }, + version: '1.0.0', + platform: 'linux', + home: '/nonexistent', + realpath: (f) => f, + isExecutable: () => false, + }), + /uv was not found/, + ); + }); +}); + +describe('bin/dg.js end to end', posixOnly, () => { + function runBin(env, args = []) { + return spawnSync(process.execPath, [BIN, ...args], { env, encoding: 'utf8' }); + } + + test('aborts when started recursively', () => { + const r = runBin({ PATH: '/nonexistent', DEEPCTL_NPM_LAUNCHER_DEPTH: '1' }); + assert.equal(r.status, 1); + assert.match(r.stderr, /recursively/); + }); + + test('no uv and no Python: clear error on stderr, nothing on stdout, exit 1', () => { + const home = tmpdir(); + const r = runBin({ PATH: '/nonexistent', HOME: home, XDG_CACHE_HOME: path.join(home, 'c') }); + assert.equal(r.status, 1); + assert.equal(r.stdout, ''); + assert.match(r.stderr, /needs uv or Python 3\.10\+/); + }); + + test('passes arguments, env and exit code through a fake uv', () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + fakeExe(bin, 'uv', '#!/bin/sh\necho "$DEEPCTL_LAUNCHER $DEEPCTL_NPM_LAUNCHER_DEPTH $*"\nexit 7\n'); + const r = runBin({ PATH: bin, HOME: home, XDG_CACHE_HOME: path.join(home, 'c') }, ['listen', 'a b.wav']); + assert.equal(r.status, 7); + const version = launcher.packageVersion(PKG_DIR); + assert.equal(r.stdout, `npm 1 tool run --from deepctl==${version} deepctl listen a b.wav\n`); + }); + + test('re-raises the signal the child died from', () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + fakeExe(bin, 'uv', '#!/bin/sh\nkill -TERM $$\n'); + const r = runBin({ PATH: bin, HOME: home, XDG_CACHE_HOME: path.join(home, 'c') }); + assert.equal(r.status, null); + assert.equal(r.signal, 'SIGTERM'); + }); +}); From 7e793eb2db2fbb4781dc1b483ba0e0b66a819d68 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:50:14 -0400 Subject: [PATCH 02/21] feat(update): detect the npm launcher and point updates at npm deepctl started by @deepgram/cli (DEEPCTL_LAUNCHER=npm) is now InstallMethod.NPM, checked before every other method because the launcher runs it through uv or its own venv. dg update prints the npm command instead of running pip/uv, the update banner and dg update --check-only name npm, and the banner is no longer suppressed as one-shot under npm. Plugin update hints say dg rather than deepctl, which npm does not install. --- .../src/deepctl_cmd_update/installation.py | 28 ++++++++- .../deepctl_cmd_update/plugin_update_check.py | 4 +- .../src/deepctl_cmd_update/startup_check.py | 23 ++++++- .../src/deepctl_cmd_update/version_check.py | 4 +- .../tests/unit/test_installation.py | 61 ++++++++++++++++++- .../tests/unit/test_plugin_update_check.py | 4 +- .../tests/unit/test_startup_check.py | 32 ++++++++++ .../tests/unit/test_version_check.py | 11 ++++ 8 files changed, 158 insertions(+), 9 deletions(-) diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py index 873df08..857e88c 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py @@ -9,6 +9,17 @@ from pydantic import BaseModel +# The @deepgram/cli npm launcher sets this in deepctl's environment. Its +# venv or uv cache is private to the launcher, so pip/uv commands run from +# inside it would update the wrong thing; npm is the only way to update. +LAUNCHER_ENV_VAR = "DEEPCTL_LAUNCHER" +NPM_UPDATE_COMMAND = "npm install -g @deepgram/cli@latest" + + +def launched_via_npm() -> bool: + """Return True if deepctl was started by the @deepgram/cli npm launcher.""" + return os.environ.get(LAUNCHER_ENV_VAR) == "npm" + class InstallMethod(str, Enum): """Supported installation methods.""" @@ -20,6 +31,7 @@ class InstallMethod(str, Enum): UV_TOOL = "uv_tool" UVX = "uvx" PIPX_RUN = "pipx_run" + NPM = "npm" SYSTEM = "system" DEVELOPMENT = "development" UNKNOWN = "unknown" @@ -105,6 +117,7 @@ def get_update_command(self, method: InstallMethod) -> list[str] | None: ], InstallMethod.UVX: None, # Ephemeral, no update needed InstallMethod.PIPX_RUN: None, # Ephemeral, no update needed + InstallMethod.NPM: None, # Updated through npm, see instructions InstallMethod.SYSTEM: None, # Handled separately InstallMethod.DEVELOPMENT: None, # Can't auto-update InstallMethod.UNKNOWN: None, @@ -123,6 +136,12 @@ def get_update_instructions(self, info: InstallationInfo) -> str: if info.method == InstallMethod.HOMEBREW: return "Please update using Homebrew: brew upgrade deepctl" + if info.method == InstallMethod.NPM: + return ( + f"Installed via npm. Update with: {NPM_UPDATE_COMMAND} " + "(or run npx @deepgram/cli@latest)" + ) + if info.method == InstallMethod.UVX: return ( "You are running deepctl via uvx (one-shot execution). " @@ -261,8 +280,8 @@ def _detect_method( ) -> InstallMethod: """Detect the installation method. - Priority: development > oneshot > homebrew > uv_tool > pipx > - uv > system > pip > unknown + Priority: npm > development > oneshot > homebrew > uv_tool > + pipx > uv > system > pip > unknown Args: package_location: Path to package @@ -272,6 +291,11 @@ def _detect_method( Returns: Detected installation method """ + # The npm launcher runs deepctl through uv or its own venv, which + # would otherwise be detected as uvx or pip. + if launched_via_npm(): + return InstallMethod.NPM + # Development installation if is_editable: return InstallMethod.DEVELOPMENT diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py index e490cb3..00517ad 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py @@ -261,7 +261,7 @@ def print_pending_plugin_notifications() -> None: u = updates[0] sys.stderr.write( f"\033[33mPlugin update available: {u['name']} {u['current']} → {u['latest']}" - f" — run 'deepctl plugin update {u['name']}' to upgrade\033[0m\n" + f" — run 'dg plugin update {u['name']}' to upgrade\033[0m\n" ) else: sys.stderr.write("\033[33mPlugin updates available:\033[0m\n") @@ -270,7 +270,7 @@ def print_pending_plugin_notifications() -> None: f"\033[33m {u['name']} {u['current']} → {u['latest']}\033[0m\n" ) sys.stderr.write( - "\033[33mRun 'deepctl plugin update ' to upgrade\033[0m\n" + "\033[33mRun 'dg plugin update ' to upgrade\033[0m\n" ) except (BrokenPipeError, OSError, ValueError): # The output stream closed (e.g. an MCP host disconnected stdio diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py index 889bab1..6d26708 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py @@ -42,8 +42,29 @@ def _is_ci() -> bool: return any(os.environ.get(v) for v in _CI_ENV_VARS) +def _launched_via_npm() -> bool: + """Return True if the @deepgram/cli npm launcher started deepctl. + + Mirrors ``installation.launched_via_npm`` without importing pydantic on + every CLI start. + """ + return os.environ.get("DEEPCTL_LAUNCHER") == "npm" + + +def upgrade_hint() -> str: + """Return the command a user should run to upgrade deepctl.""" + if _launched_via_npm(): + return "npm install -g @deepgram/cli@latest" + return "dg update" + + def _is_oneshot() -> bool: """Return True if running in an ephemeral one-shot environment.""" + # The npm launcher often runs deepctl through `uv tool run`, which looks + # one-shot, but the npm install itself persists and is pinned to one + # version, so its users do need to hear about updates. + if _launched_via_npm(): + return False # uvx if "UV_INTERNAL__PARENT_INTERPRETER" in os.environ: return True @@ -200,7 +221,7 @@ def print_pending_notification() -> None: try: sys.stderr.write( f"\n\033[33m Update available: {current} → {latest}" - f" — run \033[1mdg update\033[0m\033[33m to upgrade\033[0m\n\n" + f" — run \033[1m{upgrade_hint()}\033[0m\033[33m to upgrade\033[0m\n\n" ) except (BrokenPipeError, OSError, ValueError): # The output stream closed (e.g. an MCP host disconnected stdio diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/version_check.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/version_check.py index 89a1fa4..3338482 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/version_check.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/version_check.py @@ -7,6 +7,8 @@ from packaging import version from pydantic import BaseModel, Field +from .startup_check import upgrade_hint + class VersionInfo(BaseModel): """Version information from PyPI.""" @@ -270,5 +272,5 @@ def format_version_message(info: VersionInfo) -> str: else: message += f" (released {days_old} days ago)" - message += "\nRun 'dg update' to upgrade" + message += f"\nRun '{upgrade_hint()}' to upgrade" return message diff --git a/packages/deepctl-cmd-update/tests/unit/test_installation.py b/packages/deepctl-cmd-update/tests/unit/test_installation.py index b98cf30..bfda5f5 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_installation.py +++ b/packages/deepctl-cmd-update/tests/unit/test_installation.py @@ -22,7 +22,7 @@ def test_all_methods_defined(self): """Verify all expected methods exist.""" expected = { "pip", "pipx", "uv", "homebrew", "uv_tool", - "uvx", "pipx_run", "system", "development", "unknown", + "uvx", "pipx_run", "npm", "system", "development", "unknown", } actual = {m.value for m in InstallMethod} assert actual == expected @@ -68,6 +68,9 @@ def test_returns_none_for_pipx_run(self, detector): cmd = detector.get_update_command(InstallMethod.PIPX_RUN) assert cmd is None + def test_returns_none_for_npm(self, detector): + assert detector.get_update_command(InstallMethod.NPM) is None + def test_returns_none_for_development(self, detector): cmd = detector.get_update_command(InstallMethod.DEVELOPMENT) assert cmd is None @@ -263,3 +266,59 @@ def test_pipx_run_instructions(self, detector): instructions = detector.get_update_instructions(info) assert "pipx run" in instructions assert "automatically" in instructions + + def test_npm_instructions(self, detector): + from deepctl_cmd_update.installation import InstallationInfo + + info = InstallationInfo( + method=InstallMethod.NPM, + path="/home/u/.cache/deepgram-cli/venvs/0.4.0", + virtual_env=True, + editable=False, + python_executable="/home/u/.cache/deepgram-cli/venvs/0.4.0/bin/python", + ) + instructions = detector.get_update_instructions(info) + assert "npm install -g @deepgram/cli@latest" in instructions + assert "npx @deepgram/cli@latest" in instructions + + +class TestNpmLauncherDetection: + """The npm launcher marks deepctl's environment with DEEPCTL_LAUNCHER=npm.""" + + @patch.dict( + "os.environ", + { + "DEEPCTL_LAUNCHER": "npm", + # The launcher's uv strategy also looks like uvx; npm must win. + "UV_INTERNAL__PARENT_INTERPRETER": "/usr/bin/python3", + }, + clear=False, + ) + def test_npm_wins_over_uvx_and_development(self): + detector = InstallationDetector() + assert ( + detector._detect_method(None, in_venv=True, is_editable=True) + == InstallMethod.NPM + ) + + @patch.dict( + "os.environ", + {"UV_INTERNAL__PARENT_INTERPRETER": "/usr/bin/python3"}, + clear=False, + ) + def test_other_launcher_values_are_ignored(self): + import os + + os.environ["DEEPCTL_LAUNCHER"] = "something-else" + detector = InstallationDetector() + assert ( + detector._detect_method(None, in_venv=False, is_editable=False) + == InstallMethod.UVX + ) + + @patch.dict("os.environ", {}, clear=False) + def test_detect_reports_npm(self): + import os + + os.environ["DEEPCTL_LAUNCHER"] = "npm" + assert InstallationDetector().detect().method == InstallMethod.NPM diff --git a/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py b/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py index 9de3b70..a4f9f0a 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py @@ -365,7 +365,7 @@ def test_single_plugin_format(self): assert "deepctl-plugin-whisper" in output assert "0.1.0" in output assert "0.2.0" in output - assert "deepctl plugin update deepctl-plugin-whisper" in output + assert "dg plugin update deepctl-plugin-whisper" in output def test_multiple_plugin_format(self): import deepctl_cmd_update.plugin_update_check as mod @@ -392,7 +392,7 @@ def test_multiple_plugin_format(self): assert "Plugin updates available:" in output assert "deepctl-plugin-whisper" in output assert "deepctl-plugin-asr" in output - assert "deepctl plugin update " in output + assert "dg plugin update " in output @pytest.mark.parametrize( "error", diff --git a/packages/deepctl-cmd-update/tests/unit/test_startup_check.py b/packages/deepctl-cmd-update/tests/unit/test_startup_check.py index 8d3962c..c83964e 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_startup_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_startup_check.py @@ -66,6 +66,18 @@ def test_not_oneshot(self): def test_uvx_oneshot(self): assert _is_oneshot() is True + @patch.dict( + "os.environ", + { + "UV_INTERNAL__PARENT_INTERPRETER": "/usr/bin/python3", + "DEEPCTL_LAUNCHER": "npm", + }, + clear=True, + ) + def test_npm_launcher_is_not_oneshot(self): + """npm runs deepctl via uv, but the npm install persists.""" + assert _is_oneshot() is False + class TestCacheTimestamp: """Test file-based timestamp caching.""" @@ -208,6 +220,26 @@ def test_notification_displayed(self): assert "1.0.0" in output assert "dg update" in output + @patch.dict("os.environ", {"DEEPCTL_LAUNCHER": "npm"}, clear=False) + def test_notification_names_npm_under_the_launcher(self): + """`dg update` cannot update an npm install; say what can.""" + import io + + import deepctl_cmd_update.startup_check as mod + + mod._result = {"latest": "2.0.0", "current": "1.0.0"} + mod._thread = threading.Thread(target=lambda: None) + mod._thread.start() + mod._thread.join() + + stderr_capture = io.StringIO() + with patch.object(sys, "stderr", stderr_capture): + print_pending_notification() + + output = stderr_capture.getvalue() + assert "npm install -g @deepgram/cli@latest" in output + assert "dg update" not in output + def test_no_notification_when_up_to_date(self, capsys): """No output when no update is available.""" import deepctl_cmd_update.startup_check as mod diff --git a/packages/deepctl-cmd-update/tests/unit/test_version_check.py b/packages/deepctl-cmd-update/tests/unit/test_version_check.py index f96f4fc..35e12f5 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_version_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_version_check.py @@ -274,3 +274,14 @@ def test_update_available(self): assert "1.0.0" in msg assert "2.0.0" in msg assert "dg update" in msg + + @patch.dict("os.environ", {"DEEPCTL_LAUNCHER": "npm"}, clear=False) + def test_update_available_under_npm_launcher(self): + info = VersionInfo( + current_version="1.0.0", + latest_version="2.0.0", + update_available=True, + ) + msg = format_version_message(info) + assert "npm install -g @deepgram/cli@latest" in msg + assert "dg update" not in msg From 762aa4b54437e2234d92f15df22a2acad68056ef Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:50:14 -0400 Subject: [PATCH 03/21] feat(plugin): use the isolated plugin venv for npm installs The launcher's environment is replaced on every npm version bump, so plugins installed into it would disappear. Treat npm like Homebrew and system installs. --- .../src/deepctl_cmd_plugin/command.py | 7 ++++++- .../src/deepctl_cmd_plugin/strategies.py | 2 +- .../tests/unit/test_plugin_command.py | 1 + .../deepctl-cmd-plugin/tests/unit/test_strategies.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py index dd14fa5..a5228d5 100644 --- a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py +++ b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/command.py @@ -694,11 +694,16 @@ def _get_plugin_registry(self) -> list[PluginRegistryEntry]: ] def _needs_isolated_venv(self, method: InstallMethod) -> bool: - """Check if this installation method needs the isolated plugin venv.""" + """Check if this installation method needs the isolated plugin venv. + + npm counts: the launcher's venv or uv environment is replaced on every + npm version bump, so plugins installed into it would vanish. + """ return method in ( InstallMethod.HOMEBREW, InstallMethod.SYSTEM, InstallMethod.UNKNOWN, + InstallMethod.NPM, ) def _get_strategy( diff --git a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/strategies.py b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/strategies.py index 90a3628..e8dc912 100644 --- a/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/strategies.py +++ b/packages/deepctl-cmd-plugin/src/deepctl_cmd_plugin/strategies.py @@ -272,7 +272,7 @@ def get_strategy( if method in (InstallMethod.UVX, InstallMethod.PIPX_RUN): return EphemeralStrategy(method) - # HOMEBREW, SYSTEM, UNKNOWN → isolated venv + # HOMEBREW, SYSTEM, UNKNOWN, NPM → isolated venv if venv_python is None: raise ValueError( f"venv_python is required for IsolatedVenvStrategy (method={method.value})" diff --git a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py index b9f8a69..c9343c8 100644 --- a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py +++ b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py @@ -768,6 +768,7 @@ def test_needs_isolated_venv(self) -> None: assert self.command._needs_isolated_venv(InstallMethod.HOMEBREW) is True assert self.command._needs_isolated_venv(InstallMethod.SYSTEM) is True assert self.command._needs_isolated_venv(InstallMethod.UNKNOWN) is True + assert self.command._needs_isolated_venv(InstallMethod.NPM) is True assert self.command._needs_isolated_venv(InstallMethod.PIP) is False assert self.command._needs_isolated_venv(InstallMethod.PIPX) is False assert self.command._needs_isolated_venv(InstallMethod.UV) is False diff --git a/packages/deepctl-cmd-plugin/tests/unit/test_strategies.py b/packages/deepctl-cmd-plugin/tests/unit/test_strategies.py index f9217cf..f43b486 100644 --- a/packages/deepctl-cmd-plugin/tests/unit/test_strategies.py +++ b/packages/deepctl-cmd-plugin/tests/unit/test_strategies.py @@ -313,6 +313,16 @@ def test_homebrew_returns_isolated_venv(self) -> None: ) assert isinstance(strategy, IsolatedVenvStrategy) + @pytest.mark.unit + def test_npm_returns_isolated_venv(self) -> None: + strategy = get_strategy(InstallMethod.NPM, venv_python="/path/to/python") + assert isinstance(strategy, IsolatedVenvStrategy) + + @pytest.mark.unit + def test_npm_without_venv_python_raises(self) -> None: + with pytest.raises(ValueError, match="venv_python is required"): + get_strategy(InstallMethod.NPM) + @pytest.mark.unit def test_unknown_returns_isolated_venv(self) -> None: strategy = get_strategy( From 576e1b5c4ff5572f662d0b085b3b764cad699a1b Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:50:14 -0400 Subject: [PATCH 04/21] fix(skills): show the skills prompt under the npm launcher and name dg The npm launcher's uv strategy looked one-shot and suppressed the prompt, and the prompt named deepctl, which npm does not install. --- .../src/deepctl_cmd_skills/startup_check.py | 6 +++- .../tests/unit/test_skills_command.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py index 8d15322..13f71a3 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py @@ -33,6 +33,10 @@ def _is_ci() -> bool: def _is_oneshot() -> bool: + # The npm launcher may run deepctl via `uv tool run`, but its install + # persists, so a one-time prompt is worth showing there too. + if os.environ.get("DEEPCTL_LAUNCHER") == "npm": + return False if "UV_INTERNAL__PARENT_INTERPRETER" in os.environ: return True exe_path = str(Path(sys.executable).resolve()) @@ -110,7 +114,7 @@ def print_pending_notification() -> None: try: sys.stderr.write( "\033[36mAI coding assistants detected. " - "Run 'deepctl skills install' to set up integration.\033[0m\n" + "Run 'dg skills install' to set up integration.\033[0m\n" ) except (BrokenPipeError, OSError, ValueError): # The output stream closed (e.g. an MCP host disconnected stdio diff --git a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py index 700bc30..8d46e73 100644 --- a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py +++ b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py @@ -965,6 +965,35 @@ def test_suppressed_when_quiet(self): startup_check.check_and_notify(quiet=True) assert startup_check._thread is None + def test_npm_launcher_is_not_oneshot(self): + """npm runs deepctl via uv, but the npm install persists.""" + from deepctl_cmd_skills import startup_check + + env = { + "UV_INTERNAL__PARENT_INTERPRETER": "/usr/bin/python3", + "DEEPCTL_LAUNCHER": "npm", + } + with patch.dict(os.environ, env, clear=False): + assert startup_check._is_oneshot() is False + with patch.dict(os.environ, env, clear=False): + del os.environ["DEEPCTL_LAUNCHER"] + assert startup_check._is_oneshot() is True + + def test_prompt_names_dg(self): + """npm installs only `dg` and `deepgram`, so the prompt must not say deepctl.""" + import io + import threading + + from deepctl_cmd_skills import startup_check + + startup_check._result = {"should_prompt": True} + startup_check._thread = threading.Thread(target=lambda: None) + startup_check._thread.start() + err = io.StringIO() + with patch.object(sys, "stderr", err): + startup_check.print_pending_notification() + assert "Run 'dg skills install'" in err.getvalue() + @pytest.mark.parametrize( "error", [ From 505735b30d3a8e898504987680359e11cd853e44 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:51:33 -0400 Subject: [PATCH 05/21] ci(release): publish @deepgram/cli to npm after PyPI verifies release-please now bumps npm/package.json with the root version. A new publish-npm job runs after verify-published on root tags: it checks the package version equals the tag, skips a version npm already has, publishes with trusted publishing and provenance, then waits for the registry and runs the published package through npx and uv. --- .github/release-please-config.json | 7 +- .github/workflows/release.yml | 113 +++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/.github/release-please-config.json b/.github/release-please-config.json index fd5be2c..553d9c3 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -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": { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b36ee9..2c3c9c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -345,3 +345,116 @@ 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==` + # 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. + 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 + 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. + - name: Install an npm that supports trusted publishing + run: npm install -g npm@^11.5.1 + + # 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 + TAG_VERSION="${TAG_NAME#v}" + PKG_VERSION="$(node -p 'require("./npm/package.json").version')" + if [ "${PKG_VERSION}" != "${TAG_VERSION}" ]; then + echo "::error::npm/package.json is ${PKG_VERSION} but the tag is ${TAG_NAME}" + exit 1 + fi + if ! printf '%s' "${PKG_VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::${PKG_VERSION} is not a plain X.Y.Z version; refusing to publish it as latest" + exit 1 + fi + echo "version=${PKG_VERSION}" >> "$GITHUB_OUTPUT" + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + + # 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 }} + + - name: Publish + if: steps.existing.outputs.published != 'true' + working-directory: npm + run: npm publish --access public --provenance + + - 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 }} From 185d1ed24af3abbae7b337b8ff3e0dec5cc3c7f5 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:51:33 -0400 Subject: [PATCH 06/21] ci(test): run the npm launcher tests on Linux, macOS and Windows Unit tests plus a smoke test that runs the launcher through real uv against the newest deepctl on PyPI. The job is part of the all-checks rollup. --- .github/workflows/test.yml | 41 +++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad94d84..be25816 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -112,6 +112,44 @@ 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" + + - 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}" + OUT="$(DEEPCTL_NPM_PYPI_SPEC="deepctl==${LATEST}" node npm/bin/dg.js --version)" + echo "${OUT}" + printf '%s' "${OUT}" | grep -qF "${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 @@ -123,7 +161,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: @@ -133,6 +171,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') }} From 85ff356797486a4424d1b442500426ed1874878c Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 12:51:44 -0400 Subject: [PATCH 07/21] docs: document the npm install and fix the Homebrew formula name in llms-full.txt llms-full.txt said deepgram/tap/deepctl; the formula is deepgram/tap/deepgram, as the README already says. --- README.md | 6 ++++++ web/public/llms-full.txt | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 46a2f16..74fb515 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 5b76e4d..0945382 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -23,7 +23,9 @@ iwr deepgram.com/install.ps1 | iex pip install deepctl # pip pipx install deepctl # pipx (isolated) uvx deepctl --help # uvx (run without install) -brew install deepgram/tap/deepctl # Homebrew +brew install deepgram/tap/deepgram # Homebrew +npm install -g @deepgram/cli # npm (needs uv or Python 3.10+; installs dg, deepgram) +npx @deepgram/cli --help # npx (run without install) ``` Aliases: `dg`, `deepctl`, `deepgram` From c497a0594e07c98dde9a990f0c48ce7f303a1304 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 13:37:11 -0400 Subject: [PATCH 08/21] chore(npm): ship the MIT license in the npm package npm/LICENSE is a copy of the repository LICENSE, so the tarball and npmjs.com carry the notice. --- npm/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 npm/LICENSE diff --git a/npm/LICENSE b/npm/LICENSE new file mode 100644 index 0000000..880c299 --- /dev/null +++ b/npm/LICENSE @@ -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. From 15989c4e967b9ff7ee97a5a2c0a83fb74cbae770 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 13:37:11 -0400 Subject: [PATCH 09/21] fix(npm): harden the launcher's cache, lock, pruning and signals - Only use a cache root that is a real directory owned by this user and not writable by others; the shared /tmp fallback must also be 0700. Another user could otherwise pre-create /tmp/deepgram-cli- and plant a cached deepctl that runs as the victim. - Drop the DEEPCTL_NPM_LAUNCHER_DEPTH guard: it aborted legitimate nested dg runs, and the realpath self-exclusion on PATH already prevents the launcher from finding itself. - Prune other versions' venvs only after 30 days unused (each run from a cached venv refreshes its marker), never one with a live lock. - A SIGINT or SIGQUIT sent only to the launcher is forwarded to the child if it is still running after a second; a terminal Ctrl-C is unchanged. - A same-host lock is stale only when its pid is dead; release removes the lock only if it is still ours; lock I/O errors are a clean error. - Windows: also try py -3, and try WindowsApps Pythons last instead of skipping them, letting the version probe reject the Store stub. - Report npx and npm exec runs as DEEPCTL_LAUNCHER=npx. --- npm/lib/launcher.js | 222 +++++++++++++++++++++++++++++-------- npm/test/launcher.test.js | 223 +++++++++++++++++++++++++++++++++++--- 2 files changed, 387 insertions(+), 58 deletions(-) diff --git a/npm/lib/launcher.js b/npm/lib/launcher.js index a33c264..75e2d63 100644 --- a/npm/lib/launcher.js +++ b/npm/lib/launcher.js @@ -15,6 +15,8 @@ const MIN_PYTHON = [3, 10]; const LOCK_WAIT_MS = 5 * 60 * 1000; const LOCK_STALE_MS = 10 * 60 * 1000; const LOCK_POLL_MS = 250; +const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; +const SIGNAL_GRACE_MS = 1000; const PROBE_TIMEOUT_MS = 10 * 1000; const RUNNERS = ['uvx', 'venv', 'path']; @@ -146,12 +148,25 @@ function pythonCandidates(opts = {}) { const platform = opts.platform || process.platform; const candidates = []; if (isWindows(platform)) { - const py = whichAll('py', opts).filter((f) => !isWindowsAppsStub(f))[0]; - if (py) { - for (const minor of [13, 12, 11, 10]) candidates.push({ command: py, args: [`-3.${minor}`] }); - } - for (const exe of whichAll('python', opts)) { - if (!isWindowsAppsStub(exe)) candidates.push({ command: exe, args: [] }); + // WindowsApps holds both the Store's "python" stub, which only prints + // an install hint, and real Store-installed Pythons. Try it last and let + // the version probe tell them apart. + const pys = whichAll('py', opts); + const pythons = whichAll('python', opts); + const ordered = [ + ...pys.filter((f) => !isWindowsAppsStub(f)).map((f) => ({ f, py: true })), + ...pythons.filter((f) => !isWindowsAppsStub(f)).map((f) => ({ f, py: false })), + ...pys.filter(isWindowsAppsStub).map((f) => ({ f, py: true })), + ...pythons.filter(isWindowsAppsStub).map((f) => ({ f, py: false })), + ]; + for (const { f, py } of ordered) { + if (!py) { + candidates.push({ command: f, args: [] }); + continue; + } + for (const minor of [13, 12, 11, 10]) candidates.push({ command: f, args: [`-3.${minor}`] }); + // Newer than 3.13, or a launcher configured with only a default. + candidates.push({ command: f, args: ['-3'] }); } return candidates; } @@ -203,7 +218,7 @@ function findPython(opts = {}) { // --- Cache directory ------------------------------------------------------ -function cacheRoots(opts = {}) { +function cacheRootSpecs(opts = {}) { const env = opts.env || process.env; const platform = opts.platform || process.platform; const home = opts.home || os.homedir(); @@ -215,14 +230,65 @@ function cacheRoots(opts = {}) { else if (platform === 'darwin') base = p.join(home, 'Library', 'Caches'); else base = env.XDG_CACHE_HOME && p.isAbsolute(env.XDG_CACHE_HOME) ? env.XDG_CACHE_HOME : p.join(home, '.cache'); // The fallback covers containers run as an arbitrary UID with a HOME that - // does not exist or is read-only. - return [p.join(base, 'deepgram-cli', 'venvs'), p.join(tmpdir, `deepgram-cli-${uid}`, 'venvs')]; + // does not exist or is read-only. It sits in a directory every user can + // write to, so it is held to a stricter ownership check (see rootTrusted). + return [ + { dir: p.join(base, 'deepgram-cli', 'venvs'), shared: false }, + { dir: p.join(tmpdir, `deepgram-cli-${uid}`, 'venvs'), shared: true }, + ]; +} + +function cacheRoots(opts = {}) { + return cacheRootSpecs(opts).map((spec) => spec.dir); } -function ensureWritableDir(dir) { +function currentUid(opts) { + if (opts.uid !== undefined && typeof opts.uid === 'number') return opts.uid; + return process.getuid ? process.getuid() : null; +} + +// Whatever is in a cache root gets executed, so the root and its parent +// must be ours: real directories (not symlinks) owned by this uid that no +// one else can write to. In a shared root such as /tmp, another user could +// otherwise pre-create the directory and plant a "cached" deepctl, so it +// must also be private (0700). Windows has no uid and per-user LOCALAPPDATA. +function rootTrusted(spec, opts = {}) { + const platform = opts.platform || process.platform; + if (isWindows(platform)) return true; + const uid = currentUid(opts); + const lstat = opts.lstat || fs.lstatSync; + const forbidden = spec.shared ? 0o077 : 0o022; + for (const dir of [path.posix.dirname(spec.dir), spec.dir]) { + let st; + try { + st = lstat(dir); + } catch { + return false; + } + if (!st.isDirectory() || st.uid !== uid || (st.mode & forbidden) !== 0) return false; + } + return true; +} + +function mkdirPrivate(dir) { try { - fs.mkdirSync(dir, { recursive: true }); - fs.accessSync(dir, fs.constants.W_OK); + fs.mkdirSync(dir, { mode: 0o700 }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } +} + +// Creates the root if needed and returns whether it is safe and writable. +function prepareRoot(spec, opts = {}) { + const platform = opts.platform || process.platform; + const p = isWindows(platform) ? path.win32 : path.posix; + const top = p.dirname(spec.dir); + try { + fs.mkdirSync(p.dirname(top), { recursive: true }); + mkdirPrivate(top); + mkdirPrivate(spec.dir); + if (!rootTrusted(spec, opts)) return false; + fs.accessSync(spec.dir, fs.constants.W_OK); return true; } catch { return false; @@ -243,14 +309,26 @@ function venvLayout(dir, platform = process.platform) { function findCachedVenv(key, opts = {}) { const exists = opts.exists || fs.existsSync; + const trusted = opts.trustRoot || ((spec) => rootTrusted(spec, opts)); const p = isWindows(opts.platform || process.platform) ? path.win32 : path.posix; - for (const root of cacheRoots(opts)) { - const layout = venvLayout(p.join(root, key), opts.platform); + for (const spec of cacheRootSpecs(opts)) { + if (!trusted(spec)) continue; + const layout = venvLayout(p.join(spec.dir, key), opts.platform); if (exists(layout.marker) && exists(layout.deepctl)) return layout; } return null; } +// Marks a venv as recently used, which keeps pruneSiblings away from it. +function touchMarker(marker) { + try { + const now = new Date(); + fs.utimesSync(marker, now, now); + } catch { + // Best effort: a read-only cache only loses the recency hint. + } +} + // --- Lock ----------------------------------------------------------------- function sleepSync(ms) { @@ -275,8 +353,11 @@ function lockIsStale(lockDir, now = Date.now(), alive = pidAlive) { // the directory's age. } if (owner && typeof owner.pid === 'number') { - if (now - owner.time > LOCK_STALE_MS) return true; - return owner.host === os.hostname() && !alive(owner.pid); + // On this host the pid is authoritative: a slow first install (a big + // wheel on a bad network) can outlive any age limit. Across hosts + // (a shared home directory) the pid means nothing, so fall back to age. + if (owner.host === os.hostname()) return !alive(owner.pid); + return now - owner.time > LOCK_STALE_MS; } try { return now - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS; @@ -293,16 +374,20 @@ function acquireLock(lockDir, opts = {}) { const onWait = opts.onWait || (() => {}); const deadline = Date.now() + waitMs; let announced = false; + const writeFile = opts.writeFile || fs.writeFileSync; for (;;) { + let created = false; try { fs.mkdirSync(lockDir); - fs.writeFileSync( - path.join(lockDir, 'owner.json'), - JSON.stringify({ pid: process.pid, host: os.hostname(), time: Date.now() }), - ); - return () => fs.rmSync(lockDir, { recursive: true, force: true }); + created = true; + const owner = { pid: process.pid, host: os.hostname(), time: Date.now() }; + writeFile(path.join(lockDir, 'owner.json'), JSON.stringify(owner)); + return () => releaseLock(lockDir, owner); } catch (err) { - if (err.code !== 'EEXIST') throw err; + if (created) fs.rmSync(lockDir, { recursive: true, force: true }); + if (err.code !== 'EEXIST' || created) { + throw new LauncherError(`Could not create the setup lock ${lockDir}: ${err.message}`); + } } if (lockIsStale(lockDir)) { fs.rmSync(lockDir, { recursive: true, force: true }); @@ -319,6 +404,19 @@ function acquireLock(lockDir, opts = {}) { } } +// Only removes the lock if it is still ours: if it was judged stale and +// taken over while we ran, the new owner's lock must survive. +function releaseLock(lockDir, owner) { + try { + const current = JSON.parse(fs.readFileSync(path.join(lockDir, 'owner.json'), 'utf8')); + if (current.pid === owner.pid && current.time === owner.time && current.host === owner.host) { + fs.rmSync(lockDir, { recursive: true, force: true }); + } + } catch { + // Gone already, or not readable: either way it is not ours to remove. + } +} + // --- Venv bootstrap ------------------------------------------------------- function runStep(command, args, what, hint, spawnSync) { @@ -331,22 +429,43 @@ function runStep(command, args, what, hint, spawnSync) { const VENV_HINT = 'On Debian or Ubuntu, install python3-venv (sudo apt install python3-venv), or install uv instead.'; const NETWORK_HINT = 'Check your network connection and that PyPI is reachable, then run dg again.'; -function pruneSiblings(root, keep) { +// Removes other versions' venvs, but only ones nobody has used for a long +// time: a dg pinned to an older npm version (npx @deepgram/cli@0.4.0, a +// project-local install) may be running from one right now. Every run from +// a cached venv refreshes its .complete mtime. +function pruneSiblings(root, keep, now = Date.now()) { + let entries; try { - for (const entry of fs.readdirSync(root)) { - if (entry === keep || entry.endsWith('.lock')) continue; - if (fs.existsSync(path.join(root, `${entry}.lock`))) continue; - fs.rmSync(path.join(root, entry), { recursive: true, force: true }); - } + entries = fs.readdirSync(root); } catch { - // Best effort: an old version left behind only costs disk space. + return; + } + for (const entry of entries) { + if (entry === keep || entry.endsWith('.lock')) continue; + try { + const lock = path.join(root, `${entry}.lock`); + if (fs.existsSync(lock) && !lockIsStale(lock, now)) continue; + const dir = path.join(root, entry); + let idle; + try { + idle = now - fs.statSync(path.join(dir, '.complete')).mtimeMs > PRUNE_AFTER_MS; + } catch { + // No marker: a build that died. Leave it until no live build could own it. + idle = now - fs.statSync(dir).mtimeMs > LOCK_STALE_MS; + } + if (idle) fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort: an old version left behind only costs disk space. + } } } function buildVenv({ python, key, spec, version, opts = {} }) { const stderr = opts.stderr || process.stderr; const spawnSync = opts.spawnSync || childProcess.spawnSync; - const root = cacheRoots(opts).find(ensureWritableDir); + const prepare = opts.prepareRoot || ((spec) => prepareRoot(spec, opts)); + const rootSpec = cacheRootSpecs(opts).find(prepare); + const root = rootSpec && rootSpec.dir; if (!root) throw new LauncherError('No writable cache directory for the Deepgram CLI.', 'Set XDG_CACHE_HOME to a writable directory, or install uv.'); // Built in place: venv console scripts carry absolute shebangs, so a venv // built elsewhere and renamed into place would not run. @@ -383,11 +502,11 @@ function buildVenv({ python, key, spec, version, opts = {} }) { function childEnv(env, strategy) { const out = { ...env }; - out.DEEPCTL_NPM_LAUNCHER_DEPTH = String((parseInt(env.DEEPCTL_NPM_LAUNCHER_DEPTH, 10) || 0) + 1); // A deepctl the user installed themselves knows its own install method; // telling it "npm" would make `dg update` give the wrong advice. if (strategy === 'path') delete out.DEEPCTL_LAUNCHER; - else out.DEEPCTL_LAUNCHER = 'npm'; + // npx and `npm exec` set npm_command=exec; a global `dg` does not. + else out.DEEPCTL_LAUNCHER = env.npm_command === 'exec' ? 'npx' : 'npm'; return out; } @@ -442,7 +561,10 @@ function resolveRunner(argv, opts = {}) { if (!forced || forced === 'venv') { const cached = findCachedVenv(key, opts); - if (cached) return { command: cached.deepctl, args: argv, strategy: 'venv' }; + if (cached) { + touchMarker(cached.marker); + return { command: cached.deepctl, args: argv, strategy: 'venv' }; + } } if (!forced || forced === 'uvx') { @@ -460,10 +582,6 @@ function resolveRunner(argv, opts = {}) { function run(argv = process.argv.slice(2), opts = {}) { const env = opts.env || process.env; const stderr = opts.stderr || process.stderr; - if ((parseInt(env.DEEPCTL_NPM_LAUNCHER_DEPTH, 10) || 0) >= 1) { - stderr.write('dg: the npm launcher started itself recursively; aborting. Check DEEPCTL_NPM_RUNNER and PATH.\n'); - process.exit(1); - } let runner; try { @@ -480,14 +598,26 @@ function run(argv = process.argv.slice(2), opts = {}) { env: childEnv(env, runner.strategy), }); - // Ctrl-C and Ctrl-\ reach the child straight from the terminal (same - // process group); the parent only has to survive them. TERM and HUP come - // from whoever is managing *this* process, so pass them on. - const ignore = () => {}; + // TERM and HUP come from whoever manages *this* process: pass them on. + // Ctrl-C and Ctrl-\ from a terminal reach the child directly (same + // process group), so forwarding them at once would deliver them twice. + // But an IDE, supervisor or `timeout -s INT` signals only our pid; if the + // child is still running after a grace period, it never got the signal. + const timers = new Set(); + const childRunning = () => child.exitCode === null && child.signalCode === null; const forward = (sig) => () => child.kill(sig); - const handlers = { SIGINT: ignore, SIGTERM: forward('SIGTERM') }; + const forwardIfMissed = (sig) => () => { + const timer = setTimeout(() => { + timers.delete(timer); + if (childRunning()) child.kill(sig); + }, SIGNAL_GRACE_MS); + timers.add(timer); + }; + // On Windows a console Ctrl-C reaches every attached process, and + // child.kill() would terminate the child outright rather than interrupt it. + const handlers = { SIGINT: isWindows(process.platform) ? () => {} : forwardIfMissed('SIGINT'), SIGTERM: forward('SIGTERM') }; if (!isWindows(process.platform)) { - handlers.SIGQUIT = ignore; + handlers.SIGQUIT = forwardIfMissed('SIGQUIT'); handlers.SIGHUP = forward('SIGHUP'); } for (const [sig, fn] of Object.entries(handlers)) process.on(sig, fn); @@ -498,6 +628,7 @@ function run(argv = process.argv.slice(2), opts = {}) { }); child.on('exit', (code, signal) => { + for (const timer of timers) clearTimeout(timer); for (const [sig, fn] of Object.entries(handlers)) process.removeListener(sig, fn); const action = exitAction(code, signal); if (action.signal) { @@ -519,6 +650,7 @@ function run(argv = process.argv.slice(2), opts = {}) { module.exports = { LauncherError, acquireLock, + cacheRootSpecs, cacheRoots, childEnv, exitAction, @@ -530,10 +662,14 @@ module.exports = { lockIsStale, packageVersion, parseProbeOutput, + probePython, pathDirs, pypiSpec, + pruneSiblings, pythonCandidates, + releaseLock, resolveRunner, + rootTrusted, run, venvKey, venvLayout, diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js index 7bfbc22..59569aa 100644 --- a/npm/test/launcher.test.js +++ b/npm/test/launcher.test.js @@ -42,6 +42,13 @@ describe('package.json', () => { } }); + test('ships the same LICENSE as the repository', () => { + const repoLicense = path.join(PKG_DIR, '..', 'LICENSE'); + const own = fs.readFileSync(path.join(PKG_DIR, 'LICENSE'), 'utf8'); + assert.match(own, /MIT License/); + if (fs.existsSync(repoLicense)) assert.equal(own, fs.readFileSync(repoLicense, 'utf8')); + }); + test('version is a plain release version', () => { assert.match(launcher.packageVersion(PKG_DIR), /^\d+\.\d+\.\d+$/); }); @@ -153,7 +160,7 @@ describe('Python discovery', () => { assert.deepEqual(linux, ['/usr/local/bin/python3.11', '/usr/local/bin/python3', '/usr/bin/python3']); }); - test('Windows tries py -3.13..-3.10, then python, skipping the Store stub', () => { + test('Windows tries py -3.13..-3.10 and -3, then python, with WindowsApps last', () => { const present = new Set([ 'C:\\Windows\\py.exe', 'C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe', @@ -172,10 +179,37 @@ describe('Python discovery', () => { 'C:\\Windows\\py.exe -3.12', 'C:\\Windows\\py.exe -3.11', 'C:\\Windows\\py.exe -3.10', + 'C:\\Windows\\py.exe -3', 'C:\\Python312\\python.exe', + // A real Store Python lives here too; the probe rejects the stub. + 'C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe', ]); }); + test('Windows tries a py found only in WindowsApps, after everything else', () => { + const apps = 'C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps'; + const present = new Set([`${apps}\\py.exe`, `${apps}\\python.exe`]); + const opts = { + env: { Path: apps }, + platform: 'win32', + pkgDir: 'C:\\npm\\node_modules\\@deepgram\\cli', + realpath: (f) => f, + isExecutable: (f) => present.has(f), + }; + const got = launcher.pythonCandidates(opts).map((c) => [c.command, ...c.args].join(' ')); + assert.equal(got.length, 6); + assert.equal(got[0], `${apps}\\py.exe -3.13`); + assert.equal(got[4], `${apps}\\py.exe -3`); + assert.equal(got[5], `${apps}\\python.exe`); + }); + + test('the version probe rejects a Store stub that prints no version', () => { + const stub = () => ({ status: 9009, stdout: '', stderr: 'Python was not found; run without arguments to install from the Microsoft Store' }); + const info = launcher.probePython({ command: 'python.exe', args: [] }, stub); + assert.equal(info.ok, false); + assert.equal(info.version, null); + }); + test('findPython returns the first usable one and records the rest', () => { const present = new Set(['/usr/bin/python3.12', '/usr/bin/python3']); const probes = { @@ -226,13 +260,123 @@ describe('cache directory', () => { }); test('a cached venv is used only when complete, from either root', () => { - const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1 }; + const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1, trustRoot: () => true }; const files = new Set(['/tmp/deepgram-cli-1/venvs/1.0.0/.complete', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl']); const hit = launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }); assert.equal(hit.deepctl, '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl'); files.delete('/tmp/deepgram-cli-1/venvs/1.0.0/.complete'); assert.equal(launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }), null); }); + + test('an untrusted root is never used, however complete its venv looks', () => { + const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1, trustRoot: (spec) => !spec.shared }; + const files = new Set(['/tmp/deepgram-cli-1/venvs/1.0.0/.complete', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl']); + assert.equal(launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }), null); + }); +}); + +describe('cache root ownership (POSIX)', () => { + const dirStat = (uid, mode, isDir = true) => ({ uid, mode: (isDir ? 0o040000 : 0o120000) | mode, isDirectory: () => isDir }); + const shared = { dir: '/tmp/deepgram-cli-1000/venvs', shared: true }; + const home = { dir: '/home/u/.cache/deepgram-cli/venvs', shared: false }; + const check = (spec, stats) => launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, lstat: (d) => { + if (!(d in stats)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return stats[d]; + } }); + + test('a private directory we own is trusted', () => { + assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(1000, 0o700), '/tmp/deepgram-cli-1000/venvs': dirStat(1000, 0o700) }), true); + }); + + test('a shared root owned by another user is rejected', () => { + assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(2000, 0o700), '/tmp/deepgram-cli-1000/venvs': dirStat(1000, 0o700) }), false); + assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(1000, 0o700), '/tmp/deepgram-cli-1000/venvs': dirStat(2000, 0o700) }), false); + }); + + test('a shared root others can read or write is rejected, even if ours', () => { + assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(1000, 0o777), '/tmp/deepgram-cli-1000/venvs': dirStat(1000, 0o700) }), false); + assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(1000, 0o750), '/tmp/deepgram-cli-1000/venvs': dirStat(1000, 0o700) }), false); + }); + + test('a symlinked root is rejected', () => { + assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(1000, 0o777, false), '/tmp/deepgram-cli-1000/venvs': dirStat(1000, 0o700) }), false); + }); + + test('the home root may be readable by others but not writable', () => { + const base = '/home/u/.cache/deepgram-cli'; + assert.equal(check(home, { [base]: dirStat(1000, 0o755), [`${base}/venvs`]: dirStat(1000, 0o755) }), true); + assert.equal(check(home, { [base]: dirStat(1000, 0o775), [`${base}/venvs`]: dirStat(1000, 0o755) }), false); + assert.equal(check(home, { [base]: dirStat(2000, 0o755), [`${base}/venvs`]: dirStat(1000, 0o755) }), false); + }); + + test('a missing root is not trusted', () => { + assert.equal(check(shared, {}), false); + }); + + test('on a real filesystem, a 0777 directory we own is rejected and 0700 accepted', posixOnly, () => { + const base = tmpdir(); + const spec = { dir: path.join(base, 'deepgram-cli-x', 'venvs'), shared: true }; + fs.mkdirSync(spec.dir, { recursive: true }); + fs.chmodSync(path.dirname(spec.dir), 0o777); + fs.chmodSync(spec.dir, 0o700); + assert.equal(launcher.rootTrusted(spec), false); + fs.chmodSync(path.dirname(spec.dir), 0o700); + assert.equal(launcher.rootTrusted(spec), true); + const link = { dir: path.join(base, 'linked', 'venvs'), shared: true }; + fs.symlinkSync(path.dirname(spec.dir), path.join(base, 'linked')); + assert.equal(launcher.rootTrusted(link), false); + }); + + test('Windows skips the ownership check', () => { + assert.equal(launcher.rootTrusted({ dir: 'C:\\x\\venvs', shared: true }, { platform: 'win32' }), true); + }); +}); + +describe('pruning other versions', () => { + const DAY = 24 * 60 * 60 * 1000; + function venv(root, name, { markerAgeDays, dirAgeMs } = {}) { + const dir = path.join(root, name); + fs.mkdirSync(path.join(dir, 'bin'), { recursive: true }); + if (markerAgeDays !== undefined) { + const marker = path.join(dir, '.complete'); + fs.writeFileSync(marker, ''); + const t = new Date(Date.now() - markerAgeDays * DAY); + fs.utimesSync(marker, t, t); + } + if (dirAgeMs !== undefined) { + const t = new Date(Date.now() - dirAgeMs); + fs.utimesSync(dir, t, t); + } + return dir; + } + + test('keeps recently used versions and removes long-idle ones', () => { + const root = tmpdir(); + venv(root, '1.0.0', { markerAgeDays: 0 }); + const recent = venv(root, '0.9.0', { markerAgeDays: 2 }); + const idle = venv(root, '0.8.0', { markerAgeDays: 45 }); + launcher.pruneSiblings(root, '1.0.0'); + assert.equal(fs.existsSync(recent), true); + assert.equal(fs.existsSync(idle), false); + }); + + test('a half-built venv is removed only once no build could still own it', () => { + const root = tmpdir(); + const fresh = venv(root, '0.9.0', { dirAgeMs: 60 * 1000 }); + const dead = venv(root, '0.8.0', { dirAgeMs: 60 * 60 * 1000 }); + launcher.pruneSiblings(root, '1.0.0'); + assert.equal(fs.existsSync(fresh), true); + assert.equal(fs.existsSync(dead), false); + }); + + test('never removes a version with a live lock', () => { + const root = tmpdir(); + const locked = venv(root, '0.8.0', { markerAgeDays: 90 }); + const release = launcher.acquireLock(`${locked}.lock`); + launcher.pruneSiblings(root, '1.0.0'); + assert.equal(fs.existsSync(locked), true); + release(); + }); }); describe('lock', () => { @@ -267,30 +411,54 @@ describe('lock', () => { release(); }); - test('an old lock is stale even if its pid looks alive', () => { + test('an old lock on this host is not stale while its pid is alive', () => { const lock = path.join(tmpdir(), 'v.lock'); fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, host: os.hostname(), time: 0 })); - assert.equal(launcher.lockIsStale(lock), true); + assert.equal(launcher.lockIsStale(lock), false); }); - test('a lock from another host is not judged by local pids', () => { + test('a lock from another host is judged by age, not local pids', () => { const lock = path.join(tmpdir(), 'v.lock'); fs.mkdirSync(lock); fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: 999999, host: 'elsewhere', time: Date.now() })); assert.equal(launcher.lockIsStale(lock, Date.now(), () => false), false); + assert.equal(launcher.lockIsStale(lock, Date.now() + 11 * 60 * 1000, () => false), true); + }); + + test('release leaves a lock that another process has taken over', () => { + const lock = path.join(tmpdir(), 'v.lock'); + const release = launcher.acquireLock(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: 4242, host: os.hostname(), time: 1 })); + release(); + assert.equal(fs.existsSync(lock), true); + }); + + test('a failure writing the lock is a clean LauncherError and leaves no lock', () => { + const lock = path.join(tmpdir(), 'v.lock'); + const writeFile = () => { + throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }); + }; + assert.throws( + () => launcher.acquireLock(lock, { writeFile }), + (err) => err instanceof launcher.LauncherError && /Could not create the setup lock.*ENOSPC/.test(err.message), + ); + assert.equal(fs.existsSync(lock), false); }); }); describe('child environment', () => { - test('marks the launcher and increments the depth', () => { + test('marks the launcher and adds nothing else', () => { const env = launcher.childEnv({ HOME: '/h' }, 'uvx'); - assert.equal(env.DEEPCTL_LAUNCHER, 'npm'); - assert.equal(env.DEEPCTL_NPM_LAUNCHER_DEPTH, '1'); - assert.equal(env.HOME, '/h'); + assert.deepEqual(env, { HOME: '/h', DEEPCTL_LAUNCHER: 'npm' }); assert.equal(launcher.childEnv({}, 'venv').DEEPCTL_LAUNCHER, 'npm'); }); + test('npx and npm exec are reported as npx', () => { + assert.equal(launcher.childEnv({ npm_command: 'exec' }, 'uvx').DEEPCTL_LAUNCHER, 'npx'); + assert.equal(launcher.childEnv({ npm_command: 'run-script' }, 'venv').DEEPCTL_LAUNCHER, 'npm'); + }); + test('the path strategy removes DEEPCTL_LAUNCHER', () => { const env = launcher.childEnv({ DEEPCTL_LAUNCHER: 'npm' }, 'path'); assert.equal('DEEPCTL_LAUNCHER' in env, false); @@ -343,6 +511,7 @@ describe('resolveRunner', () => { realpath: (f) => f, isExecutable: (f) => f === '/bin/uv', exists: (f) => f.startsWith('/h/.cache/deepgram-cli/venvs/1.2.3/'), + trustRoot: () => true, }); assert.equal(r.strategy, 'venv'); assert.equal(r.command, '/h/.cache/deepgram-cli/venvs/1.2.3/bin/deepctl'); @@ -382,10 +551,13 @@ describe('bin/dg.js end to end', posixOnly, () => { return spawnSync(process.execPath, [BIN, ...args], { env, encoding: 'utf8' }); } - test('aborts when started recursively', () => { - const r = runBin({ PATH: '/nonexistent', DEEPCTL_NPM_LAUNCHER_DEPTH: '1' }); - assert.equal(r.status, 1); - assert.match(r.stderr, /recursively/); + test('a dg run from inside deepctl (nested launcher) still works', () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + fakeExe(bin, 'uv', '#!/bin/sh\necho nested-ok\n'); + const r = runBin({ PATH: bin, HOME: home, XDG_CACHE_HOME: path.join(home, 'c'), DEEPCTL_NPM_LAUNCHER_DEPTH: '1' }); + assert.equal(r.status, 0); + assert.equal(r.stdout, 'nested-ok\n'); }); test('no uv and no Python: clear error on stderr, nothing on stdout, exit 1', () => { @@ -399,11 +571,32 @@ describe('bin/dg.js end to end', posixOnly, () => { test('passes arguments, env and exit code through a fake uv', () => { const home = tmpdir(); const bin = path.join(home, 'bin'); - fakeExe(bin, 'uv', '#!/bin/sh\necho "$DEEPCTL_LAUNCHER $DEEPCTL_NPM_LAUNCHER_DEPTH $*"\nexit 7\n'); + fakeExe(bin, 'uv', '#!/bin/sh\necho "$DEEPCTL_LAUNCHER $*"\nexit 7\n'); const r = runBin({ PATH: bin, HOME: home, XDG_CACHE_HOME: path.join(home, 'c') }, ['listen', 'a b.wav']); assert.equal(r.status, 7); const version = launcher.packageVersion(PKG_DIR); - assert.equal(r.stdout, `npm 1 tool run --from deepctl==${version} deepctl listen a b.wav\n`); + assert.equal(r.stdout, `npm tool run --from deepctl==${version} deepctl listen a b.wav\n`); + }); + + test('a SIGINT sent only to the launcher reaches the child after the grace period', async () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + fakeExe(bin, 'deepctl', '#!/bin/sh\ntrap "echo got-INT; exit 130" INT\nwhile :; do sleep 0.1; done\n'); + const { spawn } = require('node:child_process'); + const child = spawn(process.execPath, [BIN], { + env: { PATH: `${bin}:/usr/bin:/bin`, HOME: home, DEEPCTL_NPM_RUNNER: 'path' }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + let out = ''; + child.stdout.on('data', (d) => (out += d)); + await new Promise((r) => setTimeout(r, 500)); + const sentAt = Date.now(); + process.kill(child.pid, 'SIGINT'); + const code = await new Promise((r) => child.on('exit', (c) => r(c))); + assert.equal(code, 130); + assert.match(out, /got-INT/); + assert.ok(Date.now() - sentAt >= 900, 'forwarded only after the grace period'); }); test('re-raises the signal the child died from', () => { From 50a4fba0482212533118ff086d8bcf2ed87c9686 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 13:37:11 -0400 Subject: [PATCH 10/21] fix: read DEEPCTL_LAUNCHER once and keep it out of child processes deepctl_core.launcher captures the variable at startup (src/deepctl/main.py) and removes it from os.environ, so a starter app, shell or nested dg that deepctl starts does not inherit a claim about how it was installed. The update and skills startup checks and install detection all read that one value. Under npx, update hints say npx @deepgram/cli@latest. --- conftest.py | 10 ++++ .../src/deepctl_cmd_skills/startup_check.py | 4 +- .../tests/unit/test_skills_command.py | 3 ++ .../src/deepctl_cmd_update/installation.py | 22 ++++---- .../src/deepctl_cmd_update/startup_check.py | 17 ++---- .../tests/unit/test_installation.py | 24 +++++++++ .../tests/unit/test_startup_check.py | 12 +++++ .../deepctl-core/src/deepctl_core/launcher.py | 53 +++++++++++++++++++ .../deepctl-core/tests/unit/test_launcher.py | 45 ++++++++++++++++ src/deepctl/main.py | 5 ++ tests/unit/test_main.py | 22 ++++++++ 11 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 packages/deepctl-core/src/deepctl_core/launcher.py create mode 100644 packages/deepctl-core/tests/unit/test_launcher.py diff --git a/conftest.py b/conftest.py index fc8e6cb..89d1f5c 100644 --- a/conftest.py +++ b/conftest.py @@ -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() diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py index 13f71a3..ed6ae71 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py @@ -11,6 +11,8 @@ import threading from pathlib import Path +from deepctl_core.launcher import launched_via_npm + # Environment variables that indicate a CI environment _CI_ENV_VARS = ( "CI", @@ -35,7 +37,7 @@ def _is_ci() -> bool: def _is_oneshot() -> bool: # The npm launcher may run deepctl via `uv tool run`, but its install # persists, so a one-time prompt is worth showing there too. - if os.environ.get("DEEPCTL_LAUNCHER") == "npm": + if launched_via_npm(): return False if "UV_INTERNAL__PARENT_INTERPRETER" in os.environ: return True diff --git a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py index 8d46e73..9717c0b 100644 --- a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py +++ b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py @@ -975,6 +975,9 @@ def test_npm_launcher_is_not_oneshot(self): } with patch.dict(os.environ, env, clear=False): assert startup_check._is_oneshot() is False + from deepctl_core.launcher import reset_launcher_for_tests + + reset_launcher_for_tests() with patch.dict(os.environ, env, clear=False): del os.environ["DEEPCTL_LAUNCHER"] assert startup_check._is_oneshot() is True diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py index 857e88c..c75e864 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py @@ -7,19 +7,9 @@ from enum import Enum from pathlib import Path +from deepctl_core.launcher import get_launcher, launched_via_npm from pydantic import BaseModel -# The @deepgram/cli npm launcher sets this in deepctl's environment. Its -# venv or uv cache is private to the launcher, so pip/uv commands run from -# inside it would update the wrong thing; npm is the only way to update. -LAUNCHER_ENV_VAR = "DEEPCTL_LAUNCHER" -NPM_UPDATE_COMMAND = "npm install -g @deepgram/cli@latest" - - -def launched_via_npm() -> bool: - """Return True if deepctl was started by the @deepgram/cli npm launcher.""" - return os.environ.get(LAUNCHER_ENV_VAR) == "npm" - class InstallMethod(str, Enum): """Supported installation methods.""" @@ -136,9 +126,17 @@ def get_update_instructions(self, info: InstallationInfo) -> str: if info.method == InstallMethod.HOMEBREW: return "Please update using Homebrew: brew upgrade deepctl" + # The launcher's venv or uv cache is private to it, so pip/uv run + # from inside would update the wrong thing; only npm can update it. if info.method == InstallMethod.NPM: + if get_launcher() == "npx": + return ( + "Running via npx. Use the latest version with: " + "npx @deepgram/cli@latest" + ) return ( - f"Installed via npm. Update with: {NPM_UPDATE_COMMAND} " + "Installed via npm. Update with: " + "npm install -g @deepgram/cli@latest " "(or run npx @deepgram/cli@latest)" ) diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py index 6d26708..bbaec96 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import Any +from deepctl_core.launcher import launched_via_npm, npm_upgrade_command + # Sentinel used when the background check is suppressed or has no result _NO_RESULT: dict[str, Any] = {} @@ -42,19 +44,10 @@ def _is_ci() -> bool: return any(os.environ.get(v) for v in _CI_ENV_VARS) -def _launched_via_npm() -> bool: - """Return True if the @deepgram/cli npm launcher started deepctl. - - Mirrors ``installation.launched_via_npm`` without importing pydantic on - every CLI start. - """ - return os.environ.get("DEEPCTL_LAUNCHER") == "npm" - - def upgrade_hint() -> str: """Return the command a user should run to upgrade deepctl.""" - if _launched_via_npm(): - return "npm install -g @deepgram/cli@latest" + if launched_via_npm(): + return npm_upgrade_command() return "dg update" @@ -63,7 +56,7 @@ def _is_oneshot() -> bool: # The npm launcher often runs deepctl through `uv tool run`, which looks # one-shot, but the npm install itself persists and is pinned to one # version, so its users do need to hear about updates. - if _launched_via_npm(): + if launched_via_npm(): return False # uvx if "UV_INTERNAL__PARENT_INTERPRETER" in os.environ: diff --git a/packages/deepctl-cmd-update/tests/unit/test_installation.py b/packages/deepctl-cmd-update/tests/unit/test_installation.py index bfda5f5..c932dfc 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_installation.py +++ b/packages/deepctl-cmd-update/tests/unit/test_installation.py @@ -282,6 +282,22 @@ def test_npm_instructions(self, detector): assert "npx @deepgram/cli@latest" in instructions + @patch.dict("os.environ", {"DEEPCTL_LAUNCHER": "npx"}, clear=False) + def test_npx_instructions(self, detector): + from deepctl_cmd_update.installation import InstallationInfo + + info = InstallationInfo( + method=InstallMethod.NPM, + path="/home/u/.cache/deepgram-cli/venvs/0.4.0", + virtual_env=True, + editable=False, + python_executable="/home/u/.cache/deepgram-cli/venvs/0.4.0/bin/python", + ) + instructions = detector.get_update_instructions(info) + assert "npx @deepgram/cli@latest" in instructions + assert "npm install -g" not in instructions + + class TestNpmLauncherDetection: """The npm launcher marks deepctl's environment with DEEPCTL_LAUNCHER=npm.""" @@ -316,6 +332,14 @@ def test_other_launcher_values_are_ignored(self): == InstallMethod.UVX ) + @patch.dict("os.environ", {"DEEPCTL_LAUNCHER": "npx"}, clear=False) + def test_npx_is_the_npm_method(self): + detector = InstallationDetector() + assert ( + detector._detect_method(None, in_venv=True, is_editable=False) + == InstallMethod.NPM + ) + @patch.dict("os.environ", {}, clear=False) def test_detect_reports_npm(self): import os diff --git a/packages/deepctl-cmd-update/tests/unit/test_startup_check.py b/packages/deepctl-cmd-update/tests/unit/test_startup_check.py index c83964e..cc26c84 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_startup_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_startup_check.py @@ -78,6 +78,18 @@ def test_npm_launcher_is_not_oneshot(self): """npm runs deepctl via uv, but the npm install persists.""" assert _is_oneshot() is False + @patch.dict("os.environ", {"DEEPCTL_LAUNCHER": "npx"}, clear=True) + def test_upgrade_hint_under_npx(self): + from deepctl_cmd_update.startup_check import upgrade_hint + + assert upgrade_hint() == "npx @deepgram/cli@latest" + + @patch.dict("os.environ", {}, clear=True) + def test_upgrade_hint_default(self): + from deepctl_cmd_update.startup_check import upgrade_hint + + assert upgrade_hint() == "dg update" + class TestCacheTimestamp: """Test file-based timestamp caching.""" diff --git a/packages/deepctl-core/src/deepctl_core/launcher.py b/packages/deepctl-core/src/deepctl_core/launcher.py new file mode 100644 index 0000000..58b31d3 --- /dev/null +++ b/packages/deepctl-core/src/deepctl_core/launcher.py @@ -0,0 +1,53 @@ +"""Which wrapper, if any, started deepctl. + +The ``@deepgram/cli`` npm package runs deepctl from a private venv or through +``uv tool run`` and says so in ``DEEPCTL_LAUNCHER``: ``npm`` for a global +install, ``npx`` for ``npx``/``npm exec``. Nothing else sets it. + +The variable is read once and removed from ``os.environ``, so it describes +this process only. Anything deepctl spawns -- a starter app, a shell, another +``dg`` -- must not inherit a claim about how *it* was installed. +""" + +from __future__ import annotations + +import os + +LAUNCHER_ENV_VAR = "DEEPCTL_LAUNCHER" +NPM_LAUNCHERS = frozenset({"npm", "npx"}) + +_captured = False +_launcher: str | None = None + + +def capture_launcher() -> str | None: + """Read and remove ``DEEPCTL_LAUNCHER`` (first call only); return its value.""" + global _captured, _launcher + if not _captured: + _launcher = os.environ.pop(LAUNCHER_ENV_VAR, None) or None + _captured = True + return _launcher + + +def get_launcher() -> str | None: + """Return the launcher that started deepctl, or ``None``.""" + return capture_launcher() + + +def launched_via_npm() -> bool: + """Return True if the npm package (global install or npx) started deepctl.""" + return get_launcher() in NPM_LAUNCHERS + + +def npm_upgrade_command() -> str: + """Return the command that upgrades an npm-launched deepctl.""" + if get_launcher() == "npx": + return "npx @deepgram/cli@latest" + return "npm install -g @deepgram/cli@latest" + + +def reset_launcher_for_tests() -> None: + """Forget the captured value so the next call re-reads the environment.""" + global _captured, _launcher + _captured = False + _launcher = None diff --git a/packages/deepctl-core/tests/unit/test_launcher.py b/packages/deepctl-core/tests/unit/test_launcher.py new file mode 100644 index 0000000..ae954b2 --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_launcher.py @@ -0,0 +1,45 @@ +"""Unit tests for deepctl_core.launcher.""" + +import os +from unittest.mock import patch + +from deepctl_core import launcher + + +@patch.dict(os.environ, {"DEEPCTL_LAUNCHER": "npm"}, clear=False) +def test_capture_reads_once_and_removes_the_variable(): + assert launcher.capture_launcher() == "npm" + assert "DEEPCTL_LAUNCHER" not in os.environ + # A later change to the environment does not alter the captured value. + os.environ["DEEPCTL_LAUNCHER"] = "npx" + assert launcher.get_launcher() == "npm" + assert os.environ["DEEPCTL_LAUNCHER"] == "npx" + + +@patch.dict(os.environ, {}, clear=False) +def test_unset_means_no_launcher(): + os.environ.pop("DEEPCTL_LAUNCHER", None) + assert launcher.get_launcher() is None + assert launcher.launched_via_npm() is False + + +@patch.dict(os.environ, {"DEEPCTL_LAUNCHER": ""}, clear=False) +def test_empty_value_means_no_launcher(): + assert launcher.get_launcher() is None + + +@patch.dict(os.environ, {"DEEPCTL_LAUNCHER": "npm"}, clear=False) +def test_npm_global_install_upgrade_command(): + assert launcher.launched_via_npm() is True + assert launcher.npm_upgrade_command() == "npm install -g @deepgram/cli@latest" + + +@patch.dict(os.environ, {"DEEPCTL_LAUNCHER": "npx"}, clear=False) +def test_npx_upgrade_command(): + assert launcher.launched_via_npm() is True + assert launcher.npm_upgrade_command() == "npx @deepgram/cli@latest" + + +@patch.dict(os.environ, {"DEEPCTL_LAUNCHER": "something-else"}, clear=False) +def test_unknown_launcher_is_not_npm(): + assert launcher.launched_via_npm() is False diff --git a/src/deepctl/main.py b/src/deepctl/main.py index cb8fb51..7b67a33 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -19,9 +19,14 @@ print_timing_summary, setup_output, ) +from deepctl_core.launcher import capture_launcher from deepctl_core.output import stderr_console from rich.traceback import install +# Read DEEPCTL_LAUNCHER (set by the @deepgram/cli npm package) once and drop it +# from the environment, so processes deepctl starts do not inherit it. +LAUNCHER = capture_launcher() + # Install rich traceback for better error messages install(show_locals=True) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 7ad33fa..deff935 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -336,3 +336,25 @@ def test_usage_error_without_hint_keeps_contract(self, capsys): captured = capsys.readouterr() assert captured.out == "" assert "Error" in captured.err + + +def test_launcher_marker_is_captured_and_not_inherited(): + """DEEPCTL_LAUNCHER describes this process only; children must not see it.""" + import os + import subprocess + + code = ( + "import os, subprocess, sys\n" + "import importlib\n" + "m = importlib.import_module('deepctl.main')\n" + "child = subprocess.run([sys.executable, '-c', " + "'import os; print(os.environ.get(\"DEEPCTL_LAUNCHER\"))'], " + "capture_output=True, text=True).stdout.strip()\n" + "print(m.LAUNCHER, child)\n" + ) + env = {**os.environ, "DEEPCTL_LAUNCHER": "npm"} + out = subprocess.run( + [sys.executable, "-c", code], env=env, capture_output=True, text=True + ) + assert out.returncode == 0, out.stderr + assert out.stdout.split() == ["npm", "None"] From bdeb9043f2fead88e8cd064878d36fc78c4c16f5 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 13:37:12 -0400 Subject: [PATCH 11/21] ci(test): check npm/LICENSE and smoke-test the launcher's venv path The launcher job now fails if npm/LICENSE drifts from LICENSE, and runs the launcher with DEEPCTL_NPM_RUNNER=venv on all three OSes, asserting stdout is exactly the version line. --- .github/workflows/test.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be25816..2319576 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,6 +135,11 @@ jobs: - 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 + - name: Install uv uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 @@ -146,10 +151,28 @@ jobs: 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 From abe80e5e6215e755e2891f7f3025ce252491eb33 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 13:37:12 -0400 Subject: [PATCH 12/21] ci(release): add a manual npm re-publish and keep older lines off latest The publish steps move into a composite action shared by publish-npm and a new publish-npm-manual job, run by dispatching release.yml with npm_tag. The manual job checks the tag exists, that npm/package.json at the tag matches it, and that deepctl==V resolves from PyPI, and skips release-please. A version lower than the current latest dist-tag publishes under v.-maint. npm is pinned to 11.21.0. --- .github/actions/publish-npm/action.yml | 148 +++++++++++++++++++++++++ .github/workflows/release.yml | 141 ++++++++++++----------- 2 files changed, 218 insertions(+), 71 deletions(-) create mode 100644 .github/actions/publish-npm/action.yml diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml new file mode 100644 index 0000000..9493b2f --- /dev/null +++ b/.github/actions/publish-npm/action.yml @@ -0,0 +1,148 @@ +name: Publish @deepgram/cli to npm +description: > + Publish the npm launcher package with trusted publishing and provenance, + then wait until `npx @deepgram/cli@` actually runs. Shared by the + release job and the manual re-publish job in release.yml; the calling job + must have `id-token: write` and run in the `npm-publish` environment, which + is what the npm trusted publisher is bound to. + +inputs: + version: + description: Version to publish (X.Y.Z). The package.json must already say this. + required: true + package-dir: + description: Directory holding the npm package.json. + required: true + +runs: + using: composite + steps: + # 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 + shell: bash + 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 nobody verified. + - name: Check the package version + shell: bash + run: | + set -euo pipefail + PKG_VERSION="$(node -p 'require(process.argv[1]).version' "$PWD/${PACKAGE_DIR}/package.json")" + if [ "${PKG_VERSION}" != "${VERSION}" ]; then + echo "::error::${PACKAGE_DIR}/package.json is ${PKG_VERSION}, expected ${VERSION}" + 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 + env: + VERSION: ${{ inputs.version }} + PACKAGE_DIR: ${{ inputs.package-dir }} + + # 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 + shell: bash + 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: ${{ inputs.version }} + + # `npm publish` moves `latest` by default. A patch for an older line + # (re-publishing 0.4.x after 0.5.0 shipped) must not drag every new + # install back, so it goes out under a maintenance tag instead. + - name: Choose the dist-tag + id: tag + shell: bash + run: | + set -euo pipefail + LATEST="$(npm view @deepgram/cli dist-tags.latest 2>/dev/null || true)" + 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: ${{ inputs.version }} + + - name: Publish + if: steps.existing.outputs.published != 'true' + shell: bash + working-directory: ${{ inputs.package-dir }} + run: npm publish --access public --provenance --tag "${DIST_TAG}" + env: + DIST_TAG: ${{ steps.tag.outputs.tag }} + + - name: Wait until the registry serves the version + shell: bash + 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: ${{ inputs.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 + shell: bash + 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: ${{ inputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c3c9c8..2f2ebf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,13 @@ on: branches: - main workflow_dispatch: + inputs: + npm_tag: + description: >- + Re-publish only the npm package for an existing root release tag + (for example v0.4.1). Leave empty for a normal release run. + required: false + default: "" permissions: contents: write @@ -13,6 +20,9 @@ permissions: jobs: release-please: name: Release Please + # A manual npm re-publish must not cut releases or touch the release PR; + # skipping this job skips everything that needs it. + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.npm_tag }} runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} @@ -368,93 +378,82 @@ jobs: 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. - - name: Install an npm that supports trusted publishing - run: npm install -g npm@^11.5.1 - - # 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 + - name: Read the version from the tag id: version - run: | - set -euo pipefail - TAG_VERSION="${TAG_NAME#v}" - PKG_VERSION="$(node -p 'require("./npm/package.json").version')" - if [ "${PKG_VERSION}" != "${TAG_VERSION}" ]; then - echo "::error::npm/package.json is ${PKG_VERSION} but the tag is ${TAG_NAME}" - exit 1 - fi - if ! printf '%s' "${PKG_VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::${PKG_VERSION} is not a plain X.Y.Z version; refusing to publish it as latest" - exit 1 - fi - echo "version=${PKG_VERSION}" >> "$GITHUB_OUTPUT" + run: echo "version=${TAG_NAME#v}" >> "$GITHUB_OUTPUT" env: TAG_NAME: ${{ needs.release-please.outputs.tag_name }} - # 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 + - name: Publish + uses: ./.github/actions/publish-npm + with: + version: ${{ steps.version.outputs.version }} + package-dir: npm + + # Manual path for when the automatic publish could not run: the trusted + # publisher was not configured yet, npm was down past the job timeout, or + # a release predates this job. Same workflow file and environment as + # publish-npm, because that is what the trusted publisher accepts. + publish-npm-manual: + name: Re-publish @deepgram/cli to npm (manual) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_tag }} + runs-on: ubuntu-latest + environment: npm-publish + permissions: + id-token: write + contents: read + timeout-minutes: 30 + steps: + - name: Validate the tag name + id: version 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" + if ! printf '%s' "${NPM_TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::npm_tag must look like v1.2.3 (got '${NPM_TAG}')" + exit 1 fi + echo "version=${NPM_TAG#v}" >> "$GITHUB_OUTPUT" env: - VERSION: ${{ steps.version.outputs.version }} + NPM_TAG: ${{ inputs.npm_tag }} - - name: Publish - if: steps.existing.outputs.published != 'true' - working-directory: npm - run: npm publish --access public --provenance + # The publish steps come from the branch this was dispatched on; the + # package itself comes from the tag, so what ships is what was released. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - name: Wait until the registry serves the version + - name: Check the tag exists 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 + if ! git ls-remote --exit-code --tags origin "refs/tags/${NPM_TAG}" >/dev/null; then + echo "::error::tag ${NPM_TAG} does not exist" + exit 1 + fi env: - VERSION: ${{ steps.version.outputs.version }} + NPM_TAG: ${{ inputs.npm_tag }} - # 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 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + ref: refs/tags/${{ inputs.npm_tag }} + path: release + + # The same gate verify-published applies on a normal release: the + # launcher must never point at a deepctl version pip cannot install. + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" - - name: Run the published package + - name: Check deepctl resolves from PyPI 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 + python -m venv /tmp/verify + /tmp/verify/bin/pip install --quiet --upgrade pip + /tmp/verify/bin/pip install --dry-run --no-cache-dir "deepctl==${VERSION}" env: VERSION: ${{ steps.version.outputs.version }} + + # The action also checks release/npm/package.json equals the tag. + - name: Publish + uses: ./.github/actions/publish-npm + with: + version: ${{ steps.version.outputs.version }} + package-dir: release/npm From 1310ec3c858b78e630ebf0060a1d35b60bdeff58 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 13:37:12 -0400 Subject: [PATCH 13/21] docs: note that the npm package installs only dg and deepgram --- web/public/llms-full.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 0945382..3d3b426 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -28,7 +28,7 @@ npm install -g @deepgram/cli # npm (needs uv or Python 3.10+; installs dg npx @deepgram/cli --help # npx (run without install) ``` -Aliases: `dg`, `deepctl`, `deepgram` +Aliases: `dg`, `deepctl`, `deepgram` (the npm package installs only `dg` and `deepgram`) --- From 92015f7715fc119cdbaee0358874a5803559db5b Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 14:27:29 -0400 Subject: [PATCH 14/21] fix(npm): close the remaining launcher cache, lock and signal gaps - Under a terminal, a SIGINT/SIGQUIT the launcher sees came from the TTY and reached the child too, so it is no longer forwarded a second time (that cut short a child still cleaning up). Without a terminal the delayed forward stays. - A same-host lock naming our own pid, or older than an hour, is stale: a container that crashed mid-setup restarts as pid 1 with the same hostname and cache volume and would otherwise wait forever. - Stale locks are moved aside under a unique name and deleted only if the owner is still the one judged stale, so two waiters cannot delete each other's fresh lock. - A cached venv whose bin/python no longer resolves (base interpreter uninstalled) is treated as missing and rebuilt instead of failing every run with ENOENT. - Every ancestor of a cache root, as written and as resolved, must belong to root or the user and be sticky if others can write to it, so the cache cannot be swapped between check and exec. - Unexpected filesystem errors are reported in one line. - npx is detected from the package's own path (_npx), so a global dg run from inside an npx process is still reported as npm. --- .github/actions/publish-npm/action.yml | 148 ------------------------- npm/lib/launcher.js | 147 ++++++++++++++++++++---- npm/test/launcher.test.js | 140 ++++++++++++++++++++--- 3 files changed, 248 insertions(+), 187 deletions(-) delete mode 100644 .github/actions/publish-npm/action.yml diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml deleted file mode 100644 index 9493b2f..0000000 --- a/.github/actions/publish-npm/action.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Publish @deepgram/cli to npm -description: > - Publish the npm launcher package with trusted publishing and provenance, - then wait until `npx @deepgram/cli@` actually runs. Shared by the - release job and the manual re-publish job in release.yml; the calling job - must have `id-token: write` and run in the `npm-publish` environment, which - is what the npm trusted publisher is bound to. - -inputs: - version: - description: Version to publish (X.Y.Z). The package.json must already say this. - required: true - package-dir: - description: Directory holding the npm package.json. - required: true - -runs: - using: composite - steps: - # 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 - shell: bash - 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 nobody verified. - - name: Check the package version - shell: bash - run: | - set -euo pipefail - PKG_VERSION="$(node -p 'require(process.argv[1]).version' "$PWD/${PACKAGE_DIR}/package.json")" - if [ "${PKG_VERSION}" != "${VERSION}" ]; then - echo "::error::${PACKAGE_DIR}/package.json is ${PKG_VERSION}, expected ${VERSION}" - 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 - env: - VERSION: ${{ inputs.version }} - PACKAGE_DIR: ${{ inputs.package-dir }} - - # 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 - shell: bash - 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: ${{ inputs.version }} - - # `npm publish` moves `latest` by default. A patch for an older line - # (re-publishing 0.4.x after 0.5.0 shipped) must not drag every new - # install back, so it goes out under a maintenance tag instead. - - name: Choose the dist-tag - id: tag - shell: bash - run: | - set -euo pipefail - LATEST="$(npm view @deepgram/cli dist-tags.latest 2>/dev/null || true)" - 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: ${{ inputs.version }} - - - name: Publish - if: steps.existing.outputs.published != 'true' - shell: bash - working-directory: ${{ inputs.package-dir }} - run: npm publish --access public --provenance --tag "${DIST_TAG}" - env: - DIST_TAG: ${{ steps.tag.outputs.tag }} - - - name: Wait until the registry serves the version - shell: bash - 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: ${{ inputs.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 - shell: bash - 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: ${{ inputs.version }} diff --git a/npm/lib/launcher.js b/npm/lib/launcher.js index 75e2d63..32c02fb 100644 --- a/npm/lib/launcher.js +++ b/npm/lib/launcher.js @@ -14,6 +14,9 @@ const PKG_DIR = path.resolve(__dirname, '..'); const MIN_PYTHON = [3, 10]; const LOCK_WAIT_MS = 5 * 60 * 1000; const LOCK_STALE_MS = 10 * 60 * 1000; +// No first-run install takes this long; past it a same-host lock is +// abandoned even if its pid has been reused by an unrelated process. +const LOCK_MAX_MS = 60 * 60 * 1000; const LOCK_POLL_MS = 250; const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; const SIGNAL_GRACE_MS = 1000; @@ -247,27 +250,65 @@ function currentUid(opts) { return process.getuid ? process.getuid() : null; } +const STICKY = 0o1000; + // Whatever is in a cache root gets executed, so the root and its parent // must be ours: real directories (not symlinks) owned by this uid that no // one else can write to. In a shared root such as /tmp, another user could // otherwise pre-create the directory and plant a "cached" deepctl, so it -// must also be private (0700). Windows has no uid and per-user LOCALAPPDATA. +// must also be private (0700). +// +// Every ancestor above them matters too: whoever can rename entries in an +// ancestor can swap our cache for theirs between our check and our exec. +// So each ancestor must belong to root or to us, and if others can write to +// it, it must be sticky (like /tmp), which stops them renaming our entries. +// Windows has no uid and a per-user LOCALAPPDATA. function rootTrusted(spec, opts = {}) { const platform = opts.platform || process.platform; if (isWindows(platform)) return true; const uid = currentUid(opts); const lstat = opts.lstat || fs.lstatSync; const forbidden = spec.shared ? 0o077 : 0o022; - for (const dir of [path.posix.dirname(spec.dir), spec.dir]) { + const statDir = (dir) => { + try { + const st = lstat(dir); + return st.isDirectory() ? st : null; + } catch { + return null; + } + }; + const top = path.posix.dirname(spec.dir); + for (const dir of [top, spec.dir]) { + const st = statDir(dir); + if (!st || st.uid !== uid || (st.mode & forbidden) !== 0) return false; + } + // Both the path as written (whose symlinks, like macOS's /var, must not + // be replaceable) and the real path it resolves to. + const realpath = opts.realpath || fs.realpathSync; + const above = path.posix.dirname(top); + let real; + try { + real = realpath(above); + } catch { + return false; + } + return ancestorsSafe(above, uid, lstat, true) && ancestorsSafe(real, uid, lstat, false); +} + +function ancestorsSafe(start, uid, lstat, allowLinks) { + for (let dir = start; ; dir = path.posix.dirname(dir)) { let st; try { st = lstat(dir); } catch { return false; } - if (!st.isDirectory() || st.uid !== uid || (st.mode & forbidden) !== 0) return false; + const link = st.isSymbolicLink ? st.isSymbolicLink() : false; + if (link ? !allowLinks : !st.isDirectory()) return false; + if (st.uid !== 0 && st.uid !== uid) return false; + if (!link && (st.mode & 0o022) !== 0 && (st.mode & STICKY) === 0) return false; + if (dir === '/' || dir === '.') return true; } - return true; } function mkdirPrivate(dir) { @@ -314,11 +355,19 @@ function findCachedVenv(key, opts = {}) { for (const spec of cacheRootSpecs(opts)) { if (!trusted(spec)) continue; const layout = venvLayout(p.join(spec.dir, key), opts.platform); - if (exists(layout.marker) && exists(layout.deepctl)) return layout; + if (venvUsable(layout, exists)) return layout; } return null; } +// A venv's python is a link to the interpreter that built it. When that +// interpreter is uninstalled (brew, pyenv, a distro upgrade), every console +// script fails with a confusing ENOENT, so a venv without a working python +// is treated as missing and rebuilt. existsSync follows the link. +function venvUsable(layout, exists = fs.existsSync) { + return exists(layout.marker) && exists(layout.deepctl) && exists(layout.python); +} + // Marks a venv as recently used, which keeps pruneSiblings away from it. function touchMarker(marker) { try { @@ -353,10 +402,16 @@ function lockIsStale(lockDir, now = Date.now(), alive = pidAlive) { // the directory's age. } if (owner && typeof owner.pid === 'number') { - // On this host the pid is authoritative: a slow first install (a big - // wheel on a bad network) can outlive any age limit. Across hosts - // (a shared home directory) the pid means nothing, so fall back to age. - if (owner.host === os.hostname()) return !alive(owner.pid); + if (owner.host === os.hostname()) { + // Our own pid in someone else's lock means the pid was reused: a + // container that crashed mid-setup restarts as pid 1 with the same + // hostname and cache volume. A live pid is otherwise trusted for a + // slow install, up to an hour, after which it must be a reused pid. + if (owner.pid === process.pid) return true; + if (!alive(owner.pid)) return true; + return now - owner.time > LOCK_MAX_MS; + } + // Across hosts (a shared home directory) the pid means nothing. return now - owner.time > LOCK_STALE_MS; } try { @@ -390,7 +445,7 @@ function acquireLock(lockDir, opts = {}) { } } if (lockIsStale(lockDir)) { - fs.rmSync(lockDir, { recursive: true, force: true }); + breakStaleLock(lockDir); continue; } if (Date.now() >= deadline) { @@ -404,6 +459,39 @@ function acquireLock(lockDir, opts = {}) { } } +function readOwnerRaw(lockDir) { + try { + return fs.readFileSync(path.join(lockDir, 'owner.json'), 'utf8'); + } catch { + return null; + } +} + +// Two waiters can both judge the same lock stale. If each simply deleted +// it, the slower one could delete the lock the faster one has just taken. +// Instead, move the lock aside under a unique name (rename is atomic, so +// only one waiter gets the stale lock) and delete it only if what moved is +// still the owner that was judged stale. If a fresh lock was moved by +// mistake, try to put it back; the caller then simply retries. +function breakStaleLock(lockDir) { + const judged = readOwnerRaw(lockDir); + const grave = `${lockDir}.stale.${process.pid}.${crypto.randomBytes(4).toString('hex')}`; + try { + fs.renameSync(lockDir, grave); + } catch { + return; // Someone else moved or removed it first. + } + if (readOwnerRaw(grave) === judged) { + fs.rmSync(grave, { recursive: true, force: true }); + return; + } + try { + fs.renameSync(grave, lockDir); + } catch { + // A new lock exists already; leave the moved one for pruning. + } +} + // Only removes the lock if it is still ours: if it was judged stale and // taken over while we ran, the new owner's lock must survive. function releaseLock(lockDir, owner) { @@ -477,7 +565,8 @@ function buildVenv({ python, key, spec, version, opts = {} }) { const onExit = () => release(); process.once('exit', onExit); try { - if (fs.existsSync(layout.marker)) return layout; + if (venvUsable(layout)) return layout; + // Half-built, or broken by a removed base interpreter: start over. fs.rmSync(layout.dir, { recursive: true, force: true }); 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']; @@ -500,13 +589,19 @@ function buildVenv({ python, key, spec, version, opts = {} }) { // --- Child environment and exit mapping ------------------------------------ -function childEnv(env, strategy) { +// npx and `npm exec` unpack packages under npm's cache in a `_npx` +// directory. Where this package lives is what counts, not how the parent +// was started: a global dg run from inside an npx process is still global. +function launcherKind(pkgDir = PKG_DIR) { + return pkgDir.split(/[\\/]/).includes('_npx') ? 'npx' : 'npm'; +} + +function childEnv(env, strategy, pkgDir = PKG_DIR) { const out = { ...env }; // A deepctl the user installed themselves knows its own install method; // telling it "npm" would make `dg update` give the wrong advice. if (strategy === 'path') delete out.DEEPCTL_LAUNCHER; - // npx and `npm exec` set npm_command=exec; a global `dg` does not. - else out.DEEPCTL_LAUNCHER = env.npm_command === 'exec' ? 'npx' : 'npm'; + else out.DEEPCTL_LAUNCHER = launcherKind(pkgDir); return out; } @@ -587,9 +682,10 @@ function run(argv = process.argv.slice(2), opts = {}) { try { runner = resolveRunner(argv, opts); } catch (err) { - if (!(err instanceof LauncherError)) throw err; - const hint = err.hint ? `\n${err.hint}` : ''; - stderr.write(`dg: ${err.message.trimStart()}${hint}\n`); + // Filesystem surprises (a cache directory changing underneath us) get + // the same one-line treatment as expected failures, not a stack trace. + const hint = err instanceof LauncherError && err.hint ? `\n${err.hint}` : ''; + stderr.write(`dg: ${String(err.message || err).trimStart()}${hint}\n`); process.exit(1); } @@ -613,11 +709,17 @@ function run(argv = process.argv.slice(2), opts = {}) { }, SIGNAL_GRACE_MS); timers.add(timer); }; - // On Windows a console Ctrl-C reaches every attached process, and - // child.kill() would terminate the child outright rather than interrupt it. - const handlers = { SIGINT: isWindows(process.platform) ? () => {} : forwardIfMissed('SIGINT'), SIGTERM: forward('SIGTERM') }; + // + // With a terminal attached, assume the signal came from it and reached the + // child too: a second, forwarded interrupt would cut short a child that + // is still cleaning up after the first. On Windows a console Ctrl-C + // reaches every attached process, and child.kill() would terminate the + // child outright rather than interrupt it. + const terminal = Boolean(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY); + const interrupt = (sig) => (isWindows(process.platform) || terminal ? () => {} : forwardIfMissed(sig)); + const handlers = { SIGINT: interrupt('SIGINT'), SIGTERM: forward('SIGTERM') }; if (!isWindows(process.platform)) { - handlers.SIGQUIT = forwardIfMissed('SIGQUIT'); + handlers.SIGQUIT = interrupt('SIGQUIT'); handlers.SIGHUP = forward('SIGHUP'); } for (const [sig, fn] of Object.entries(handlers)) process.on(sig, fn); @@ -652,7 +754,9 @@ module.exports = { acquireLock, cacheRootSpecs, cacheRoots, + breakStaleLock, childEnv, + launcherKind, exitAction, exeNames, findCachedVenv, @@ -673,6 +777,7 @@ module.exports = { run, venvKey, venvLayout, + venvUsable, versionAtLeast, which, whichAll, diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js index 59569aa..3e910aa 100644 --- a/npm/test/launcher.test.js +++ b/npm/test/launcher.test.js @@ -24,6 +24,12 @@ function fakeExe(dir, name, body = '#!/bin/sh\nexit 0\n') { return file; } +// A lock owned by another live process on this host (the test runner). +function liveForeignLock(lock, time = Date.now()) { + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.ppid, host: os.hostname(), time })); +} + const posixOnly = { skip: process.platform === 'win32' && 'POSIX shell scripts' }; describe('package.json', () => { @@ -261,13 +267,32 @@ describe('cache directory', () => { test('a cached venv is used only when complete, from either root', () => { const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1, trustRoot: () => true }; - const files = new Set(['/tmp/deepgram-cli-1/venvs/1.0.0/.complete', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl']); + const files = new Set(['/tmp/deepgram-cli-1/venvs/1.0.0/.complete', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/python']); const hit = launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }); assert.equal(hit.deepctl, '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl'); files.delete('/tmp/deepgram-cli-1/venvs/1.0.0/.complete'); assert.equal(launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }), null); }); + test('a venv whose python no longer resolves is not used', () => { + const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1, trustRoot: () => true }; + const files = new Set(['/home/u/.cache/deepgram-cli/venvs/1.0.0/.complete', '/home/u/.cache/deepgram-cli/venvs/1.0.0/bin/deepctl']); + assert.equal(launcher.findCachedVenv('1.0.0', { ...opts, exists: (f) => files.has(f) }), null); + }); + + test('a dangling bin/python symlink makes a venv unusable (real filesystem)', posixOnly, () => { + const dir = tmpdir(); + const layout = launcher.venvLayout(dir, 'linux'); + fs.mkdirSync(path.dirname(layout.python), { recursive: true }); + fs.writeFileSync(layout.marker, ''); + fakeExe(path.dirname(layout.deepctl), 'deepctl'); + fs.symlinkSync('/nonexistent/python3', layout.python); + assert.equal(launcher.venvUsable(layout), false); + fs.unlinkSync(layout.python); + fs.symlinkSync(process.execPath, layout.python); + assert.equal(launcher.venvUsable(layout), true); + }); + test('an untrusted root is never used, however complete its venv looks', () => { const opts = { env: {}, platform: 'linux', home: '/home/u', tmpdir: '/tmp', uid: 1, trustRoot: (spec) => !spec.shared }; const files = new Set(['/tmp/deepgram-cli-1/venvs/1.0.0/.complete', '/tmp/deepgram-cli-1/venvs/1.0.0/bin/deepctl']); @@ -276,15 +301,26 @@ describe('cache directory', () => { }); describe('cache root ownership (POSIX)', () => { - const dirStat = (uid, mode, isDir = true) => ({ uid, mode: (isDir ? 0o040000 : 0o120000) | mode, isDirectory: () => isDir }); + const dirStat = (uid, mode, isDir = true) => ({ uid, mode: (isDir ? 0o040000 : 0o120000) | mode, isDirectory: () => isDir, isSymbolicLink: () => !isDir }); const shared = { dir: '/tmp/deepgram-cli-1000/venvs', shared: true }; const home = { dir: '/home/u/.cache/deepgram-cli/venvs', shared: false }; - const check = (spec, stats) => launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, lstat: (d) => { - if (!(d in stats)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - return stats[d]; - } }); - - test('a private directory we own is trusted', () => { + // Sane ancestors; individual tests override entries. + const ancestors = { + '/': dirStat(0, 0o755), + '/tmp': dirStat(0, 0o1777), + '/home': dirStat(0, 0o755), + '/home/u': dirStat(1000, 0o755), + '/home/u/.cache': dirStat(1000, 0o700), + }; + const check = (spec, stats, realpath = (d) => d) => { + const all = { ...ancestors, ...stats }; + return launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, realpath, lstat: (d) => { + if (!(d in all)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return all[d]; + } }); + }; + + test('a private directory we own under sticky /tmp is trusted', () => { assert.equal(check(shared, { '/tmp/deepgram-cli-1000': dirStat(1000, 0o700), '/tmp/deepgram-cli-1000/venvs': dirStat(1000, 0o700) }), true); }); @@ -309,6 +345,32 @@ describe('cache root ownership (POSIX)', () => { assert.equal(check(home, { [base]: dirStat(2000, 0o755), [`${base}/venvs`]: dirStat(1000, 0o755) }), false); }); + test('an ancestor another user can write to is rejected', () => { + const base = '/home/u/.cache/deepgram-cli'; + const ok = { [base]: dirStat(1000, 0o700), [`${base}/venvs`]: dirStat(1000, 0o700) }; + assert.equal(check(home, ok), true); + // HOME=/tmp style: ~/.cache made world-writable by someone else. + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(2000, 0o777) }), false); + // Ours but group-writable and not sticky: still swappable by the group. + assert.equal(check(home, { ...ok, '/home/u': dirStat(1000, 0o775) }), false); + // World-writable but sticky and root-owned, like /tmp: fine. + assert.equal(check(home, { ...ok, '/home': dirStat(0, 0o1777) }), true); + // Owned by a third user, even read-only: rejected. + assert.equal(check(home, { ...ok, '/home': dirStat(2000, 0o755) }), false); + }); + + test('a root-owned symlink ancestor is allowed, an attacker-owned one is not', () => { + const base = '/home/u/.cache/deepgram-cli'; + const ok = { [base]: dirStat(1000, 0o700), [`${base}/venvs`]: dirStat(1000, 0o700) }; + // /home -> /data/home, as /var -> /private/var on macOS. + const real = (d) => d.replace(/^\/home/, '/data/home'); + const target = { '/data': dirStat(0, 0o755), '/data/home': dirStat(0, 0o755), '/data/home/u': dirStat(1000, 0o755), '/data/home/u/.cache': dirStat(1000, 0o700) }; + assert.equal(check(home, { ...ok, ...target, '/home': dirStat(0, 0o777, false) }, real), true); + assert.equal(check(home, { ...ok, ...target, '/home': dirStat(2000, 0o777, false) }, real), false); + // The resolved path is checked too. + assert.equal(check(home, { ...ok, ...target, '/data/home': dirStat(2000, 0o777), '/home': dirStat(0, 0o777, false) }, real), false); + }); + test('a missing root is not trusted', () => { assert.equal(check(shared, {}), false); }); @@ -372,10 +434,9 @@ describe('pruning other versions', () => { test('never removes a version with a live lock', () => { const root = tmpdir(); const locked = venv(root, '0.8.0', { markerAgeDays: 90 }); - const release = launcher.acquireLock(`${locked}.lock`); + liveForeignLock(`${locked}.lock`); launcher.pruneSiblings(root, '1.0.0'); assert.equal(fs.existsSync(locked), true); - release(); }); }); @@ -391,14 +452,13 @@ describe('lock', () => { test('a lock held by a live process times out and announces the wait once', () => { const lock = path.join(tmpdir(), 'v.lock'); - const release = launcher.acquireLock(lock); + liveForeignLock(lock); let waits = 0; assert.throws( () => launcher.acquireLock(lock, { waitMs: 300, pollMs: 50, onWait: () => waits++ }), /Timed out waiting/, ); assert.equal(waits, 1); - release(); }); test('a lock whose pid is dead is stale and is taken over', () => { @@ -411,11 +471,52 @@ describe('lock', () => { release(); }); - test('an old lock on this host is not stale while its pid is alive', () => { + test('a same-host lock with a live pid is kept for up to an hour', () => { const lock = path.join(tmpdir(), 'v.lock'); - fs.mkdirSync(lock); - fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, host: os.hostname(), time: 0 })); + liveForeignLock(lock, Date.now() - 30 * 60 * 1000); assert.equal(launcher.lockIsStale(lock), false); + assert.equal(launcher.lockIsStale(lock, Date.now() + 31 * 60 * 1000), true); + }); + + test('a lock naming our own pid is a reused pid and stale (crashed container restart)', () => { + const lock = path.join(tmpdir(), 'v.lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, host: os.hostname(), time: Date.now() })); + assert.equal(launcher.lockIsStale(lock), true); + const release = launcher.acquireLock(lock, { waitMs: 0 }); + release(); + assert.equal(fs.existsSync(lock), false); + }); + + test('breaking a stale lock removes it only if it is still the judged owner', () => { + const lock = path.join(tmpdir(), 'v.lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: 999999, host: os.hostname(), time: 1 })); + launcher.breakStaleLock(lock); + assert.equal(fs.existsSync(lock), false); + assert.deepEqual(fs.readdirSync(path.dirname(lock)), []); + }); + + test('a fresh lock moved aside by a racing waiter is put back', () => { + const lock = path.join(tmpdir(), 'v.lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({ pid: 1, host: 'h', time: 1 })); + // Simulate the race: the owner changes between judging and renaming. + const realRename = fs.renameSync; + let swapped = false; + fs.renameSync = (from, to) => { + if (!swapped) { + swapped = true; + fs.writeFileSync(path.join(from, 'owner.json'), JSON.stringify({ pid: 2, host: 'h', time: 2 })); + } + return realRename(from, to); + }; + try { + launcher.breakStaleLock(lock); + } finally { + fs.renameSync = realRename; + } + assert.equal(JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8')).pid, 2); }); test('a lock from another host is judged by age, not local pids', () => { @@ -454,9 +555,12 @@ describe('child environment', () => { assert.equal(launcher.childEnv({}, 'venv').DEEPCTL_LAUNCHER, 'npm'); }); - test('npx and npm exec are reported as npx', () => { - assert.equal(launcher.childEnv({ npm_command: 'exec' }, 'uvx').DEEPCTL_LAUNCHER, 'npx'); - assert.equal(launcher.childEnv({ npm_command: 'run-script' }, 'venv').DEEPCTL_LAUNCHER, 'npm'); + test('a package unpacked under npm\'s _npx cache is reported as npx', () => { + const npx = '/home/u/.npm/_npx/0123abcd/node_modules/@deepgram/cli'; + assert.equal(launcher.childEnv({}, 'uvx', npx).DEEPCTL_LAUNCHER, 'npx'); + assert.equal(launcher.launcherKind('C:\\Users\\u\\AppData\\Local\\npm-cache\\_npx\\ab\\node_modules\\@deepgram\\cli'), 'npx'); + // How the parent was started does not matter: a global dg is npm. + assert.equal(launcher.childEnv({ npm_command: 'exec' }, 'venv', '/usr/local/lib/node_modules/@deepgram/cli').DEEPCTL_LAUNCHER, 'npm'); }); test('the path strategy removes DEEPCTL_LAUNCHER', () => { From 9c04a03dc1185bce119e5475d2048f5eac694645 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 14:27:29 -0400 Subject: [PATCH 15/21] docs(npm): recommend one install method instead of npm install --force --- npm/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/npm/README.md b/npm/README.md index d53691e..c01fcd3 100644 --- a/npm/README.md +++ b/npm/README.md @@ -52,8 +52,10 @@ Node.js 18 or newer runs the launcher. ## Already installed the CLI another way? If you installed the CLI with Homebrew, pip, pipx, uv or the install script, -you don't need this package. Homebrew and npm both want to own `dg`; if you -do want the npm version to take it over, run `npm install -g --force @deepgram/cli`. +you don't need this package. Use one install method: they all provide a +`dg` command, and two of them on the same machine fight over it. To switch to +npm, uninstall the other first (for example `brew uninstall deepgram` or +`pip uninstall deepctl`), then `npm install -g @deepgram/cli`. Update the npm version with `npm install -g @deepgram/cli@latest`, or run `npx @deepgram/cli@latest`. From e0eb1a35b3235769273295a54f39d608b5eaa6fc Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 14:27:29 -0400 Subject: [PATCH 16/21] ci(release): inline the npm publish steps and fail closed on the dist-tag lookup Drop the manual npm_tag re-publish path and restore release.yml's dispatch behaviour; a failed publish is recovered with Re-run failed jobs, which the job comment documents. With one caller left, the composite action is inlined back into publish-npm. Reading the current latest dist-tag now fails the job on any registry error except E404, instead of publishing as latest. --- .github/workflows/release.yml | 188 +++++++++++++++++++++------------- 1 file changed, 119 insertions(+), 69 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f2ebf5..40068c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,6 @@ on: branches: - main workflow_dispatch: - inputs: - npm_tag: - description: >- - Re-publish only the npm package for an existing root release tag - (for example v0.4.1). Leave empty for a normal release run. - required: false - default: "" permissions: contents: write @@ -20,9 +13,6 @@ permissions: jobs: release-please: name: Release Please - # A manual npm re-publish must not cut releases or touch the release PR; - # skipping this job skips everything that needs it. - if: ${{ github.event_name != 'workflow_dispatch' || !inputs.npm_tag }} runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} @@ -362,6 +352,11 @@ jobs: # The npm package is a launcher that runs `deepctl==` # 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') @@ -375,85 +370,140 @@ jobs: id-token: write contents: read timeout-minutes: 30 + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - name: Read the version from the tag - id: version - run: echo "version=${TAG_NAME#v}" >> "$GITHUB_OUTPUT" - env: - TAG_NAME: ${{ needs.release-please.outputs.tag_name }} - - - name: Publish - uses: ./.github/actions/publish-npm + # 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: - version: ${{ steps.version.outputs.version }} - package-dir: npm - - # Manual path for when the automatic publish could not run: the trusted - # publisher was not configured yet, npm was down past the job timeout, or - # a release predates this job. Same workflow file and environment as - # publish-npm, because that is what the trusted publisher accepts. - publish-npm-manual: - name: Re-publish @deepgram/cli to npm (manual) - if: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_tag }} - runs-on: ubuntu-latest - environment: npm-publish - permissions: - id-token: write - contents: read - timeout-minutes: 30 - steps: - - name: Validate the tag name + 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 - if ! printf '%s' "${NPM_TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::npm_tag must look like v1.2.3 (got '${NPM_TAG}')" + 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 - echo "version=${NPM_TAG#v}" >> "$GITHUB_OUTPUT" - env: - NPM_TAG: ${{ inputs.npm_tag }} + 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" - # The publish steps come from the branch this was dispatched on; the - # package itself comes from the tag, so what ships is what was released. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + # 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 }} - - name: Check the tag exists + # `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 - if ! git ls-remote --exit-code --tags origin "refs/tags/${NPM_TAG}" >/dev/null; then - echo "::error::tag ${NPM_TAG} does not exist" + 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: - NPM_TAG: ${{ inputs.npm_tag }} - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - ref: refs/tags/${{ inputs.npm_tag }} - path: release + VERSION: ${{ steps.version.outputs.version }} - # The same gate verify-published applies on a normal release: the - # launcher must never point at a deepctl version pip cannot install. - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" + - 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: Check deepctl resolves from PyPI + - name: Wait until the registry serves the version run: | set -euo pipefail - python -m venv /tmp/verify - /tmp/verify/bin/pip install --quiet --upgrade pip - /tmp/verify/bin/pip install --dry-run --no-cache-dir "deepctl==${VERSION}" + 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 action also checks release/npm/package.json equals the tag. - - name: Publish - uses: ./.github/actions/publish-npm - with: - version: ${{ steps.version.outputs.version }} - package-dir: release/npm + # 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 }} From 0710ea14b60173ce515e8340298aca5be3b29167 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 14:53:09 -0400 Subject: [PATCH 17/21] fix(npm): keep deepctl's stdin blocking and accept a private-group ~/.cache - Reading process.stdin.isTTY made Node wrap fd 0 in a stream, and for a pipe libuv set O_NONBLOCK on it. deepctl inherits that open file, so 'dg mcp' never answered an initialize that arrived a moment late and piped input such as 'ffmpeg | dg listen' could fail. The launcher now uses tty.isatty() and writes its own messages with fs.writeSync(2), and never touches process.stdin, stdout or stderr. - Under umask 002 (Ubuntu, Fedora) ~/.cache is 0775 with the user's own private group. An ancestor owned by the user whose group is the user's primary group may be group-writable; other-writable or foreign-owned ancestors are still rejected. Missing parents are created 0700. - With a terminal attached, a second SIGINT within 3 seconds is passed on to deepctl. - Note the accepted residual race in stale-lock recovery. --- npm/lib/launcher.js | 53 ++++++++++++++++++++++---- npm/test/launcher.test.js | 78 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/npm/lib/launcher.js b/npm/lib/launcher.js index 32c02fb..58278a1 100644 --- a/npm/lib/launcher.js +++ b/npm/lib/launcher.js @@ -9,6 +9,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const tty = require('node:tty'); const PKG_DIR = path.resolve(__dirname, '..'); const MIN_PYTHON = [3, 10]; @@ -20,6 +21,7 @@ const LOCK_MAX_MS = 60 * 60 * 1000; const LOCK_POLL_MS = 250; const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; const SIGNAL_GRACE_MS = 1000; +const DOUBLE_INTERRUPT_MS = 3000; const PROBE_TIMEOUT_MS = 10 * 1000; const RUNNERS = ['uvx', 'venv', 'path']; @@ -27,6 +29,13 @@ const RUNNERS = ['uvx', 'venv', 'path']; // python3 without python3-venv) still reports what it is. const PROBE_CODE = 'import sys;print(tuple(sys.version_info[:2]));import venv,ensurepip'; +// Launcher messages go straight to fd 2. Touching process.stdin, stdout or +// stderr makes Node wrap the fd in a stream, and for a pipe libuv then sets +// O_NONBLOCK on it. deepctl inherits the same open file, and a non-blocking +// stdin breaks `ffmpeg ... | dg listen` and MCP hosts that send their first +// message a moment after starting `dg mcp`. +const fdStderr = { write: (text) => fs.writeSync(2, text) }; + class LauncherError extends Error { constructor(message, hint = '') { super(message); @@ -292,10 +301,14 @@ function rootTrusted(spec, opts = {}) { } catch { return false; } - return ancestorsSafe(above, uid, lstat, true) && ancestorsSafe(real, uid, lstat, false); + const gid = opts.gid !== undefined ? opts.gid : process.getgid ? process.getgid() : null; + return ancestorsSafe(above, uid, gid, lstat, true) && ancestorsSafe(real, uid, gid, lstat, false); } -function ancestorsSafe(start, uid, lstat, allowLinks) { +// Group-writable is fine for a directory we own whose group is our own +// primary group: the user-private-group convention (Ubuntu, Fedora; umask +// 002) makes ~/.cache 0775 with a group that only we are in. +function ancestorsSafe(start, uid, gid, lstat, allowLinks) { for (let dir = start; ; dir = path.posix.dirname(dir)) { let st; try { @@ -306,7 +319,9 @@ function ancestorsSafe(start, uid, lstat, allowLinks) { const link = st.isSymbolicLink ? st.isSymbolicLink() : false; if (link ? !allowLinks : !st.isDirectory()) return false; if (st.uid !== 0 && st.uid !== uid) return false; - if (!link && (st.mode & 0o022) !== 0 && (st.mode & STICKY) === 0) return false; + const privateGroup = st.uid === uid && st.gid === gid; + const writableByOthers = (st.mode & 0o002) !== 0 || ((st.mode & 0o020) !== 0 && !privateGroup); + if (!link && writableByOthers && (st.mode & STICKY) === 0) return false; if (dir === '/' || dir === '.') return true; } } @@ -325,7 +340,9 @@ function prepareRoot(spec, opts = {}) { const p = isWindows(platform) ? path.win32 : path.posix; const top = p.dirname(spec.dir); try { - fs.mkdirSync(p.dirname(top), { recursive: true }); + // 0700 so that, under umask 002, we do not create a group-writable + // ~/.cache ourselves. + fs.mkdirSync(p.dirname(top), { recursive: true, mode: 0o700 }); mkdirPrivate(top); mkdirPrivate(spec.dir); if (!rootTrusted(spec, opts)) return false; @@ -473,6 +490,11 @@ function readOwnerRaw(lockDir) { // only one waiter gets the stale lock) and delete it only if what moved is // still the owner that was judged stale. If a fresh lock was moved by // mistake, try to put it back; the caller then simply retries. +// +// A residual interleaving remains with three or more waiters (one restores +// a lock another has already replaced). Directory locks cannot close it +// completely, and it needs a stale lock plus three concurrent first runs; +// the worst case is two builds of the same venv racing, which is accepted. function breakStaleLock(lockDir) { const judged = readOwnerRaw(lockDir); const grave = `${lockDir}.stale.${process.pid}.${crypto.randomBytes(4).toString('hex')}`; @@ -549,7 +571,7 @@ function pruneSiblings(root, keep, now = Date.now()) { } function buildVenv({ python, key, spec, version, opts = {} }) { - const stderr = opts.stderr || process.stderr; + const stderr = opts.stderr || fdStderr; const spawnSync = opts.spawnSync || childProcess.spawnSync; const prepare = opts.prepareRoot || ((spec) => prepareRoot(spec, opts)); const rootSpec = cacheRootSpecs(opts).find(prepare); @@ -676,7 +698,7 @@ function resolveRunner(argv, opts = {}) { function run(argv = process.argv.slice(2), opts = {}) { const env = opts.env || process.env; - const stderr = opts.stderr || process.stderr; + const stderr = opts.stderr || fdStderr; let runner; try { @@ -715,8 +737,22 @@ function run(argv = process.argv.slice(2), opts = {}) { // is still cleaning up after the first. On Windows a console Ctrl-C // reaches every attached process, and child.kill() would terminate the // child outright rather than interrupt it. - const terminal = Boolean(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY); - const interrupt = (sig) => (isWindows(process.platform) || terminal ? () => {} : forwardIfMissed(sig)); + // isatty(), not process.stdin.isTTY: see fdStderr for why. + const terminal = [0, 1, 2].some((fd) => tty.isatty(fd)); + // A second Ctrl-C within a few seconds means "stop now": pass that one on + // even under a terminal, in case the child is ignoring or slow to act on + // the first. + let lastInterrupt = 0; + const onTerminalInterrupt = (sig) => () => { + const now = Date.now(); + if (now - lastInterrupt <= DOUBLE_INTERRUPT_MS && childRunning()) child.kill(sig); + lastInterrupt = now; + }; + const interrupt = (sig) => { + if (isWindows(process.platform)) return () => {}; + if (terminal) return sig === 'SIGINT' ? onTerminalInterrupt(sig) : () => {}; + return forwardIfMissed(sig); + }; const handlers = { SIGINT: interrupt('SIGINT'), SIGTERM: forward('SIGTERM') }; if (!isWindows(process.platform)) { handlers.SIGQUIT = interrupt('SIGQUIT'); @@ -768,6 +804,7 @@ module.exports = { parseProbeOutput, probePython, pathDirs, + prepareRoot, pypiSpec, pruneSiblings, pythonCandidates, diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js index 3e910aa..8c5b937 100644 --- a/npm/test/launcher.test.js +++ b/npm/test/launcher.test.js @@ -301,7 +301,7 @@ describe('cache directory', () => { }); describe('cache root ownership (POSIX)', () => { - const dirStat = (uid, mode, isDir = true) => ({ uid, mode: (isDir ? 0o040000 : 0o120000) | mode, isDirectory: () => isDir, isSymbolicLink: () => !isDir }); + const dirStat = (uid, mode, isDir = true, gid = uid) => ({ uid, gid, mode: (isDir ? 0o040000 : 0o120000) | mode, isDirectory: () => isDir, isSymbolicLink: () => !isDir }); const shared = { dir: '/tmp/deepgram-cli-1000/venvs', shared: true }; const home = { dir: '/home/u/.cache/deepgram-cli/venvs', shared: false }; // Sane ancestors; individual tests override entries. @@ -314,7 +314,7 @@ describe('cache root ownership (POSIX)', () => { }; const check = (spec, stats, realpath = (d) => d) => { const all = { ...ancestors, ...stats }; - return launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, realpath, lstat: (d) => { + return launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, gid: 1000, realpath, lstat: (d) => { if (!(d in all)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); return all[d]; } }); @@ -351,8 +351,8 @@ describe('cache root ownership (POSIX)', () => { assert.equal(check(home, ok), true); // HOME=/tmp style: ~/.cache made world-writable by someone else. assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(2000, 0o777) }), false); - // Ours but group-writable and not sticky: still swappable by the group. - assert.equal(check(home, { ...ok, '/home/u': dirStat(1000, 0o775) }), false); + // Ours but group-writable by a shared group, not sticky: swappable by the group. + assert.equal(check(home, { ...ok, '/home/u': dirStat(1000, 0o775, true, 50) }), false); // World-writable but sticky and root-owned, like /tmp: fine. assert.equal(check(home, { ...ok, '/home': dirStat(0, 0o1777) }), true); // Owned by a third user, even read-only: rejected. @@ -389,6 +389,32 @@ describe('cache root ownership (POSIX)', () => { assert.equal(launcher.rootTrusted(link), false); }); + test('our own group-writable ~/.cache in our private group is accepted (umask 002)', () => { + const base = '/home/u/.cache/deepgram-cli'; + const ok = { [base]: dirStat(1000, 0o700), [`${base}/venvs`]: dirStat(1000, 0o700) }; + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(1000, 0o775) }), true); + assert.equal(check(home, { ...ok, '/home/u': dirStat(1000, 0o775) }), true); + // Group-writable with a shared group (staff, users): rejected. + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(1000, 0o775, true, 50) }), false); + // Writable by everyone: rejected even when ours. + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(1000, 0o777) }), false); + // Group-writable, our gid, but owned by someone else: rejected. + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(2000, 0o775, true, 1000) }), false); + }); + + test('prepareRoot creates missing parents 0700 even under umask 002', posixOnly, () => { + const home = tmpdir(); + const spec = { dir: path.join(home, '.cache', 'deepgram-cli', 'venvs'), shared: false }; + const old = process.umask(0o002); + try { + assert.equal(launcher.prepareRoot(spec), true); + } finally { + process.umask(old); + } + assert.equal(fs.statSync(path.join(home, '.cache')).mode & 0o777, 0o700); + assert.equal(fs.statSync(spec.dir).mode & 0o777, 0o700); + }); + test('Windows skips the ownership check', () => { assert.equal(launcher.rootTrusted({ dir: 'C:\\x\\venvs', shared: true }, { platform: 'win32' }), true); }); @@ -703,6 +729,50 @@ describe('bin/dg.js end to end', posixOnly, () => { assert.ok(Date.now() - sentAt >= 900, 'forwarded only after the grace period'); }); + // Regression: reading process.stdin.isTTY made Node set O_NONBLOCK on a + // piped fd 0, which deepctl inherited; a reader that had to wait for + // input (an MCP host, `ffmpeg | dg listen`) then failed. + const python3 = ['/usr/bin/python3', '/usr/local/bin/python3', '/opt/homebrew/bin/python3'].find((f) => fs.existsSync(f)); + test('fds 0-2 stay blocking for deepctl, and a delayed stdin writer works', { skip: !python3 && 'needs python3' }, async () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + const checker = [ + `#!${python3}`, + 'import fcntl, os, sys', + 'flags = [bool(fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_NONBLOCK) for fd in (0, 1, 2)]', + 'data = sys.stdin.read()', + 'print("nonblock=%s read=%r" % (flags, data))', + '', + ].join('\n'); + // A fake python3 so the venv strategy runs (and writes its setup line + // to stderr) without touching PyPI: `-m venv` lays down the checker as + // deepctl, everything else succeeds. + fakeExe(bin, 'python3', [ + '#!/bin/sh', + 'case "$1 $2" in', + ' "-c "*) echo "(3, 12)"; exit 0;;', + ` "-m venv") mkdir -p "$3/bin" && cp "${path.join(home, 'checker')}" "$3/bin/deepctl" && printf '#!/bin/sh\\nexit 0\\n' > "$3/bin/python" && chmod +x "$3/bin/deepctl" "$3/bin/python"; exit 0;;`, + 'esac', + 'exit 0', + '', + ].join('\n')); + fs.writeFileSync(path.join(home, 'checker'), checker); + const { spawn } = require('node:child_process'); + const child = spawn(process.execPath, [BIN], { + env: { PATH: `${bin}:/usr/bin:/bin`, HOME: home, XDG_CACHE_HOME: path.join(home, 'c'), DEEPCTL_NPM_RUNNER: 'venv' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (err += d)); + setTimeout(() => child.stdin.end('hello\n'), 1000); + const code = await new Promise((r) => child.on('exit', (c) => r(c))); + assert.equal(code, 0, err); + assert.match(err, /Setting up Deepgram CLI/); + assert.equal(out.trim(), "nonblock=[False, False, False] read='hello\\n'"); + }); + test('re-raises the signal the child died from', () => { const home = tmpdir(); const bin = path.join(home, 'bin'); From 05e7bb12b380d857f0f2f7d066cff846911cff23 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 14:53:09 -0400 Subject: [PATCH 18/21] ci(test): check that the launcher leaves deepctl's stdin blocking A stand-in deepctl checks O_NONBLOCK on fds 0-2 and reads input that arrives a second late, on Linux and macOS. --- .github/workflows/test.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2319576..3f9d032 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,6 +140,26 @@ jobs: - 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 From e32ac0579bbe01a57a3e57e09195febbb2c73d55 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 15:18:36 -0400 Subject: [PATCH 19/21] fix(npm): trust group-write only for a user-private group, and make stderr best effort - A group-writable ancestor is trusted only when it is ours and its gid equals our uid (the user-private-group convention). On a shared primary group such as SUSE's users or macOS's staff, another member could swap the cache during the first build. - Drop forwarding a second terminal Ctrl-C: the terminal already delivers every press to deepctl, so it arrived twice and broke deepctl's own second-Ctrl-C handling. - Launcher messages complete partial writes, retry EAGAIN briefly and ignore every other error, so a full or closed stderr no longer aborts the first-run setup. --- npm/lib/launcher.js | 52 +++++++++++++++++++++-------------- npm/test/launcher.test.js | 57 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/npm/lib/launcher.js b/npm/lib/launcher.js index 58278a1..db7623c 100644 --- a/npm/lib/launcher.js +++ b/npm/lib/launcher.js @@ -21,7 +21,8 @@ const LOCK_MAX_MS = 60 * 60 * 1000; const LOCK_POLL_MS = 250; const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; const SIGNAL_GRACE_MS = 1000; -const DOUBLE_INTERRUPT_MS = 3000; +const STDERR_EAGAIN_RETRIES = 100; +const STDERR_EAGAIN_SLEEP_MS = 10; const PROBE_TIMEOUT_MS = 10 * 1000; const RUNNERS = ['uvx', 'venv', 'path']; @@ -34,7 +35,27 @@ const PROBE_CODE = 'import sys;print(tuple(sys.version_info[:2]));import venv,en // O_NONBLOCK on it. deepctl inherits the same open file, and a non-blocking // stdin breaks `ffmpeg ... | dg listen` and MCP hosts that send their first // message a moment after starting `dg mcp`. -const fdStderr = { write: (text) => fs.writeSync(2, text) }; +// +// Writing is best effort: fd 2 may be a full non-blocking pipe (EAGAIN) or +// one whose reader has gone (EPIPE), and a progress line must never abort +// a first-run setup or keep deepctl from running. +function writeStderr(text, opts = {}) { + const writeSync = opts.writeSync || fs.writeSync; + const sleep = opts.sleep || sleepSync; + const buf = Buffer.from(String(text)); + let offset = 0; + let retries = 0; + while (offset < buf.length) { + try { + offset += writeSync(2, buf, offset, buf.length - offset); + } catch (err) { + if (err.code !== 'EAGAIN' || retries++ >= STDERR_EAGAIN_RETRIES) return; + sleep(STDERR_EAGAIN_SLEEP_MS); + } + } +} + +const fdStderr = { write: (text) => writeStderr(text) }; class LauncherError extends Error { constructor(message, hint = '') { @@ -305,9 +326,12 @@ function rootTrusted(spec, opts = {}) { return ancestorsSafe(above, uid, gid, lstat, true) && ancestorsSafe(real, uid, gid, lstat, false); } -// Group-writable is fine for a directory we own whose group is our own -// primary group: the user-private-group convention (Ubuntu, Fedora; umask -// 002) makes ~/.cache 0775 with a group that only we are in. +// Group-writable is trusted only for a directory we own whose group id +// equals our uid: the user-private-group convention (Ubuntu, Fedora; umask +// 002), where ~/.cache is 0775 with a group named after us. A shared +// primary group (SUSE and Debian USERGROUPS=no use `users`, macOS uses +// `staff`) has other members who could swap the cache during the first +// build, so anything else group-writable fails closed. function ancestorsSafe(start, uid, gid, lstat, allowLinks) { for (let dir = start; ; dir = path.posix.dirname(dir)) { let st; @@ -319,7 +343,7 @@ function ancestorsSafe(start, uid, gid, lstat, allowLinks) { 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; + const privateGroup = st.uid === uid && st.gid === gid && gid === uid; const writableByOthers = (st.mode & 0o002) !== 0 || ((st.mode & 0o020) !== 0 && !privateGroup); if (!link && writableByOthers && (st.mode & STICKY) === 0) return false; if (dir === '/' || dir === '.') return true; @@ -739,20 +763,7 @@ function run(argv = process.argv.slice(2), opts = {}) { // child outright rather than interrupt it. // isatty(), not process.stdin.isTTY: see fdStderr for why. const terminal = [0, 1, 2].some((fd) => tty.isatty(fd)); - // A second Ctrl-C within a few seconds means "stop now": pass that one on - // even under a terminal, in case the child is ignoring or slow to act on - // the first. - let lastInterrupt = 0; - const onTerminalInterrupt = (sig) => () => { - const now = Date.now(); - if (now - lastInterrupt <= DOUBLE_INTERRUPT_MS && childRunning()) child.kill(sig); - lastInterrupt = now; - }; - const interrupt = (sig) => { - if (isWindows(process.platform)) return () => {}; - if (terminal) return sig === 'SIGINT' ? onTerminalInterrupt(sig) : () => {}; - return forwardIfMissed(sig); - }; + const interrupt = (sig) => (isWindows(process.platform) || terminal ? () => {} : forwardIfMissed(sig)); const handlers = { SIGINT: interrupt('SIGINT'), SIGTERM: forward('SIGTERM') }; if (!isWindows(process.platform)) { handlers.SIGQUIT = interrupt('SIGQUIT'); @@ -804,6 +815,7 @@ module.exports = { parseProbeOutput, probePython, pathDirs, + writeStderr, prepareRoot, pypiSpec, pruneSiblings, diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js index 8c5b937..124b320 100644 --- a/npm/test/launcher.test.js +++ b/npm/test/launcher.test.js @@ -312,9 +312,9 @@ describe('cache root ownership (POSIX)', () => { '/home/u': dirStat(1000, 0o755), '/home/u/.cache': dirStat(1000, 0o700), }; - const check = (spec, stats, realpath = (d) => d) => { + const check = (spec, stats, realpath = (d) => d, gid = 1000) => { const all = { ...ancestors, ...stats }; - return launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, gid: 1000, realpath, lstat: (d) => { + return launcher.rootTrusted(spec, { platform: 'linux', uid: 1000, gid, realpath, lstat: (d) => { if (!(d in all)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); return all[d]; } }); @@ -402,6 +402,16 @@ describe('cache root ownership (POSIX)', () => { assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(2000, 0o775, true, 1000) }), false); }); + test('a group-writable ~/.cache in a shared primary group is rejected even though it is ours', () => { + // SUSE / Debian USERGROUPS=no: primary group `users` (100), shared with + // every other user, so gid matches ours but is not a private group. + const base = '/home/u/.cache/deepgram-cli'; + const ok = { [base]: dirStat(1000, 0o700, true, 100), [`${base}/venvs`]: dirStat(1000, 0o700, true, 100) }; + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(1000, 0o775, true, 100) }, (d) => d, 100), false); + // The same directory without group write is fine. + assert.equal(check(home, { ...ok, '/home/u/.cache': dirStat(1000, 0o755, true, 100) }, (d) => d, 100), true); + }); + test('prepareRoot creates missing parents 0700 even under umask 002', posixOnly, () => { const home = tmpdir(); const spec = { dir: path.join(home, '.cache', 'deepgram-cli', 'venvs'), shared: false }; @@ -574,6 +584,49 @@ describe('lock', () => { }); }); +describe('launcher messages on stderr', () => { + const err = (code) => Object.assign(new Error(code), { code }); + + test('partial writes are completed', () => { + const chunks = []; + const writeSync = (fd, buf, offset, length) => { + const n = Math.min(3, length); + chunks.push(buf.subarray(offset, offset + n).toString()); + return n; + }; + launcher.writeStderr('hello world\n', { writeSync }); + assert.equal(chunks.join(''), 'hello world\n'); + }); + + test('EAGAIN is retried after a short sleep', () => { + let calls = 0; + let sleeps = 0; + const writeSync = (fd, buf, offset, length) => { + if (calls++ < 3) throw err('EAGAIN'); + return length; + }; + launcher.writeStderr('x', { writeSync, sleep: () => sleeps++ }); + assert.equal(calls, 4); + assert.equal(sleeps, 3); + }); + + test('a stderr that stays full is given up on, not thrown', () => { + let calls = 0; + const writeSync = () => { + calls++; + throw err('EAGAIN'); + }; + assert.doesNotThrow(() => launcher.writeStderr('x', { writeSync, sleep: () => {} })); + assert.equal(calls, 101); + }); + + test('EPIPE and other errors are swallowed', () => { + for (const code of ['EPIPE', 'EBADF', 'EIO']) { + assert.doesNotThrow(() => launcher.writeStderr('x', { writeSync: () => { throw err(code); } })); + } + }); +}); + describe('child environment', () => { test('marks the launcher and adds nothing else', () => { const env = launcher.childEnv({ HOME: '/h' }, 'uvx'); From 253685ef4d99741ea15ac3baf1e2f7c62a27886b Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 15:45:19 -0400 Subject: [PATCH 20/21] test(npm): shadow every probed python name in the stdin test GitHub's ubuntu runners have a real /usr/bin/python3.12, which the launcher probes before python3, so the test built a real venv and ran the real deepctl instead of the checker. --- npm/test/launcher.test.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js index 124b320..39915c8 100644 --- a/npm/test/launcher.test.js +++ b/npm/test/launcher.test.js @@ -797,10 +797,12 @@ describe('bin/dg.js end to end', posixOnly, () => { 'print("nonblock=%s read=%r" % (flags, data))', '', ].join('\n'); - // A fake python3 so the venv strategy runs (and writes its setup line + // A fake python so the venv strategy runs (and writes its setup line // to stderr) without touching PyPI: `-m venv` lays down the checker as - // deepctl, everything else succeeds. - fakeExe(bin, 'python3', [ + // deepctl, everything else succeeds. It's installed under every name + // the launcher probes, or a real python3.12 later on PATH (as on + // GitHub's ubuntu runners) wins and builds a real venv. + const fakePython = [ '#!/bin/sh', 'case "$1 $2" in', ' "-c "*) echo "(3, 12)"; exit 0;;', @@ -808,7 +810,10 @@ describe('bin/dg.js end to end', posixOnly, () => { 'esac', 'exit 0', '', - ].join('\n')); + ].join('\n'); + for (const name of ['python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3']) { + fakeExe(bin, name, fakePython); + } fs.writeFileSync(path.join(home, 'checker'), checker); const { spawn } = require('node:child_process'); const child = spawn(process.execPath, [BIN], { From 746e61b170a30f0763877d4065c630bf29ce78ab Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Wed, 21 Oct 2026 15:56:50 -0400 Subject: [PATCH 21/21] fix(npm): name the signal when deepctl or a setup step crashes A crash otherwise showed up only as a bare exit status (139 on a macOS CI run), with nothing saying which process died. --- npm/lib/launcher.js | 9 ++++++++- npm/test/launcher.test.js | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/npm/lib/launcher.js b/npm/lib/launcher.js index db7623c..0293593 100644 --- a/npm/lib/launcher.js +++ b/npm/lib/launcher.js @@ -553,11 +553,16 @@ function releaseLock(lockDir, owner) { // --- Venv bootstrap ------------------------------------------------------- +const CRASH_SIGNALS = new Set(['SIGSEGV', 'SIGBUS', 'SIGILL', 'SIGFPE', 'SIGABRT']); + function runStep(command, args, what, hint, spawnSync) { // stdout goes to stderr: `dg -o json` and `dg mcp` own stdout. const result = spawnSync(command, args, { stdio: ['ignore', 2, 2], windowsHide: true }); if (result.error) throw new LauncherError(`${what} failed: ${result.error.message}.`, hint); - if (result.status !== 0) throw new LauncherError(`${what} failed (exit ${result.status}).`, hint); + if (result.status !== 0) { + const how = result.signal ? `killed by ${result.signal}` : `exit ${result.status}`; + throw new LauncherError(`${what} failed (${how}).`, hint); + } } const VENV_HINT = 'On Debian or Ubuntu, install python3-venv (sudo apt install python3-venv), or install uv instead.'; @@ -780,6 +785,8 @@ function run(argv = process.argv.slice(2), opts = {}) { for (const timer of timers) clearTimeout(timer); for (const [sig, fn] of Object.entries(handlers)) process.removeListener(sig, fn); const action = exitAction(code, signal); + // A crash otherwise surfaces only as a bare exit status (139 and so on). + if (CRASH_SIGNALS.has(signal)) stderr.write(`dg: ${runner.command} crashed (${signal}).\n`); if (action.signal) { // Die the same way the child did, so shells and supervisors see the // signal rather than a plain exit code. diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js index 39915c8..b04c37a 100644 --- a/npm/test/launcher.test.js +++ b/npm/test/launcher.test.js @@ -838,5 +838,25 @@ describe('bin/dg.js end to end', posixOnly, () => { const r = runBin({ PATH: bin, HOME: home, XDG_CACHE_HOME: path.join(home, 'c') }); assert.equal(r.status, null); assert.equal(r.signal, 'SIGTERM'); + assert.doesNotMatch(r.stderr, /crashed/); + }); + + test('names the signal when the child crashes', { skip: process.platform === 'win32' && 'POSIX signals' }, () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + fakeExe(bin, 'uv', '#!/bin/sh\nkill -SEGV $$\n'); + const r = runBin({ PATH: bin, HOME: home, XDG_CACHE_HOME: path.join(home, 'c') }); + assert.equal(r.signal, 'SIGSEGV'); + assert.match(r.stderr, /dg: .*uv crashed \(SIGSEGV\)\./); + }); + + test('a venv build step killed by a signal says so', { skip: process.platform === 'win32' && 'POSIX signals' }, () => { + const home = tmpdir(); + const bin = path.join(home, 'bin'); + const fakePython = '#!/bin/sh\ncase "$1 $2" in\n "-c "*) echo "(3, 12)"; exit 0;;\nesac\nkill -KILL $$\n'; + for (const name of ['python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3']) fakeExe(bin, name, fakePython); + const r = runBin({ PATH: `${bin}:/usr/bin:/bin`, HOME: home, XDG_CACHE_HOME: path.join(home, 'c'), DEEPCTL_NPM_RUNNER: 'venv' }); + assert.equal(r.status, 1); + assert.match(r.stderr, /failed \(killed by SIGKILL\)/); }); });