From 5386b84efebc059d5d01e7360c8c76236347d504 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Mon, 12 Oct 2026 19:11:05 +0700 Subject: [PATCH] 0.1.3: install.sh installer, dsh --version, README install guide - install.sh: installs from npm into its own prefix and links dsh into a PATH directory (--version, --prefix, --bin-dir, --uninstall); idempotent; refuses to overwrite a foreign file; finds npm next to node for fnm/nvm setups; checks Node >= 22 first - dsh -v/--version - README: install, configure, installer options - tests on the packed tarball Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 1 + README.md | 42 ++++++++++++++++++++++++---- install.sh | 65 +++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/cli.ts | 7 +++++ test/install.test.mjs | 56 +++++++++++++++++++++++++++++++++++++ 7 files changed, 169 insertions(+), 8 deletions(-) create mode 100755 install.sh create mode 100644 test/install.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index a70d8e9..8843662 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,4 +4,5 @@ - Real-API check: `DEEPCODE_API_KEY=... node dist/cli.js --json "..."` (on the coderbuzz VM: `with-secrets deepseek -- ...`). - `vendor/deepcode-core` is upstream code: do not edit in place. Changes go in `vendor/patches/*.patch` and are applied by `scripts/sync-upstream.sh`. - The core finds templates at `/..`: the bundle must keep core code in `dist/chunks/` and templates in `dist/templates`, `dist/bundled`. +- `install.sh` (POSIX sh) is what users run via curl; test it with `npm test` (packed tarball, temp dirs). Keep it dependency-free. - Public types live in `src/types.ts` with no imports from the core. diff --git a/README.md b/README.md index d47e990..0b101da 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,47 @@ Structured-output wrapper around the [deepcode-cli](https://github.com/lessweb/d The engine (`@vegamo/deepcode-core`, not on npm) is vendored in `vendor/deepcode-core`; see its `UPSTREAM.md`. -## Install / build +## Install + +Needs **Node.js >= 22** (`dsh` runs `node`, so it must be on `PATH` wherever the orchestrator spawns it). + +```bash +curl -fsSL https://raw.githubusercontent.com/coderbuzz/dsh/main/install.sh | sh +dsh --version +``` + +The installer puts the package in its own prefix (`~/.local/share/dsh`, so a Node version switch does not remove it) +and links `dsh` into `~/.local/bin`. If that directory is not on your `PATH` it prints the line to add. It is +idempotent: running it again upgrades. Options (flags after `sh -s --`, or env vars): + +| Flag | Env | Default | +|---|---|---| +| `--version ` | `DSH_VERSION` | `latest` (pin one for reproducible installs) | +| `--prefix ` | `DSH_PREFIX` | `~/.local/share/dsh` | +| `--bin-dir ` | `DSH_BIN_DIR` | `~/.local/bin` (use `/usr/local/bin` for all users, with sudo) | +| `--uninstall` | | removes the prefix and the link (only a link that points into the prefix) | +| | `DSH_REGISTRY` | `https://registry.npmjs.org` | ```bash -npm install && npm run build # Node >= 22 -npm link # optional: puts `dsh` on PATH +curl -fsSL https://raw.githubusercontent.com/coderbuzz/dsh/main/install.sh | sh -s -- --version 0.1.2 --bin-dir /usr/local/bin ``` -API key: `DEEPCODE_API_KEY` env var, or `~/.deepcode/settings.json` (same config as deepcode-cli; +Other ways: `npm i -g @coderbuzz/dsh` (SDK users: `npm i @coderbuzz/dsh`), or from source: +`git clone https://github.com/coderbuzz/dsh && cd dsh && npm ci && npm run build && npm link`. +Releases are published from CI with npm provenance; the installer script is a plain POSIX `sh` file you can read first. + +## Configure + +API key: `DEEPCODE_API_KEY` env var, or `~/.deepcode/settings.json` (same file as deepcode-cli; also `DEEPCODE_BASE_URL`, `DEEPCODE_MODEL`, ...). Sessions are stored under `~/.deepcode/`. Upstream telemetry (a ping per prompt) is **off** in dsh; only `DEEPCODE_TELEMETRY_ENABLED=true` turns it on. +```bash +export DEEPCODE_API_KEY=sk-... +dsh models # lists models (falls back to a built-in list offline) +dsh -p "say hi in one word" --json # first run +``` + ## CLI ```bash @@ -121,7 +151,9 @@ Node OpenAI SDK passes through as a literal key and DeepSeek ignores; dsh patche `npm test` builds and runs an end-to-end test against a fake DeepSeek server (no key needed): a tool call, a resume, overrides, usage accounting, a stable message prefix, plan mode without bash, -timeout killing child processes, parallel runs in one project, and telemetry staying off. +timeout killing child processes, parallel runs in one project, telemetry staying off, the API key hidden from the agent's +shell, and `install.sh` (install, rerun, uninstall, never overwriting a foreign file) on the packed tarball. +The installer test needs network access for the dependencies (~20 s). ## Release diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3c0e9e9 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Installs dsh from npm into its own prefix and links it into a directory on PATH. +# curl -fsSL https://raw.githubusercontent.com/coderbuzz/dsh/main/install.sh | sh +# curl -fsSL .../install.sh | sh -s -- --version 0.1.2 --bin-dir /usr/local/bin +# Options (or env): --version V (DSH_VERSION, default latest) --prefix DIR (DSH_PREFIX, default ~/.local/share/dsh) +# --bin-dir DIR (DSH_BIN_DIR, default ~/.local/bin) --uninstall -h +# Env only: DSH_REGISTRY (default https://registry.npmjs.org), DSH_PACKAGE (tarball/dir instead of the registry; for tests). +set -eu + +version="${DSH_VERSION:-latest}" +prefix="${DSH_PREFIX:-$HOME/.local/share/dsh}" +bin_dir="${DSH_BIN_DIR:-$HOME/.local/bin}" +registry="${DSH_REGISTRY:-https://registry.npmjs.org}" +package="${DSH_PACKAGE:-@coderbuzz/dsh@$version}" +uninstall=0 + +die() { echo "install.sh: $*" >&2; exit 1; } +while [ $# -gt 0 ]; do + case "$1" in + --version) [ $# -ge 2 ] || die "--version needs a value"; version="$2"; package="${DSH_PACKAGE:-@coderbuzz/dsh@$2}"; shift 2 ;; + --prefix) [ $# -ge 2 ] || die "--prefix needs a value"; prefix="$2"; shift 2 ;; + --bin-dir) [ $# -ge 2 ] || die "--bin-dir needs a value"; bin_dir="$2"; shift 2 ;; + --uninstall) uninstall=1; shift ;; + -h|--help) sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac +done + +# npm sits next to node; on version-manager setups (fnm, nvm) it is not on PATH when the shell is non-interactive, +# so look beside the real node binary too. +node_bin="$(command -v node 2>/dev/null || true)" +[ -n "$node_bin" ] || die "Node.js >= 22 is required (https://nodejs.org)" +node_real="$(readlink -f "$node_bin" 2>/dev/null || echo "$node_bin")" +PATH="$PATH:$(dirname "$node_real")" +major="$(node -p 'process.versions.node.split(".")[0]')" +[ "$major" -ge 22 ] 2>/dev/null || die "Node.js >= 22 is required (found $(node -v))" +command -v npm >/dev/null 2>&1 || die "npm not found next to node ($node_real)" + +if [ "$uninstall" = 1 ]; then + npm rm -g --prefix "$prefix" @coderbuzz/dsh >/dev/null 2>&1 || true + # Remove the link only if it points into our prefix; never touch an unrelated `dsh`. + link="$bin_dir/dsh" + if [ -L "$link" ] && [ "$(readlink "$link")" = "$prefix/bin/dsh" ]; then rm -f "$link"; fi + rm -rf "$prefix" + echo "dsh removed (prefix $prefix, link $link)." + exit 0 +fi + +echo "Installing $package into $prefix ..." +npm install -g --prefix "$prefix" "$package" --registry "$registry" --no-audit --no-fund --loglevel=error \ + || die "npm install failed" + +mkdir -p "$bin_dir" +link="$bin_dir/dsh" +if [ -e "$link" ] && [ ! -L "$link" ]; then die "$link exists and is not a symlink; remove it or pick --bin-dir"; fi +ln -sfn "$prefix/bin/dsh" "$link" + +installed="$(node -p "require('$prefix/lib/node_modules/@coderbuzz/dsh/package.json').version" 2>/dev/null || echo '?')" +echo "Installed dsh $installed -> $link" +case ":$PATH:" in + *":$bin_dir:"*) ;; + *) echo "Note: $bin_dir is not on your PATH. Add this to your shell profile:" + echo " export PATH=\"$bin_dir:\$PATH\"" ;; +esac +echo "Set DEEPCODE_API_KEY (see README) and try: dsh models" diff --git a/package-lock.json b/package-lock.json index e1e0f72..e99b1eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coderbuzz/dsh", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@coderbuzz/dsh", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/package.json b/package.json index 1cf7d60..3c0b6cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coderbuzz/dsh", - "version": "0.1.2", + "version": "0.1.3", "description": "DeepSeek harness for orchestrators: structured JSON SDK and CLI on top of the deepcode-cli engine", "license": "MIT", "repository": { diff --git a/src/cli.ts b/src/cli.ts index 82929b0..bd69829 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { parseArgs } from "node:util"; +import pkg from "../package.json" with { type: "json" }; import { getAvailableModels, run } from "./index.js"; import type { Mode, ReasoningEffort } from "./types.js"; @@ -19,6 +20,7 @@ Options: --mode plan (read-only) | edit (writes in cwd/tmp) | yolo (default, everything) --disallowed-tools Remove tools, e.g. bash,WebSearch (bash scopes are self-declared: remove it for read-only) --timeout Stop the run (and its child processes) and report failed + -v, --version -h, --help API key: DEEPCODE_API_KEY env or ~/.deepcode/settings.json (same as deepcode-cli). @@ -48,8 +50,13 @@ async function main(): Promise { cwd: { type: "string", short: "C" }, refresh: { type: "boolean" }, help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, }, }); + if (values.version) { + process.stdout.write(`${pkg.version}\n`); + return 0; + } if (values.help) { process.stdout.write(USAGE + "\n"); return 0; diff --git a/test/install.test.mjs b/test/install.test.mjs new file mode 100644 index 0000000..4900429 --- /dev/null +++ b/test/install.test.mjs @@ -0,0 +1,56 @@ +// install.sh against the package exactly as npm would publish it (npm pack), into temp dirs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const run = promisify(execFile); +const script = path.resolve("install.sh"); +const version = JSON.parse(fs.readFileSync("package.json", "utf8")).version; + +test("install.sh: install, rerun, refuse to overwrite, uninstall", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-inst-")); + const packed = (await run("npm", ["pack", "--pack-destination", tmp, "--ignore-scripts", "--json"])).stdout; + const tarball = path.join(tmp, JSON.parse(packed)[0].filename); + const prefix = path.join(tmp, "prefix"); + const bin = path.join(tmp, "bin"); + const env = { ...process.env, DSH_PACKAGE: tarball, HOME: tmp }; + const install = (...args) => run("sh", [script, "--prefix", prefix, "--bin-dir", bin, ...args], { env }); + + const first = await install(); + assert.match(first.stdout, new RegExp(`Installed dsh ${version.replaceAll(".", "\\.")} `)); + assert.match(first.stdout, /not on your PATH/, "hints when bin dir is not on PATH"); + assert.equal(fs.readlinkSync(path.join(bin, "dsh")), path.join(prefix, "bin", "dsh")); + assert.equal((await run(path.join(bin, "dsh"), ["--version"])).stdout.trim(), version); + + await install(); // idempotent + assert.equal((await run(path.join(bin, "dsh"), ["--version"])).stdout.trim(), version); + + const onPath = await run("sh", [script, "--prefix", prefix, "--bin-dir", bin], { env: { ...env, PATH: `${bin}:${process.env.PATH}` } }); + assert.doesNotMatch(onPath.stdout, /not on your PATH/); + + // an unrelated `dsh` in another bin dir must survive uninstall + const other = path.join(tmp, "other"); + fs.mkdirSync(other); + fs.symlinkSync("/bin/true", path.join(other, "dsh")); + await run("sh", [script, "--uninstall", "--prefix", prefix, "--bin-dir", other], { env }); + assert.ok(fs.existsSync(path.join(other, "dsh")), "unrelated link kept"); + + // a regular file at the link location is never overwritten + const clash = path.join(tmp, "clash"); + fs.mkdirSync(clash); + fs.writeFileSync(path.join(clash, "dsh"), "mine"); + await assert.rejects(run("sh", [script, "--prefix", path.join(tmp, "p2"), "--bin-dir", clash], { env }), /not a symlink/); + assert.equal(fs.readFileSync(path.join(clash, "dsh"), "utf8"), "mine"); + + const gone = await run("sh", [script, "--uninstall", "--prefix", prefix, "--bin-dir", bin], { env }); + assert.match(gone.stdout, /removed/); + assert.ok(!fs.existsSync(path.join(bin, "dsh")) && !fs.existsSync(prefix)); +}); + +test("install.sh: rejects unknown options", async () => { + await assert.rejects(run("sh", [script, "--bogus"]), /unknown option/); +});