diff --git a/.github/release-please-config.json b/.github/release-please-config.json index fd5be2c1..553d9c3f 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 4b36ee90..40068c04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -345,3 +345,165 @@ jobs: --head "${BRANCH}" \ --title "Bump deepctl to ${VERSION}" \ --body-file "${BODY_FILE}" + + publish-npm: + name: Publish @deepgram/cli to npm + needs: [release-please, verify-published] + # The npm package is a launcher that runs `deepctl==` + # from PyPI, so it must never go out before that exact version installs. + # verify-published is that gate. Root tags only, same as mark-latest. + # + # Recovery: if this job fails (npm outage, trusted publisher not set up + # yet), fix the cause and use "Re-run failed jobs" on this workflow run. + # Job outputs from release-please are preserved across re-runs, and every + # step here is safe to repeat: an already-published version is skipped. + if: | + needs.release-please.outputs.release_created == 'true' && + startsWith(needs.release-please.outputs.tag_name, 'v') + runs-on: ubuntu-latest + # Trusted publishing: npm exchanges this job's OIDC token for a one-time + # publish credential, so no long-lived npm token exists to leak. The + # trusted publisher on npmjs.com is bound to this workflow file and this + # environment; renaming either breaks publishing until it is updated. + environment: npm-publish + permissions: + id-token: write + contents: read + timeout-minutes: 30 + env: + TAG_NAME: ${{ needs.release-please.outputs.tag_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + + # No registry-url: it writes an .npmrc expecting NODE_AUTH_TOKEN, and an + # empty token there makes npm skip the OIDC exchange. + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + # Trusted publishing needs npm 11.5.1+; Node 22 bundles npm 10. Pinned + # exactly so a new npm release cannot change how a release publishes. + - name: Install an npm that supports trusted publishing + run: npm install -g npm@11.21.0 + + # release-please bumps npm/package.json through extra-files. If that + # ever drifts, publishing would ship a launcher pinned to a deepctl + # version that verify-published never checked. + - name: Check the package version matches the tag + id: version + run: | + set -euo pipefail + VERSION="${TAG_NAME#v}" + PKG_VERSION="$(node -p 'require("./npm/package.json").version')" + if [ "${PKG_VERSION}" != "${VERSION}" ]; then + echo "::error::npm/package.json is ${PKG_VERSION} but the tag is ${TAG_NAME}" + exit 1 + fi + if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::${VERSION} is not a plain X.Y.Z version" + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + # npm refuses to republish a version, so a re-run after a later step + # failed would otherwise fail here forever. + - name: Check whether this version is already on npm + id: existing + run: | + set -euo pipefail + if [ "$(npm view "@deepgram/cli@${VERSION}" version 2>/dev/null || true)" = "${VERSION}" ]; then + echo "@deepgram/cli@${VERSION} is already published; skipping publish" + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "published=false" >> "$GITHUB_OUTPUT" + fi + env: + VERSION: ${{ steps.version.outputs.version }} + + # `npm publish` moves `latest` by default. A patch for an older line + # (0.4.x after 0.5.0 shipped) must not drag every new install back, so + # it goes out under a maintenance tag instead. Fail closed: only "the + # package has no latest yet" (E404) may fall through to latest. + - name: Choose the dist-tag + id: tag + run: | + set -euo pipefail + ERR="$(mktemp)" + if LATEST="$(npm view @deepgram/cli dist-tags.latest 2>"${ERR}")"; then + : + elif grep -q E404 "${ERR}"; then + LATEST="" + else + cat "${ERR}" + echo "::error::could not read the current latest dist-tag; refusing to guess" + exit 1 + fi + TAG=latest + if [ -n "${LATEST}" ]; then + ORDER="$(node -e ' + const parse = (v) => { + const [core, pre] = v.split("+")[0].split("-"); + return { n: core.split(".").map(Number), pre: pre !== undefined }; + }; + const a = parse(process.argv[1]); + const b = parse(process.argv[2]); + let c = 0; + for (let i = 0; i < 3 && c === 0; i++) c = Math.sign(a.n[i] - b.n[i]); + if (c === 0 && a.pre !== b.pre) c = a.pre ? -1 : 1; + console.log(c < 0 ? "lower" : "not-lower"); + ' "${VERSION}" "${LATEST}")" + if [ "${ORDER}" = "lower" ]; then + TAG="v$(printf '%s' "${VERSION}" | cut -d. -f1-2)-maint" + fi + fi + echo "latest is ${LATEST:-unset}; publishing ${VERSION} with dist-tag ${TAG}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + env: + VERSION: ${{ steps.version.outputs.version }} + + - name: Publish + if: steps.existing.outputs.published != 'true' + working-directory: npm + run: npm publish --access public --provenance --tag "${DIST_TAG}" + env: + DIST_TAG: ${{ steps.tag.outputs.tag }} + + - name: Wait until the registry serves the version + run: | + set -euo pipefail + for i in $(seq 1 40); do + if [ "$(npm view "@deepgram/cli@${VERSION}" version 2>/dev/null || true)" = "${VERSION}" ]; then + echo "@deepgram/cli@${VERSION} is visible" + exit 0 + fi + echo "Waiting for @deepgram/cli@${VERSION} (${i}/40)..." + sleep 15 + done + echo "::error::@deepgram/cli@${VERSION} was not visible on the registry after 10 minutes" + exit 1 + env: + VERSION: ${{ steps.version.outputs.version }} + + # The end-to-end check a user would do: npx resolves the published + # tarball, the launcher hands off to uv, uv installs deepctl from PyPI. + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + + - name: Run the published package + run: | + set -euo pipefail + for i in $(seq 1 10); do + if OUT="$(npx -y "@deepgram/cli@${VERSION}" --version 2>/dev/null)" && printf '%s' "${OUT}" | grep -qF "${VERSION}"; then + echo "${OUT}" + exit 0 + fi + echo "npx @deepgram/cli@${VERSION} not runnable yet (${i}/10)..." + sleep 30 + done + echo "Final attempt, with output:" + npx -y "@deepgram/cli@${VERSION}" --version || true + echo "::error::npx @deepgram/cli@${VERSION} --version did not report ${VERSION}" + exit 1 + env: + VERSION: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad94d847..3f9d032b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -112,6 +112,87 @@ jobs: - name: Verify packages run: make verify-packages + launcher: + name: npm launcher / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + # A glob, not the directory: Node 22+ treats --test arguments as file + # patterns, and Node expands it itself where the shell does not. + - name: Unit tests + run: node --test "npm/test/*.test.js" + + # npm/LICENSE is what npmjs.com and the tarball show; it must not drift + # from the repository's. + - name: npm/LICENSE matches LICENSE + run: cmp LICENSE npm/LICENSE + + # Regression: the launcher once made the stdin it hands to deepctl + # non-blocking, so a reader that had to wait (an MCP host, `ffmpeg | + # dg listen`) failed. A stand-in deepctl checks fds 0-2 and reads input + # that arrives a second late. + - name: stdin stays blocking for deepctl + if: runner.os != 'Windows' + run: | + set -euo pipefail + FAKE="$(mktemp -d)" + cat > "${FAKE}/deepctl" <<'PY' + #!/usr/bin/env python3 + import fcntl, os, sys + flags = [bool(fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_NONBLOCK) for fd in (0, 1, 2)] + print("nonblock=%s read=%r" % (flags, sys.stdin.read())) + PY + chmod +x "${FAKE}/deepctl" + OUT="$( (sleep 1; echo hello) | PATH="${FAKE}:${PATH}" DEEPCTL_NPM_RUNNER=path node npm/bin/dg.js)" + echo "${OUT}" + [ "${OUT}" = "nonblock=[False, False, False] read='hello\n'" ] + + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + + # npm/package.json is already bumped on a release PR, before PyPI has + # that version, so run the launcher against the newest deepctl that + # PyPI actually serves. This checks the launcher, not the release. + - name: Smoke test against the latest deepctl on PyPI + run: | + set -euo pipefail + LATEST="$(node -e 'fetch("https://pypi.org/pypi/deepctl/json").then(r => r.json()).then(d => console.log(d.info.version))')" + echo "deepctl on PyPI: ${LATEST}" + echo "latest=${LATEST}" >> "$GITHUB_ENV" + OUT="$(DEEPCTL_NPM_PYPI_SPEC="deepctl==${LATEST}" node npm/bin/dg.js --version)" + echo "${OUT}" + printf '%s' "${OUT}" | grep -qF "${LATEST}" + + # The fallback for machines without uv: find Python, build a private + # venv, run from it. Windows exercises Scripts\deepctl.exe and the py + # launcher here. + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Smoke test the venv strategy + run: | + set -euo pipefail + # tr: Python on Windows ends lines with CRLF. + OUT="$(DEEPCTL_NPM_RUNNER=venv DEEPCTL_NPM_PYPI_SPEC="deepctl==${latest}" node npm/bin/dg.js --version | tr -d '\r')" + printf 'stdout: %s\n' "${OUT}" + # Exactly the version line: setup chatter must stay on stderr. + [ "${OUT}" = "deepctl, version ${latest}" ] + # Single rollup so branch protection needs exactly one required check. # Requiring the matrix contexts directly means editing repo settings every # time a Python version or OS is added or dropped, and a required context @@ -123,7 +204,7 @@ jobs: # cancelled and skipped prerequisites are failures here too. all-checks: name: All checks - needs: [test, lint, floors, build-test] + needs: [test, lint, floors, build-test, launcher] if: always() runs-on: ubuntu-latest steps: @@ -133,6 +214,7 @@ jobs: echo "lint: ${{ needs.lint.result }}" echo "floors: ${{ needs.floors.result }}" echo "build-test: ${{ needs.build-test.result }}" + echo "launcher: ${{ needs.launcher.result }}" - name: Fail unless every prerequisite succeeded if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} diff --git a/.gitignore b/.gitignore index 77c72295..388075d8 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/README.md b/README.md index 46a2f16d..74fb5153 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/conftest.py b/conftest.py index fc8e6cb2..89d1f5c3 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/npm/LICENSE b/npm/LICENSE new file mode 100644 index 00000000..880c2996 --- /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. diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 00000000..c01fcd37 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,74 @@ +# @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. 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`. + +## 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 00000000..761c717f --- /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 00000000..0293593a --- /dev/null +++ b/npm/lib/launcher.js @@ -0,0 +1,840 @@ +'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 tty = require('node:tty'); + +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; +const STDERR_EAGAIN_RETRIES = 100; +const STDERR_EAGAIN_SLEEP_MS = 10; +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'; + +// 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`. +// +// 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 = '') { + 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)) { + // 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; + } + 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 cacheRootSpecs(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. 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 currentUid(opts) { + if (opts.uid !== undefined && typeof opts.uid === 'number') return opts.uid; + 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). +// +// 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; + 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; + } + 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); +} + +// 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; + try { + st = lstat(dir); + } catch { + 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; + 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; + } +} + +function mkdirPrivate(dir) { + try { + 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 { + // 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; + fs.accessSync(spec.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 trusted = opts.trustRoot || ((spec) => rootTrusted(spec, opts)); + const p = isWindows(opts.platform || process.platform) ? path.win32 : path.posix; + for (const spec of cacheRootSpecs(opts)) { + if (!trusted(spec)) continue; + const layout = venvLayout(p.join(spec.dir, key), opts.platform); + 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 { + 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) { + 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 (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 { + 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; + const writeFile = opts.writeFile || fs.writeFileSync; + for (;;) { + let created = false; + try { + fs.mkdirSync(lockDir); + 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 (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)) { + breakStaleLock(lockDir); + 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); + } +} + +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. +// +// 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')}`; + 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) { + 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 ------------------------------------------------------- + +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) { + 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.'; +const NETWORK_HINT = 'Check your network connection and that PyPI is reachable, then run dg again.'; + +// 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 { + entries = fs.readdirSync(root); + } catch { + 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 || fdStderr; + const spawnSync = opts.spawnSync || childProcess.spawnSync; + 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. + 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 (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']; + 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 ------------------------------------ + +// 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; + else out.DEEPCTL_LAUNCHER = launcherKind(pkgDir); + 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) { + touchMarker(cached.marker); + 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 || fdStderr; + + let runner; + try { + runner = resolveRunner(argv, opts); + } catch (err) { + // 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); + } + + const child = childProcess.spawn(runner.command, runner.args, { + stdio: 'inherit', + env: childEnv(env, runner.strategy), + }); + + // 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 forwardIfMissed = (sig) => () => { + const timer = setTimeout(() => { + timers.delete(timer); + if (childRunning()) child.kill(sig); + }, SIGNAL_GRACE_MS); + timers.add(timer); + }; + // + // 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. + // isatty(), not process.stdin.isTTY: see fdStderr for why. + const terminal = [0, 1, 2].some((fd) => tty.isatty(fd)); + 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'); + 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 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. + 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, + cacheRootSpecs, + cacheRoots, + breakStaleLock, + childEnv, + launcherKind, + exitAction, + exeNames, + findCachedVenv, + findPython, + findUv, + installHelp, + lockIsStale, + packageVersion, + parseProbeOutput, + probePython, + pathDirs, + writeStderr, + prepareRoot, + pypiSpec, + pruneSiblings, + pythonCandidates, + releaseLock, + resolveRunner, + rootTrusted, + run, + venvKey, + venvLayout, + venvUsable, + versionAtLeast, + which, + whichAll, +}; diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 00000000..35f9c1c3 --- /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 00000000..b04c37a8 --- /dev/null +++ b/npm/test/launcher.test.js @@ -0,0 +1,862 @@ +'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; +} + +// 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', () => { + 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('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+$/); + }); +}); + +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 and -3, then python, with WindowsApps last', () => { + 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:\\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 = { + '/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, 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', '/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']); + 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, 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. + 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, gid = 1000) => { + const all = { ...ancestors, ...stats }; + 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]; + } }); + }; + + 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); + }); + + 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('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 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. + 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); + }); + + 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('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('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 }; + 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); + }); +}); + +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 }); + liveForeignLock(`${locked}.lock`); + launcher.pruneSiblings(root, '1.0.0'); + assert.equal(fs.existsSync(locked), true); + }); +}); + +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'); + liveForeignLock(lock); + let waits = 0; + assert.throws( + () => launcher.acquireLock(lock, { waitMs: 300, pollMs: 50, onWait: () => waits++ }), + /Timed out waiting/, + ); + assert.equal(waits, 1); + }); + + 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('a same-host lock with a live pid is kept for up to an hour', () => { + const lock = path.join(tmpdir(), 'v.lock'); + 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', () => { + 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('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'); + assert.deepEqual(env, { HOME: '/h', DEEPCTL_LAUNCHER: 'npm' }); + assert.equal(launcher.childEnv({}, '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', () => { + 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/'), + trustRoot: () => true, + }); + 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('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', () => { + 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 $*"\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 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'); + }); + + // 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 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. 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;;', + ` "-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'); + 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], { + 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'); + 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'); + 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\)/); + }); +}); 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 dd14fa5a..a5228d57 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 90a36289..e8dc9120 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 b9f8a692..c9343c83 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 f9217cfd..f43b4863 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( 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 8d153221..ed6ae711 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", @@ -33,6 +35,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 launched_via_npm(): + return False if "UV_INTERNAL__PARENT_INTERPRETER" in os.environ: return True exe_path = str(Path(sys.executable).resolve()) @@ -110,7 +116,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 700bc305..9717c0b2 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,38 @@ 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 + 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 + + 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", [ 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 873df081..c75e864d 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/installation.py @@ -7,6 +7,7 @@ from enum import Enum from pathlib import Path +from deepctl_core.launcher import get_launcher, launched_via_npm from pydantic import BaseModel @@ -20,6 +21,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 +107,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 +126,20 @@ 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 ( + "Installed via npm. Update with: " + "npm install -g @deepgram/cli@latest " + "(or run npx @deepgram/cli@latest)" + ) + if info.method == InstallMethod.UVX: return ( "You are running deepctl via uvx (one-shot execution). " @@ -261,8 +278,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 +289,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 e490cb3b..00517ada 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 889bab17..bbaec963 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,8 +44,20 @@ def _is_ci() -> bool: return any(os.environ.get(v) for v in _CI_ENV_VARS) +def upgrade_hint() -> str: + """Return the command a user should run to upgrade deepctl.""" + if launched_via_npm(): + return npm_upgrade_command() + 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 +214,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 89a1fa43..33384823 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 b98cf306..c932dfca 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,83 @@ 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 + + + @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.""" + + @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", {"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 + + 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 9de3b700..a4f9f0a0 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 8d3962c5..cc26c848 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,30 @@ 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 + + @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.""" @@ -208,6 +232,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 f96f4fc6..35e12f5a 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 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 00000000..58b31d3c --- /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 00000000..ae954b26 --- /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 cb8fb512..7b67a33f 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 7ad33fa6..deff9351 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"] diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 5b76e4dc..3d3b4265 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -23,10 +23,12 @@ 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` +Aliases: `dg`, `deepctl`, `deepgram` (the npm package installs only `dg` and `deepgram`) ---