diff --git a/INTEGRATION.md b/INTEGRATION.md index 6fe53f5..5ccd0ee 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -52,20 +52,31 @@ Symlinked rather than copied — a future `git pull && npm run build` is the entire upgrade story, no need to re-run the install script. **A deploy target you'd rather not clone a git repo onto** — bundle the -CLI into a single portable file and ship just that, over SSH, the same -way flit/waste-go's `deploy-*.sh` scripts ship a compiled binary: +CLI and ship it over SSH, the same way flit/waste-go's `deploy-*.sh` +scripts ship a compiled binary: ```bash HOST=user@your-vps ./deploy-cli.sh ``` -This bundles `src/cli` (esbuild, single file, ~18kb — the CLI only -touches Node builtins and libsodium-wrappers, no native addons, so it's -fully portable) and `cat`s it straight to `~/bin/keep` on the target over -SSH — no git, no npm install, no node_modules on that machine at all, -just a `node` runtime (already there, if it's running any of the other -Node-based projects in this family) and the one file. Re-run it any time -the CLI changes; it's a single file, safe to overwrite. +Not quite a single file: the bundle itself (esbuild, ~18kb) covers +everything except `libsodium-wrappers`, which ships alongside it as the +real, unmodified package (~1.6MB total with its own dependency, +`libsodium`) rather than being force-inlined. That package's published +ESM build follows a broken relative import (see `src/shared/crypto.ts`'s +own comment on this), and statically inlining it via esbuild — technically +possible by routing through a literal `require()` call instead of +`import` — breaks its WASM engine's own Node-crypto feature detection at +runtime ("No secure random number generator found"), confirmed by +testing, not assumed. Shipping the real package is more reliable than +fighting that. + +The script tars `dist-bundle/` (the esbuild output plus that one +dependency) and extracts it to `~/.keep-cli/` on the target over SSH, +then symlinks `~/bin/keep` to the entry point inside it — no git, no npm +install on that machine at all, just a `node` runtime (already there, if +it's running any of the other Node-based projects in this family). Re-run +it any time the CLI changes; safe to overwrite. ## Pattern: several docker-compose projects on one or more VPSes diff --git a/deploy-cli.sh b/deploy-cli.sh index 2138a25..f96ac87 100755 --- a/deploy-cli.sh +++ b/deploy-cli.sh @@ -1,23 +1,38 @@ #!/usr/bin/env bash -# deploy-cli.sh — bundle the keep CLI into a single portable file and ship -# it straight to a host over SSH. No git clone, no npm install on the -# target — the bundle only touches Node builtins and libsodium-wrappers -# (pure JS/WASM, no native compilation), so the file itself is the whole -# artifact. Mirrors flit/deploy-daemon.sh's ssh-cat-chmod pattern. +# deploy-cli.sh — package the keep CLI (bundle + its one real runtime +# dependency, libsodium-wrappers) and ship it straight to a host over SSH. +# No git clone, no npm install on the target. +# +# Not a single file: libsodium-wrappers' published ESM build is broken +# (a relative import that doesn't resolve outside a bundler-aware +# context — see src/shared/crypto.ts), and statically force-inlining it +# via esbuild breaks its own Node-crypto feature-detection at runtime +# ("No secure random number generator found"). Shipping the real, +# unmodified package alongside a small (~18kb) bundle for everything +# else is more reliable than fighting that — the whole package is still +# only ~1.6MB and this script is still the entire deploy step. # # Usage: HOST=user@host ./deploy-cli.sh set -euo pipefail HOST="${HOST:?usage: HOST=user@host ./deploy-cli.sh}" BIN_DIR="${BIN_DIR:-~/bin}" +INSTALL_DIR="${INSTALL_DIR:-~/.keep-cli}" echo "→ bundling keep CLI…" npm run build:cli-bundle -echo "→ uploading to $HOST:$BIN_DIR/keep…" -ssh "$HOST" "mkdir -p $BIN_DIR && cat > $BIN_DIR/keep && chmod +x $BIN_DIR/keep" < dist-bundle/keep +echo "→ packaging…" +tar -C dist-bundle -czf /tmp/keep-cli.tar.gz . + +echo "→ uploading to $HOST:$INSTALL_DIR…" +ssh "$HOST" "mkdir -p $INSTALL_DIR && tar -C $INSTALL_DIR -xzf -" < /tmp/keep-cli.tar.gz +rm /tmp/keep-cli.tar.gz + +echo "→ linking $BIN_DIR/keep…" +ssh "$HOST" "mkdir -p $BIN_DIR && chmod +x $INSTALL_DIR/keep.mjs && ln -sf $INSTALL_DIR/keep.mjs $BIN_DIR/keep" echo "→ verifying…" ssh "$HOST" "$BIN_DIR/keep --help" | head -1 -echo "✓ done — re-run this script any time the CLI changes; it's one file, safe to overwrite." +echo "✓ done — re-run this script any time the CLI changes; safe to overwrite." diff --git a/package.json b/package.json index e5109ef..3beaa7b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:server": "tsx watch --env-file=.env src/server/index.ts", "cli": "tsx src/cli/index.ts", "build": "tsc", - "build:cli-bundle": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --outfile=dist-bundle/keep --external:node:*", + "build:cli-bundle": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --outfile=dist-bundle/keep.mjs --external:node:* --external:libsodium-wrappers && node scripts/copy-cli-deps.mjs", "start": "node --env-file=.env dist/server/index.js" }, "dependencies": { diff --git a/scripts/copy-cli-deps.mjs b/scripts/copy-cli-deps.mjs new file mode 100644 index 0000000..49ae25c --- /dev/null +++ b/scripts/copy-cli-deps.mjs @@ -0,0 +1,32 @@ +// Copies the one real runtime dependency the CLI bundle can't statically +// inline (libsodium-wrappers, and its own dependency libsodium) next to +// the bundle, so dist-bundle/ is a small, complete, self-contained +// directory -- no npm install needed wherever it ends up. +// +// Why not just bundle it: libsodium-wrappers' published ESM build follows +// a relative import ("./libsodium.mjs") that's broken in that package's +// own dist layout -- the exact bug crypto.ts's own comment documents. +// Forcing static inlining via a literal require() (the only way to route +// esbuild's resolver to the *working* CJS build instead) was tried and +// technically resolved, but produced "No secure random number generator +// found" at runtime -- inlining the WASM engine breaks its own Node +// crypto feature-detection. Shipping the real, unmodified package files +// is more reliable than fighting that. +import { cpSync, mkdirSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'dist-bundle', 'node_modules'); +mkdirSync(outDir, { recursive: true }); + +for (const pkg of ['libsodium-wrappers', 'libsodium']) { + const src = join(root, 'node_modules', pkg); + if (!existsSync(src)) { + console.error(`missing node_modules/${pkg} -- run npm install first`); + process.exit(1); + } + cpSync(src, join(outDir, pkg), { recursive: true }); +} + +console.log('dist-bundle/node_modules populated (libsodium-wrappers, libsodium)');