From 2d237cee04ad054b89dc4be0bea092265efb891c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 30 May 2026 00:52:38 +1000 Subject: [PATCH 1/6] spike(npm): prototype 'npx stash proxy' distribution Proof-of-concept for shipping the proxy via npm as `npx stash proxy`, using the esbuild/Biome/SWC pattern (per-platform packages + os/cpu-filtered optionalDependencies + a thin JS launcher) -- NOT native N-API bindings, since proxy is a standalone server we only need to distribute and launch. Verified end-to-end locally on darwin-arm64: npx -> stash shim -> exec native cipherstash-proxy binary, with --version/--help passthrough, correct exit-code forwarding (0 / clap's 2), signal forwarding, and os/cpu platform resolution. Binaries are git-ignored build artifacts (build-binaries.sh / demo.sh regenerate them). Packages are private + 0.0.0-prototype to prevent publish. See npm/README.md for how this maps to a production CI matrix and the code-signing rationale (skips notarization/Developer-ID; keeps free ad-hoc signing on Apple Silicon). --- npm/.gitignore | 8 ++ npm/README.md | 82 ++++++++++++++++++++ npm/build-binaries.sh | 36 +++++++++ npm/demo.sh | 31 ++++++++ npm/packages/proxy-darwin-arm64/package.json | 9 +++ npm/packages/proxy-darwin-x64/package.json | 9 +++ npm/packages/proxy-linux-arm64/package.json | 9 +++ npm/packages/proxy-linux-x64/package.json | 9 +++ npm/packages/stash/bin/stash.js | 66 ++++++++++++++++ npm/packages/stash/lib/resolve.js | 49 ++++++++++++ npm/packages/stash/package.json | 22 ++++++ 11 files changed, 330 insertions(+) create mode 100644 npm/.gitignore create mode 100644 npm/README.md create mode 100755 npm/build-binaries.sh create mode 100755 npm/demo.sh create mode 100644 npm/packages/proxy-darwin-arm64/package.json create mode 100644 npm/packages/proxy-darwin-x64/package.json create mode 100644 npm/packages/proxy-linux-arm64/package.json create mode 100644 npm/packages/proxy-linux-x64/package.json create mode 100755 npm/packages/stash/bin/stash.js create mode 100644 npm/packages/stash/lib/resolve.js create mode 100644 npm/packages/stash/package.json diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 00000000..fdc88664 --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,8 @@ +# Native binaries are build artifacts, not source — populated by build-binaries.sh +packages/*/bin/cipherstash-proxy +packages/*/bin/cipherstash-proxy.exe + +# npm install / pack output +**/node_modules/ +**/package-lock.json +*.tgz diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 00000000..b9e84432 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,82 @@ +# `npx stash proxy` — npm distribution prototype + +Proof-of-concept for shipping CipherStash Proxy through npm so it runs as +`npx stash proxy [...]`, with **no native bindings** — the npm package is a thin +launcher around the existing prebuilt Rust binary (the esbuild / Biome / SWC +pattern). + +## Why this shape (not N-API bindings) + +Proxy is a standalone server (its own tokio runtime, listeners, TLS, signals). +We don't need to call it from JS in-process, so a native Node addon would add +lifecycle/complexity for no benefit. Instead: npm *distributes* the binary and a +tiny JS shim *launches* it. + +## Layout + +``` +npm/ + packages/ + stash/ # meta package — `bin: stash` + bin/stash.js # dispatch `stash proxy [...]` -> exec binary + lib/resolve.js # pick the platform package for this host + package.json # optionalDependencies = the platform packages + proxy-darwin-arm64/ # one package per target, each ships one binary + proxy-darwin-x64/ # package.json sets os/cpu so npm installs + proxy-linux-x64/ # only the matching one on a given host + proxy-linux-arm64/ + build-binaries.sh # populate the host's platform package + demo.sh # end-to-end local proof +``` + +How resolution works: the meta package lists each `@cipherstash/proxy--` +as an **optionalDependency**. Each platform package declares `os`/`cpu`, so npm +installs only the one matching the host. The shim `require.resolve()`s the binary +from that package and `exec`s it, forwarding argv, stdio, exit code, and signals. + +## Try it (local, no registry) + +```bash +bash npm/demo.sh +``` + +This builds the host binary, `npm install`s the meta package (which pulls in just +the matching platform package), then runs `npx . proxy --version`, +`... proxy --help`, and an unknown-subcommand case. The binaries are git-ignored +build artifacts; `build-binaries.sh` regenerates them. + +> Locally we use `file:` optionalDependencies and `npx .` so it works offline. +> In production these become published, versioned packages and the command is +> literally `npx stash proxy` (or `npx @cipherstash/stash proxy`). + +## What production needs + +1. **Release CI matrix** builds `cipherstash-proxy` for every target: + - macOS arm64 / x64 — **build on a macOS runner** so the linker ad-hoc-signs + for free (enough to run on Apple Silicon; **no Developer ID / notarization + needed** for CLI-installed binaries — npm doesn't set the Gatekeeper + quarantine attribute). + - Linux x64 / arm64 (glibc; add musl for Alpine if wanted). + - Windows x64 later (`.exe`; CLI use sidesteps SmartScreen). +2. Publish each platform package (`@cipherstash/proxy--`) plus the meta + `stash` package, all at the same version, pinned exactly. +3. The meta package's `optionalDependencies` reference the published versions + instead of `file:` paths. + +## The code-signing win (the original motivation) + +- **Avoided:** macOS notarization + Developer ID certificates, and Windows + Authenticode — the expensive, account-bound parts. npm-installed CLI binaries + aren't quarantined, so Gatekeeper/SmartScreen don't block them. +- **Still required (but free/automatic):** an *ad-hoc* signature on Apple + Silicon, which the macOS linker applies during the build. `build-binaries.sh` + re-asserts it with `codesign -s -`. + +## Caveats + +- Requires Node/npx on the host. Great for dev laptops & CI; **k8s should keep + using the Docker image / raw binary** — npm is an additional channel. +- `npx` for a long-running server is slightly unconventional but works (runs in + the foreground; signals are forwarded). +- This is a throwaway prototype: packages are `private: true` and versioned + `0.0.0-prototype` to prevent accidental publish. diff --git a/npm/build-binaries.sh b/npm/build-binaries.sh new file mode 100755 index 00000000..d71cb0a9 --- /dev/null +++ b/npm/build-binaries.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Populate the per-platform packages with prebuilt proxy binaries. +# +# Prototype scope: builds/copies the binary for the CURRENT host into its +# platform package. In production this is replaced by a CI matrix that builds +# every target on the appropriate runner (macOS runners ad-hoc-sign for free; +# Linux runners produce glibc/musl builds), then publishes each platform package. +# +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${HERE}/.." && pwd)" + +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) pkg=proxy-darwin-arm64 ;; + Darwin-x86_64) pkg=proxy-darwin-x64 ;; + Linux-aarch64) pkg=proxy-linux-arm64 ;; + Linux-x86_64) pkg=proxy-linux-x64 ;; + *) echo "Unsupported host: $(uname -s)-$(uname -m)" >&2; exit 1 ;; +esac + +echo "Building cipherstash-proxy (release) for host -> ${pkg}" +( cd "${REPO_ROOT}" && cargo build --release -p cipherstash-proxy ) + +dest="${HERE}/packages/${pkg}/bin" +mkdir -p "${dest}" +cp -f "${REPO_ROOT}/target/release/cipherstash-proxy" "${dest}/cipherstash-proxy" +chmod +x "${dest}/cipherstash-proxy" + +# macOS arm64 requires at least an ad-hoc signature to execute. Binaries linked +# on macOS are ad-hoc-signed automatically, but re-assert it to be safe. +if [[ "$(uname -s)" == "Darwin" ]]; then + codesign --force --sign - "${dest}/cipherstash-proxy" 2>/dev/null || true +fi + +echo "Installed $(du -h "${dest}/cipherstash-proxy" | cut -f1) binary into ${pkg}/bin/" diff --git a/npm/demo.sh b/npm/demo.sh new file mode 100755 index 00000000..f040b580 --- /dev/null +++ b/npm/demo.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# End-to-end local proof of `npx stash proxy`: +# 1. build + install the host binary into its platform package +# 2. npm install the meta package (resolves the matching platform package +# via os/cpu-filtered optionalDependencies) +# 3. invoke through npx and through the `stash` bin, exercising arg passthrough +# +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +META="${HERE}/packages/stash" + +echo "== 1. populate host platform binary ==" +bash "${HERE}/build-binaries.sh" + +echo "== 2. install meta package (resolves platform optionalDependency) ==" +( cd "${META}" && npm install --silent ) +echo "installed platform packages:" +ls "${META}/node_modules/@cipherstash" 2>/dev/null || echo " (none — check os/cpu match)" + +echo "== 3a. npx proxy --version ==" +( cd "${META}" && npx . proxy --version ) + +echo "== 3b. npx proxy --help (subcommand passthrough) ==" +( cd "${META}" && npx . proxy --help | head -20 ) + +echo "== 3c. exit codes are forwarded ==" +( cd "${META}" && npx . proxy --version ) >/dev/null 2>&1; echo " proxy --version -> exit $? (expect 0)" +( cd "${META}" && npx . frobnicate ) >/dev/null 2>&1; echo " unknown subcommand -> exit $? (expect non-zero)" + +echo "== done ==" diff --git a/npm/packages/proxy-darwin-arm64/package.json b/npm/packages/proxy-darwin-arm64/package.json new file mode 100644 index 00000000..fbbed959 --- /dev/null +++ b/npm/packages/proxy-darwin-arm64/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/proxy-darwin-arm64", + "version": "0.0.0-prototype", + "private": true, + "description": "CipherStash Proxy native binary for macOS arm64", + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["bin/cipherstash-proxy"] +} diff --git a/npm/packages/proxy-darwin-x64/package.json b/npm/packages/proxy-darwin-x64/package.json new file mode 100644 index 00000000..1cd65e55 --- /dev/null +++ b/npm/packages/proxy-darwin-x64/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/proxy-darwin-x64", + "version": "0.0.0-prototype", + "private": true, + "description": "CipherStash Proxy native binary for macOS x86_64", + "os": ["darwin"], + "cpu": ["x64"], + "files": ["bin/cipherstash-proxy"] +} diff --git a/npm/packages/proxy-linux-arm64/package.json b/npm/packages/proxy-linux-arm64/package.json new file mode 100644 index 00000000..7b27e6f4 --- /dev/null +++ b/npm/packages/proxy-linux-arm64/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/proxy-linux-arm64", + "version": "0.0.0-prototype", + "private": true, + "description": "CipherStash Proxy native binary for Linux arm64 (glibc)", + "os": ["linux"], + "cpu": ["arm64"], + "files": ["bin/cipherstash-proxy"] +} diff --git a/npm/packages/proxy-linux-x64/package.json b/npm/packages/proxy-linux-x64/package.json new file mode 100644 index 00000000..9c7d4342 --- /dev/null +++ b/npm/packages/proxy-linux-x64/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/proxy-linux-x64", + "version": "0.0.0-prototype", + "private": true, + "description": "CipherStash Proxy native binary for Linux x86_64 (glibc)", + "os": ["linux"], + "cpu": ["x64"], + "files": ["bin/cipherstash-proxy"] +} diff --git a/npm/packages/stash/bin/stash.js b/npm/packages/stash/bin/stash.js new file mode 100755 index 00000000..1f550b79 --- /dev/null +++ b/npm/packages/stash/bin/stash.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +"use strict"; + +// `stash` CLI launcher. For now it dispatches the `proxy` subcommand to the +// prebuilt cipherstash-proxy binary shipped via the per-platform npm package. +// +// npx stash proxy [proxy args...] +// +// The JS layer is intentionally thin: it resolves the right native binary and +// execs it, forwarding argv, stdio, exit code and termination signals so it +// behaves like running the binary directly. + +const { spawn } = require("child_process"); +const { resolveProxyBinary } = require("../lib/resolve"); + +function usage() { + process.stderr.write( + "Usage: stash [args...]\n\n" + + "Commands:\n" + + " proxy [args...] Run CipherStash Proxy (forwards all args)\n" + ); +} + +const [subcommand, ...rest] = process.argv.slice(2); + +if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") { + usage(); + process.exit(subcommand ? 0 : 2); +} + +if (subcommand !== "proxy") { + process.stderr.write(`stash: unknown command '${subcommand}'\n\n`); + usage(); + process.exit(2); +} + +let binary; +try { + binary = resolveProxyBinary(); +} catch (err) { + process.stderr.write(`stash: ${err.message}\n`); + process.exit(1); +} + +const child = spawn(binary, rest, { stdio: "inherit" }); + +// Forward termination signals so Ctrl-C / orchestrator shutdowns reach the +// long-running proxy rather than just the Node wrapper. +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); +} + +child.on("error", (err) => { + process.stderr.write(`stash: failed to launch proxy: ${err.message}\n`); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + // Re-raise so our exit reflects the signal (conventional 128+n). + process.exit(128 + (require("os").constants.signals[signal] || 0)); + } + process.exit(code ?? 0); +}); diff --git a/npm/packages/stash/lib/resolve.js b/npm/packages/stash/lib/resolve.js new file mode 100644 index 00000000..54241257 --- /dev/null +++ b/npm/packages/stash/lib/resolve.js @@ -0,0 +1,49 @@ +"use strict"; + +// Maps the current platform to the per-platform npm package that ships the +// matching prebuilt `cipherstash-proxy` binary. This mirrors the esbuild / +// Biome / SWC distribution pattern: the meta package declares each of these as +// an optionalDependency with `os`/`cpu` constraints, so npm installs only the +// one matching the host. +const PLATFORM_PACKAGES = { + "darwin-arm64": "@cipherstash/proxy-darwin-arm64", + "darwin-x64": "@cipherstash/proxy-darwin-x64", + "linux-x64": "@cipherstash/proxy-linux-x64", + "linux-arm64": "@cipherstash/proxy-linux-arm64", + // win32-x64 would go here once we ship a Windows build. +}; + +function platformKey() { + return `${process.platform}-${process.arch}`; +} + +function binaryName() { + return process.platform === "win32" + ? "cipherstash-proxy.exe" + : "cipherstash-proxy"; +} + +// Resolve the absolute path to the proxy binary for this platform, or throw a +// clear, actionable error if the platform package isn't installed. +function resolveProxyBinary() { + const key = platformKey(); + const pkg = PLATFORM_PACKAGES[key]; + if (!pkg) { + throw new Error( + `cipherstash-proxy is not available for this platform (${key}). ` + + `Supported: ${Object.keys(PLATFORM_PACKAGES).join(", ")}.` + ); + } + try { + // require.resolve finds the binary inside the installed platform package. + return require.resolve(`${pkg}/bin/${binaryName()}`); + } catch { + throw new Error( + `The platform package '${pkg}' is not installed.\n` + + `npm should install it automatically as an optionalDependency for ${key}.\n` + + `If you used '--no-optional' or '--omit=optional', reinstall without it.` + ); + } +} + +module.exports = { resolveProxyBinary, platformKey, PLATFORM_PACKAGES }; diff --git a/npm/packages/stash/package.json b/npm/packages/stash/package.json new file mode 100644 index 00000000..8fd38e0d --- /dev/null +++ b/npm/packages/stash/package.json @@ -0,0 +1,22 @@ +{ + "name": "stash", + "version": "0.0.0-prototype", + "private": true, + "description": "Prototype: CipherStash CLI launcher — `npx stash proxy`", + "bin": { + "stash": "bin/stash.js" + }, + "files": [ + "bin/", + "lib/" + ], + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@cipherstash/proxy-darwin-arm64": "file:../proxy-darwin-arm64", + "@cipherstash/proxy-darwin-x64": "file:../proxy-darwin-x64", + "@cipherstash/proxy-linux-x64": "file:../proxy-linux-x64", + "@cipherstash/proxy-linux-arm64": "file:../proxy-linux-arm64" + } +} From 4189f0a57ab840c5e05f99daa0d9f97189e55636 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 30 May 2026 00:53:23 +1000 Subject: [PATCH 2/6] docs(npm): add example release-workflow CI matrix sketch --- npm/release-workflow.example.yml | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 npm/release-workflow.example.yml diff --git a/npm/release-workflow.example.yml b/npm/release-workflow.example.yml new file mode 100644 index 00000000..b45cdcd5 --- /dev/null +++ b/npm/release-workflow.example.yml @@ -0,0 +1,63 @@ +# EXAMPLE — not wired up. Sketch of the release machinery to publish +# `npx stash proxy` to npm. Named `.example.yml` so it never triggers; copy to +# .github/workflows/ and fill in secrets/versions to use it. +# +# Shape: build cipherstash-proxy for every target on the right runner, assemble +# one npm package per platform, then publish all platform packages + the meta +# `stash` package at the same version. + +name: npm-release (example) + +on: + workflow_dispatch: + # release: + # types: [published] + +jobs: + build: + strategy: + matrix: + include: + # macOS runners ad-hoc-sign during linking — enough for Apple Silicon, + # no Developer ID / notarization needed for CLI-installed binaries. + - { os: macos-14, target: aarch64-apple-darwin, pkg: proxy-darwin-arm64 } + - { os: macos-13, target: x86_64-apple-darwin, pkg: proxy-darwin-x64 } + - { os: ubuntu-24.04, target: x86_64-unknown-linux-gnu, pkg: proxy-linux-x64 } + - { os: ubuntu-24.04, target: aarch64-unknown-linux-gnu, pkg: proxy-linux-arm64 } + # - { os: windows-2022, target: x86_64-pc-windows-msvc, pkg: proxy-win32-x64 } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - run: rustup target add ${{ matrix.target }} + # Linux cross-targets need a cross-linker (e.g. cross, or the gcc the repo + # already uses for aarch64-unknown-linux-gnu). macOS targets build natively. + - run: cargo build --release --locked --target ${{ matrix.target }} -p cipherstash-proxy + - name: Assemble platform package + run: | + dest="npm/packages/${{ matrix.pkg }}/bin" + mkdir -p "$dest" + cp "target/${{ matrix.target }}/release/cipherstash-proxy" "$dest/" + # Re-assert ad-hoc signature on macOS (linker already does this). + if [[ "${{ runner.os }}" == "macOS" ]]; then codesign --force --sign - "$dest/cipherstash-proxy"; fi + - uses: actions/upload-artifact@v4 + with: { name: "${{ matrix.pkg }}", path: "npm/packages/${{ matrix.pkg }}" } + + publish: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { path: npm/packages } + - uses: actions/setup-node@v4 + with: { node-version: 22, registry-url: "https://registry.npmjs.org" } + - name: Set matching versions + run: | + # Stamp every platform package + the meta package to $VERSION, and pin + # the meta package's optionalDependencies to exact $VERSION (not file:). + echo "TODO: bump versions + rewrite optionalDependencies to ^$VERSION" + - name: Publish platform packages then meta + env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" } + run: | + for p in npm/packages/proxy-*; do (cd "$p" && npm publish --access public); done + (cd npm/packages/stash && npm publish --access public) From b5b46617c7b15b9afe7568f452d71c852f622aa7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 30 May 2026 11:33:18 +1000 Subject: [PATCH 3/6] feat(npm): --psql opens a psql session through the proxy `stash proxy --psql ...` starts the proxy, waits for it to report its listen address (parsing the OS-assigned port when the default is in use), then launches psql connected to the proxy with the target db/user/password. psql is the foreground session; the proxy is torn down when it exits. Falls back with a clear message if psql is not on PATH. Connection details are taken from --database-url, then --db-* flags, then CS_DATABASE__* env. Validated against a local dev DB. --- npm/packages/stash/bin/stash.js | 179 ++++++++++++++++++++++++++++---- 1 file changed, 158 insertions(+), 21 deletions(-) diff --git a/npm/packages/stash/bin/stash.js b/npm/packages/stash/bin/stash.js index 1f550b79..4d4f7dd3 100755 --- a/npm/packages/stash/bin/stash.js +++ b/npm/packages/stash/bin/stash.js @@ -1,23 +1,27 @@ #!/usr/bin/env node "use strict"; -// `stash` CLI launcher. For now it dispatches the `proxy` subcommand to the -// prebuilt cipherstash-proxy binary shipped via the per-platform npm package. +// `stash` CLI launcher. Dispatches the `proxy` subcommand to the prebuilt +// cipherstash-proxy binary shipped via the per-platform npm package. // -// npx stash proxy [proxy args...] +// npx stash proxy [proxy args...] # run the proxy in the foreground +// npx stash proxy --psql [proxy args...] # run the proxy AND open psql through it // // The JS layer is intentionally thin: it resolves the right native binary and -// execs it, forwarding argv, stdio, exit code and termination signals so it -// behaves like running the binary directly. +// execs it, forwarding argv, stdio, exit code and termination signals. With +// --psql it additionally waits for the proxy to report its listen address, then +// launches psql connected to the proxy, and shuts the proxy down on exit. -const { spawn } = require("child_process"); +const { spawn, spawnSync } = require("child_process"); +const os = require("os"); const { resolveProxyBinary } = require("../lib/resolve"); function usage() { process.stderr.write( "Usage: stash [args...]\n\n" + "Commands:\n" + - " proxy [args...] Run CipherStash Proxy (forwards all args)\n" + " proxy [args...] Run CipherStash Proxy (forwards all args)\n" + + " proxy --psql [args...] Run the proxy and open a psql session through it\n" ); } @@ -42,25 +46,158 @@ try { process.exit(1); } -const child = spawn(binary, rest, { stdio: "inherit" }); +if (rest.includes("--psql")) { + runProxyThenPsql(binary, rest.filter((a) => a !== "--psql")); +} else { + runProxy(binary, rest); +} + +// --- plain proxy: exec the binary and forward everything --------------------- -// Forward termination signals so Ctrl-C / orchestrator shutdowns reach the -// long-running proxy rather than just the Node wrapper. -for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { - process.on(signal, () => { - if (!child.killed) child.kill(signal); +function runProxy(binary, args) { + const child = spawn(binary, args, { stdio: "inherit" }); + forwardSignals(child); + child.on("error", (err) => { + process.stderr.write(`stash: failed to launch proxy: ${err.message}\n`); + process.exit(1); }); + child.on("exit", (code, signal) => exitFrom(code, signal)); } -child.on("error", (err) => { - process.stderr.write(`stash: failed to launch proxy: ${err.message}\n`); - process.exit(1); -}); +// --- proxy + psql ------------------------------------------------------------ + +function runProxyThenPsql(binary, args) { + if (!commandExists("psql")) { + process.stderr.write( + "stash: --psql requires the `psql` client on your PATH, which was not found.\n" + + " Install the PostgreSQL client, or run without --psql and connect manually.\n" + ); + process.exit(1); + } + + const conn = connectionInfo(args); + + // stdin: ignore (the proxy is a server and never reads it; psql needs the tty). + // stdout: piped so we can detect the listen address. + // stderr: inherited so the proxy's few status lines are visible. + const proxy = spawn(binary, args, { stdio: ["ignore", "pipe", "inherit"] }); + + let psql = null; + let launchedPsql = false; + let buffered = ""; + + proxy.stdout.setEncoding("utf8"); + proxy.stdout.on("data", (chunk) => { + // Echo proxy stdout to our stderr to keep our stdout clean for psql. + process.stderr.write(chunk); + if (launchedPsql) return; + + buffered += chunk; + // The proxy prints e.g. "CipherStash Proxy listening on 0.0.0.0:64335". + const match = buffered.match(/listening on \S+?:(\d+)/i); + if (match) { + launchedPsql = true; + psql = launchPsql(parseInt(match[1], 10), conn, proxy); + } + }); + + forwardSignals(proxy); + + proxy.on("error", (err) => { + process.stderr.write(`stash: failed to launch proxy: ${err.message}\n`); + process.exit(1); + }); + + proxy.on("exit", (code, signal) => { + if (!launchedPsql) { + // Proxy died before it was ready (e.g. database unreachable). + process.stderr.write("stash: proxy exited before it was ready; not starting psql.\n"); + exitFrom(code, signal); + } + // If psql is running, its own exit handler drives our exit. + }); +} + +function launchPsql(port, conn, proxy) { + process.stderr.write(`stash: connecting psql to the proxy on 127.0.0.1:${port}\n`); + + // Use PG* env so we don't have to URL-escape the connection string. The proxy + // presents as the target database, so psql uses the target's user/db; sslmode + // is disabled because the local proxy listener is plaintext by default. + const env = { ...process.env, PGHOST: "127.0.0.1", PGPORT: String(port), PGSSLMODE: "disable" }; + if (conn.user) env.PGUSER = conn.user; + if (conn.dbname) env.PGDATABASE = conn.dbname; + if (conn.password != null) env.PGPASSWORD = conn.password; + + const psql = spawn("psql", [], { stdio: "inherit", env }); -child.on("exit", (code, signal) => { + psql.on("error", (err) => { + process.stderr.write(`stash: failed to launch psql: ${err.message}\n`); + stop(proxy); + process.exit(1); + }); + + psql.on("exit", (code, signal) => { + // psql is the foreground session; when it ends, tear the proxy down. + stop(proxy); + exitFrom(code, signal); + }); + + return psql; +} + +// Extract user / password / dbname for the psql connection from --database-url +// (preferred), individual --db-* flags, then CS_DATABASE__* env. +function connectionInfo(args) { + const flag = (name) => { + const i = args.indexOf(name); + return i !== -1 ? args[i + 1] : undefined; + }; + + let fromUrl = {}; + const url = flag("--database-url"); + if (url) { + try { + const u = new URL(url); + fromUrl = { + user: decodeURIComponent(u.username) || undefined, + password: u.password ? decodeURIComponent(u.password) : undefined, + dbname: u.pathname.replace(/^\//, "") || undefined, + }; + } catch { + process.stderr.write(`stash: could not parse --database-url for psql\n`); + } + } + + return { + user: flag("--db-user") ?? fromUrl.user ?? process.env.CS_DATABASE__USERNAME, + password: flag("--db-password") ?? fromUrl.password ?? process.env.CS_DATABASE__PASSWORD, + dbname: fromUrl.dbname ?? process.env.CS_DATABASE__NAME, + }; +} + +// --- helpers ----------------------------------------------------------------- + +function commandExists(cmd) { + const probe = spawnSync(cmd, ["--version"], { stdio: "ignore" }); + return !probe.error; +} + +function stop(child) { + if (child && !child.killed) child.kill("SIGTERM"); +} + +function forwardSignals(child) { + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); + } +} + +function exitFrom(code, signal) { if (signal) { - // Re-raise so our exit reflects the signal (conventional 128+n). - process.exit(128 + (require("os").constants.signals[signal] || 0)); + process.exit(128 + (os.constants.signals[signal] || 0)); } process.exit(code ?? 0); -}); +} From d7af1d4fdd1f7050107564310e301b2980c82121 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 30 May 2026 11:54:30 +1000 Subject: [PATCH 4/6] feat(npm): built-in pure-JS SQL shell fallback when psql is absent When `--psql` is used and psql isn't on PATH (or STASH_USE_BUILTIN_SQL=1 is set), open a small built-in SQL shell (lib/repl.js) instead of failing. It uses the pure-JS `pg` driver (no native binaries) and runs SQL through the proxy with tabular output and a few meta-commands (\l, \dt, \d, \?, \q). Not a psql replacement -- a convenience fallback. Real psql is still preferred when installed. Validated end-to-end against a local dev DB via the proxy. Background: bundling real psql isn't viable off-the-shelf -- the @embedded-postgres/* packages ship initdb/pg_ctl/postgres but strip psql -- so a pure-JS shell is the pragmatic no-native-deps fallback. --- npm/packages/stash/bin/stash.js | 45 ++++++++-- npm/packages/stash/lib/repl.js | 153 ++++++++++++++++++++++++++++++++ npm/packages/stash/package.json | 3 + 3 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 npm/packages/stash/lib/repl.js diff --git a/npm/packages/stash/bin/stash.js b/npm/packages/stash/bin/stash.js index 4d4f7dd3..58cdf08f 100755 --- a/npm/packages/stash/bin/stash.js +++ b/npm/packages/stash/bin/stash.js @@ -67,16 +67,16 @@ function runProxy(binary, args) { // --- proxy + psql ------------------------------------------------------------ function runProxyThenPsql(binary, args) { - if (!commandExists("psql")) { - process.stderr.write( - "stash: --psql requires the `psql` client on your PATH, which was not found.\n" + - " Install the PostgreSQL client, or run without --psql and connect manually.\n" - ); - process.exit(1); - } - const conn = connectionInfo(args); + // Prefer the system psql; fall back to the built-in JS SQL shell when it's + // not installed (or when forced via STASH_USE_BUILTIN_SQL=1). + const forceBuiltin = process.env.STASH_USE_BUILTIN_SQL === "1"; + const usePsql = !forceBuiltin && commandExists("psql"); + if (!usePsql && !forceBuiltin) { + process.stderr.write("stash: psql not found on PATH; using the built-in SQL shell instead.\n"); + } + // stdin: ignore (the proxy is a server and never reads it; psql needs the tty). // stdout: piped so we can detect the listen address. // stderr: inherited so the proxy's few status lines are visible. @@ -97,7 +97,12 @@ function runProxyThenPsql(binary, args) { const match = buffered.match(/listening on \S+?:(\d+)/i); if (match) { launchedPsql = true; - psql = launchPsql(parseInt(match[1], 10), conn, proxy); + const port = parseInt(match[1], 10); + if (usePsql) { + psql = launchPsql(port, conn, proxy); + } else { + launchRepl(port, conn, proxy); + } } }); @@ -146,6 +151,28 @@ function launchPsql(port, conn, proxy) { return psql; } +// Built-in JS SQL shell (fallback when psql isn't installed). +function launchRepl(port, conn, proxy) { + process.stderr.write(`stash: opening built-in SQL shell to the proxy on 127.0.0.1:${port}\n`); + const { runRepl } = require("../lib/repl"); + runRepl({ + host: "127.0.0.1", + port, + user: conn.user, + password: conn.password, + database: conn.dbname, + }) + .then(() => { + stop(proxy); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`stash: SQL shell error: ${err.message}\n`); + stop(proxy); + process.exit(1); + }); +} + // Extract user / password / dbname for the psql connection from --database-url // (preferred), individual --db-* flags, then CS_DATABASE__* env. function connectionInfo(args) { diff --git a/npm/packages/stash/lib/repl.js b/npm/packages/stash/lib/repl.js new file mode 100644 index 00000000..9a001ee1 --- /dev/null +++ b/npm/packages/stash/lib/repl.js @@ -0,0 +1,153 @@ +"use strict"; + +// A small built-in SQL shell used when `psql` isn't available on PATH. It is a +// convenience fallback, NOT a psql replacement: it runs SQL and prints results, +// plus a handful of \-commands. Pure JS (the `pg` driver) so it needs no native +// binaries. + +const readline = require("readline"); + +// Minimal \-command help. +const HELP = `Commands: + \\q quit + \\? this help + \\l list databases + \\dt list tables + \\d [name] describe a table (or list tables) + ; run SQL (statements end with ;) +`; + +// Map a \-command to the SQL it runs (or a control action). +function metaCommand(line, dbname) { + const [cmd, arg] = line.trim().split(/\s+/, 2); + switch (cmd) { + case "\\q": + return { quit: true }; + case "\\?": + return { help: true }; + case "\\l": + return { + sql: "SELECT datname AS name FROM pg_database WHERE datistemplate = false ORDER BY 1;", + }; + case "\\dt": + return { + sql: `SELECT schemaname AS schema, tablename AS name FROM pg_catalog.pg_tables + WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2;`, + }; + case "\\d": + if (!arg) { + return metaCommand("\\dt", dbname); + } + return { + sql: `SELECT column_name AS column, data_type AS type, is_nullable AS nullable + FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position;`, + params: [arg], + }; + default: + return { error: `unknown command: ${cmd} (try \\?)` }; + } +} + +function cell(value) { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +// Render rows (array of objects) as an aligned table. +function formatTable(fields, rows) { + const cols = fields.map((f) => f.name); + const widths = cols.map((c) => c.length); + const text = rows.map((row) => + cols.map((c, i) => { + const s = cell(row[c]); + if (s.length > widths[i]) widths[i] = s.length; + return s; + }) + ); + const pad = (s, w) => s + " ".repeat(w - s.length); + const lines = []; + lines.push(cols.map((c, i) => pad(c, widths[i])).join(" | ")); + lines.push(widths.map((w) => "-".repeat(w)).join("-+-")); + for (const r of text) lines.push(r.map((s, i) => pad(s, widths[i])).join(" | ")); + lines.push(`(${rows.length} row${rows.length === 1 ? "" : "s"})`); + return lines.join("\n"); +} + +async function runQuery(client, sql, params) { + try { + const res = await client.query(sql, params); + const results = Array.isArray(res) ? res : [res]; + for (const r of results) { + if (r.fields && r.fields.length > 0) { + process.stdout.write(formatTable(r.fields, r.rows) + "\n"); + } else { + process.stdout.write(`${r.command}${r.rowCount != null ? " " + r.rowCount : ""}\n`); + } + } + } catch (err) { + process.stderr.write(`ERROR: ${err.message}\n`); + } +} + +// Connect to the (proxy) endpoint and run an interactive shell until EOF / \q. +async function runRepl({ host, port, user, password, database }) { + // Lazy require so the `pg` dependency is only loaded on this path. + const { Client } = require("pg"); + const client = new Client({ host, port, user, password, database, ssl: false }); + await client.connect(); + + const label = database || "stash"; + process.stdout.write( + `Built-in SQL shell (psql not found). Connected to ${label} via the proxy. Type \\? for help, \\q to quit.\n` + ); + + const rl = readline.createInterface({ + input: process.stdin, + terminal: Boolean(process.stdin.isTTY), + }); + + let buffer = ""; + const promptText = () => (buffer ? "... " : `${label}=> `); + + // Ctrl-C abandons the statement in progress (like psql); Ctrl-D (EOF) ends + // the async iterator and exits. + rl.on("SIGINT", () => { + buffer = ""; + process.stdout.write("\n" + promptText()); + }); + + process.stdout.write(promptText()); + + try { + // Async iteration processes one line at a time and awaits each query before + // pulling the next, so statements (and the final one) complete in order. + for await (const line of rl) { + const trimmed = line.trim(); + + if (buffer === "" && trimmed.startsWith("\\")) { + const action = metaCommand(trimmed, database); + if (action.quit) break; + if (action.help) process.stdout.write(HELP); + else if (action.error) process.stderr.write(action.error + "\n"); + else if (action.sql) await runQuery(client, action.sql, action.params); + } else { + buffer += (buffer ? "\n" : "") + line; + if (buffer.trimEnd().endsWith(";")) { + const sql = buffer; + buffer = ""; + await runQuery(client, sql); + } + } + + process.stdout.write(promptText()); + } + } finally { + rl.close(); + // Don't block exit waiting for the proxy to close the socket; the caller + // tears the proxy down and exits immediately after we return. + client.end().catch(() => {}); + } +} + +module.exports = { runRepl }; diff --git a/npm/packages/stash/package.json b/npm/packages/stash/package.json index 8fd38e0d..0c06e7ef 100644 --- a/npm/packages/stash/package.json +++ b/npm/packages/stash/package.json @@ -13,6 +13,9 @@ "engines": { "node": ">=18" }, + "dependencies": { + "pg": "^8.13.0" + }, "optionalDependencies": { "@cipherstash/proxy-darwin-arm64": "file:../proxy-darwin-arm64", "@cipherstash/proxy-darwin-x64": "file:../proxy-darwin-x64", From a814b1e42d2412cd95a921553197b2ca5e861299 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 30 May 2026 13:46:50 +1000 Subject: [PATCH 5/6] feat(npm): branded 'stash:' prompt for psql and the built-in shell Visually distinguishes a via-proxy session from a direct psql connection: the prompt becomes e.g. `stash:mydb=>` with "stash" in cyan (on a TTY). Applied to both real psql (via PROMPT1/PROMPT2 --set) and the built-in shell. Override with STASH_PSQL_PROMPT (set empty to use psql's default / ~/.psqlrc); colour honours NO_COLOR and is disabled off a TTY. --- npm/packages/stash/bin/stash.js | 14 +++++++++++++- npm/packages/stash/lib/repl.js | 7 ++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/npm/packages/stash/bin/stash.js b/npm/packages/stash/bin/stash.js index 58cdf08f..bd876241 100755 --- a/npm/packages/stash/bin/stash.js +++ b/npm/packages/stash/bin/stash.js @@ -134,7 +134,7 @@ function launchPsql(port, conn, proxy) { if (conn.dbname) env.PGDATABASE = conn.dbname; if (conn.password != null) env.PGPASSWORD = conn.password; - const psql = spawn("psql", [], { stdio: "inherit", env }); + const psql = spawn("psql", psqlPromptArgs(), { stdio: "inherit", env }); psql.on("error", (err) => { process.stderr.write(`stash: failed to launch psql: ${err.message}\n`); @@ -205,6 +205,18 @@ function connectionInfo(args) { // --- helpers ----------------------------------------------------------------- +// Branded psql prompt so a via-proxy session is visually distinct (e.g. +// "stash:dbname=>" with "stash" in cyan). Override the whole prompt with +// STASH_PSQL_PROMPT, or set it empty to use psql's default / your ~/.psqlrc. +function psqlPromptArgs() { + const custom = process.env.STASH_PSQL_PROMPT; + if (custom === "") return []; + const ESC = "\x1b"; + // %[ %] wrap non-printing bytes so psql counts the prompt width correctly. + const prompt = custom || `%[${ESC}[36m%]stash%[${ESC}[0m%]:%/%R%# `; + return ["--set", `PROMPT1=${prompt}`, "--set", `PROMPT2=${prompt}`]; +} + function commandExists(cmd) { const probe = spawnSync(cmd, ["--version"], { stdio: "ignore" }); return !probe.error; diff --git a/npm/packages/stash/lib/repl.js b/npm/packages/stash/lib/repl.js index 9a001ee1..8ae40dfc 100644 --- a/npm/packages/stash/lib/repl.js +++ b/npm/packages/stash/lib/repl.js @@ -108,7 +108,12 @@ async function runRepl({ host, port, user, password, database }) { }); let buffer = ""; - const promptText = () => (buffer ? "... " : `${label}=> `); + // Branded prompt (e.g. "stash:dbname=>"), with the prefix coloured on a TTY + // unless NO_COLOR is set, matching the psql prompt the launcher sets. + const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; + const cyan = useColor ? "\x1b[36m" : ""; + const reset = useColor ? "\x1b[0m" : ""; + const promptText = () => `${cyan}stash${reset}:${label}${buffer ? "-> " : "=> "}`; // Ctrl-C abandons the statement in progress (like psql); Ctrl-D (EOF) ends // the async iterator and exits. From 59bed525268eabec4c35bdb170865cf83a644ddd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 30 May 2026 13:53:05 +1000 Subject: [PATCH 6/6] fix(npm): use psql octal escape (%033) so the prompt colour renders A literal ESC byte in PROMPT1 was stripped by psql's variable parser, so the prompt showed in the default colour. psql's own %033 octal escape produces the ESC reliably (verified: \001 ESC[36m \002 stash \001 ESC[0m \002 -- 'stash' wrapped in cyan). --- npm/packages/stash/bin/stash.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/npm/packages/stash/bin/stash.js b/npm/packages/stash/bin/stash.js index bd876241..f0e53c86 100755 --- a/npm/packages/stash/bin/stash.js +++ b/npm/packages/stash/bin/stash.js @@ -211,9 +211,10 @@ function connectionInfo(args) { function psqlPromptArgs() { const custom = process.env.STASH_PSQL_PROMPT; if (custom === "") return []; - const ESC = "\x1b"; - // %[ %] wrap non-printing bytes so psql counts the prompt width correctly. - const prompt = custom || `%[${ESC}[36m%]stash%[${ESC}[0m%]:%/%R%# `; + // psql renders ESC from its own octal escape `%033` (a literal ESC byte gets + // mangled in variable parsing); `%[ %]` wrap the non-printing bytes so psql + // counts the prompt width correctly. 36m = cyan, 0m = reset. + const prompt = custom || "%[%033[36m%]stash%[%033[0m%]:%/%R%# "; return ["--set", `PROMPT1=${prompt}`, "--set", `PROMPT2=${prompt}`]; }