Compare commits

...

5 Commits

Author SHA1 Message Date
Fredrik Johansson
bb7f890a68 keep overview: print which server it's talking to
All checks were successful
Docker / build-and-push (push) Successful in 1m53s
Surfaced by a real mix-up this session: KEEP_SERVER_URL was set to a
"dev"-named hostname, and it was unclear without checking DNS/proxy
config whether that was actually the same server as the one running
on the deploy VPS or a separate instance -- nothing in `overview`'s
output (vault names, recipient labels) says which server answered.
Defaults silently to localhost:3050 if the env var isn't set at all,
which is its own way to end up looking at the wrong thing.

Printed before the fetch, not after, so it still shows up on a failed
request ("error: fetch failed") -- the exact case where knowing the
target mattered most and, before this, showed nothing at all.

Verified against a local test server: shows the right URL on success,
and shows the (default, unset) URL immediately before a deliberately
failed fetch to a port nothing's listening on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:25:40 +02:00
Fredrik Johansson
74122b25cd deploy.sh: resolve vault key via .keep-vault marker, never fail on keep
Was previously hardcoded to vault key "<project-dir-name>/<env>" with
no marker-file lookup at all -- a real gap versus the design already
written out in PROPOSAL-announce-and-agent.md §4, which this script
never actually caught up to. Repo names, deploy directory names, and
vault keys are three independent strings in practice (a repo can be
named one thing, its deploy directory another, entirely by history/
accident) -- exactly the situation the npm-statuspage rollout hit.

Resolution order: $dir/.keep-vault if present, else fall back to
"<project>/<env>". `keep pull` is now fully best-effort -- missing
vault, missing grant, or keep not installed at all is never fatal,
matches the proposal's explicit requirement that a keep failure means
"doesn't apply to this project (yet)," never "stop the deploy."
Existing/hand-copied .env is left alone if the pull doesn't happen.

Verified with stub keep/docker commands on PATH (no real server
needed for this): a marker-file project with a deliberately mismatched
vault key resolves and pulls correctly; a project with neither a
marker nor a matching vault falls through cleanly to docker compose up
-d without ever touching .env or failing the deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:12:57 +02:00
Fredrik Johansson
1b4cd9b826 Fix deploy-cli.sh: bundle was completely broken on a real host
Caught live, deployed to the actual target -- the previous version
failed on the real host with "Cannot use import statement outside a
module" (Node 18, no ancestor package.json to signal ESM for an
extensionless file). That surfaced two stacked bugs my own testing had
missed by running the bundle from inside this repo's own directory
tree, which coincidentally supplied both things the standalone
artifact was silently depending on:

1. Module-format ambiguity: an extensionless file with no controlling
   package.json defaults to CommonJS on older Node (no auto-detection
   heuristic before recent versions). Fixed by naming the bundle
   output keep.mjs -- unconditionally ESM on any Node version,
   regardless of extension-less-file heuristics or nearby package.json.

2. The bigger one: crypto.ts's createRequire(import.meta.url) trick
   (needed because libsodium-wrappers' published ESM build follows a
   broken relative import) is opaque to esbuild's static bundler --
   it's a runtime-obtained require reference, not the literal `require`
   token esbuild's bundling recognizes. The previous "18kb bundle"
   never actually contained libsodium-wrappers at all; it silently
   relied on real node_modules being nearby on disk, which was only
   ever true by accident when testing from within this repo. On a
   clean host it threw "Cannot find module 'libsodium-wrappers'".

   Tried forcing static inlining via a literal require() call (esbuild
   does special-case that even in ESM-format output, unlike `import`,
   which is permanently pinned to the "import" resolution condition
   and can't be routed to the package's working CJS build no matter
   what --conditions/--main-fields/--alias combination is tried this
   was tried and confirmed exhaustively). That got real resolution and
   a real 1.7MB bundle, but broke at runtime with "No secure random
   number generator found" -- inlining the WASM engine breaks its own
   Node-crypto feature detection.

   Landed on: ship libsodium-wrappers (and its own dependency,
   libsodium) as real, unmodified package files alongside the ~18kb
   bundle, via a new copy-cli-deps.mjs step, rather than fighting
   further to force single-file inlining. ~1.6MB total, still one tar
   stream over SSH, still the entire deploy step.

deploy-cli.sh now tars dist-bundle/ (bundle + node_modules) instead of
catting a single file, extracts to ~/.keep-cli/ on the target, and
symlinks ~/bin/keep to the entry point inside it.

Verified this time in conditions that actually match the failure: full
isolation (mktemp'd HOME and install dir, no ancestor package.json, no
adjacent node_modules from this repo) for both `node keep.mjs` and
direct shebang execution, plus the exact tar/extract cycle
deploy-cli.sh performs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:59:44 +02:00
Fredrik Johansson
74bee56c54 Add deploy-cli.sh: ship the CLI as a single bundled file, no git clone needed
The CLI only touches Node builtins and libsodium-wrappers (no native
addons like better-sqlite3, which the server needs but the CLI never
imports) -- confirmed by checking its actual import graph, not assumed.
That makes it a good candidate for a single-file esbuild bundle rather
than requiring a full git checkout + npm install on every machine that
needs to run `keep pull`.

deploy-cli.sh bundles src/cli (~18kb) and ships it straight to a host
over SSH -- HOST=user@vps ./deploy-cli.sh -- mirroring flit's
deploy-daemon.sh ssh-cat-chmod pattern rather than inventing a new
convention. No git, no npm install, no node_modules on the target at
all, just the one file plus a `node` runtime already there from
whatever else is deployed on that box.

Caught a real bug while building this: --banner:js="#!/usr/bin/env
node" duplicated the shebang esbuild already preserves from the source
file automatically, landing it on line 2 instead of line 1 -- Node
only special-cases a shebang on line 1, so the bundle threw a syntax
error on every invocation until the redundant banner flag was removed.

Verified the bundle for real, not just that it builds: ran `keep
identity init` / `identity show` against it directly and confirmed a
real Ed25519 keypair comes out the other end -- the specific thing
worth checking given libsodium-wrappers needed a createRequire
workaround for its own broken ESM packaging, and bundling could easily
have broken that differently.

Also documents both CLI-provisioning paths in INTEGRATION.md
(scripts/install-cli.sh for a machine you're fine cloning onto,
deploy-cli.sh for one you'd rather not) as a "getting keep onto a
machine" prerequisite section, ahead of the existing deploy-pattern
docs that all assume it's already there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:45:31 +02:00
Fredrik Johansson
b74564fd9b Add a real global keep CLI instead of npm run cli --
npm run cli -- <args> only works from inside this repo's directory,
which is awkward everywhere but especially on a deploy host running
this alongside a dozen other projects. scripts/install-cli.sh builds
the CLI and symlinks dist/cli/index.js onto PATH (~/.local/bin by
default, no root/global npm install needed) as a real `keep` command,
runnable from anywhere.

Symlinked rather than copied, so a future `git pull && npm run build`
is the entire upgrade story -- no need to re-run the install script
after the first time.

Verified: keep --help and keep identity show both work correctly from
unrelated directories (/tmp, $HOME) after running the install script,
confirming no hidden cwd dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:38:12 +02:00
11 changed files with 213 additions and 22 deletions

View File

@@ -1,6 +1,9 @@
PORT=3050
DATA_DIR=./data
# Image to run via docker compose, e.g. repo.example.com/owner/keep:latest
IMAGE=
# Gates recipient add/remove and revoke. Unset disables /api/admin/* (503)
# but push/pull/grant keep working for already-registered recipients.
ADMIN_PASSWORD=change-me

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
dist/
dist-bundle/
.env
data/

View File

@@ -31,6 +31,53 @@ A deploy identity should almost always be granted **read-only** access
needs to `pull`, and a read-only grant means a compromised deploy box
can't also rotate the vault or add itself more recipients.
## Getting the `keep` CLI onto a machine
Chicken-and-egg: every pattern below assumes `keep` is already a runnable
command on the machine doing the pulling. Two ways to get there, pick
based on whether you're fine with a git checkout existing on that
machine:
**A machine you're already developing on, or don't mind cloning onto**
(your laptop, a VPS you administer directly):
```bash
git clone <this repo>
cd keep
npm install
./scripts/install-cli.sh # builds, symlinks dist/cli/index.js onto PATH as `keep`
```
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 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
```
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
The common shape once more than one or two projects are on `keep`:

View File

@@ -91,22 +91,29 @@ cp .env.example .env
npm install
npm run dev:server
# 2. Each machine/person that needs access generates its own identity
npm run cli -- identity init
# 2. Build the CLI and link it onto PATH as a real `keep` command --
# no npm run cli --, no cd-ing into this repo from wherever you
# actually need it (a deploy directory on some VPS, for instance).
# Symlinked, not copied: a future `git pull && npm run build` here
# is the entire upgrade story, no need to re-run this.
./scripts/install-cli.sh
# 3. Each machine/person that needs access generates its own identity
keep identity init
# → prints a public key
# 3. An admin registers that public key as a recipient
KEEP_ADMIN_PASSWORD=... npm run cli -- recipient add --label "my-laptop" --pubkey <hex>
# 4. An admin registers that public key as a recipient
KEEP_ADMIN_PASSWORD=... keep recipient add --label "my-laptop" --pubkey <hex>
# → prints a recipient id
# 4. The machine that generated the identity records its assigned id
npm run cli -- identity set-id <recipient-id>
# 5. The machine that generated the identity records its assigned id
keep identity set-id <recipient-id>
# 5. Push a vault — the pusher is automatically its first recipient
npm run cli -- push myapp/production --file .env.production
# 6. Push a vault — the pusher is automatically its first recipient
keep push myapp/production --file .env.production
# 6. Anyone else with a grant can pull it, decrypted, ready to use
npm run cli -- pull myapp/production > .env
# 7. Anyone else with a grant can pull it, decrypted, ready to use
keep pull myapp/production > .env
```
## Granting, revoking, rotating

38
deploy-cli.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 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 "→ 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; safe to overwrite."

1
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"@types/express": "^4.17.21",
"@types/libsodium-wrappers": "^0.7.14",
"@types/node": "^22.0.0",
"esbuild": "^0.28.1",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}

View File

@@ -10,18 +10,20 @@
"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.mjs --external:node:* --external:libsodium-wrappers && node scripts/copy-cli-deps.mjs",
"start": "node --env-file=.env dist/server/index.js"
},
"dependencies": {
"express": "^4.19.2",
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"libsodium-wrappers": "^0.7.15"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/libsodium-wrappers": "^0.7.14",
"@types/node": "^22.0.0",
"esbuild": "^0.28.1",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}

32
scripts/copy-cli-deps.mjs Normal file
View File

@@ -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)');

View File

@@ -1,14 +1,29 @@
#!/usr/bin/env bash
# Pulls a project's env from keep, then brings its docker compose stack up.
# Pulls a project's env from keep (if it's bootstrapped onto keep), then
# brings its docker compose stack up.
#
# Usage:
# ./deploy.sh <project> [env]
#
# Expects, per project, a directory at $DEPLOY_ROOT/<project>/ containing
# a docker-compose.yml. Vault key pulled is "<project>/<env>" (env
# defaults to "production"). Requires a keep identity already registered
# and granted read access to that vault on this machine
# (see INTEGRATION.md).
# Expects a directory at $DEPLOY_ROOT/<project>/ containing a
# docker-compose.yml.
#
# The vault key is NOT assumed to match the deploy-directory name — repo
# names, deploy directory names, and vault keys are three independent
# strings in practice (a repo can be named one thing, its deploy
# directory another, entirely by history/accident). Resolution:
# 1. $dir/.keep-vault, if present — a one-time-per-project marker
# (`echo "<vault key>" > "$dir/.keep-vault"` when bootstrapping),
# one line, human-readable, cat-able to check what's configured.
# 2. Otherwise falls back to "<project>/<env>" — works when the vault
# key genuinely does match, and is also the graceful "not
# bootstrapped yet" case for a keep pull that's expected to fail.
#
# `keep pull` is always best-effort: a missing vault, a missing grant, or
# `keep` not being installed at all is never fatal here — it means "keep
# doesn't apply to this project (yet)," never "stop the deploy." Whatever
# .env already exists on disk (hand-copied, or from a previous keep pull)
# is what docker compose reads either way.
set -euo pipefail
project="${1:?usage: deploy.sh <project> [env]}"
@@ -21,5 +36,18 @@ if [ ! -d "$dir" ]; then
exit 1
fi
keep pull "$project/$env" --out "$dir/.env"
if [ -f "$dir/.keep-vault" ]; then
vault="$(cat "$dir/.keep-vault")"
else
vault="$project/$env"
fi
if command -v keep >/dev/null 2>&1 && keep pull "$vault" --out "$dir/.env.new" 2>/dev/null; then
mv "$dir/.env.new" "$dir/.env"
echo " ✓ pulled $vault"
else
rm -f "$dir/.env.new"
echo " · keep pull skipped or failed for $vault (using existing .env, if any)"
fi
(cd "$dir" && docker compose up -d)

23
scripts/install-cli.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# Builds keep and symlinks the CLI onto PATH as a real `keep` command --
# no `npm run cli --` from inside this repo, no npm global install (which
# needs root/write access to the system Node install on most hosts).
#
# Since this is a symlink to dist/cli/index.js rather than a copy, a
# future `git pull && npm run build` here is the entire upgrade story --
# no need to re-run this script.
set -eu
cd "$(dirname "$0")/.."
BIN_DIR="${KEEP_BIN_DIR:-$HOME/.local/bin}"
mkdir -p "$BIN_DIR"
npm run build
chmod +x dist/cli/index.js
ln -sf "$(pwd)/dist/cli/index.js" "$BIN_DIR/keep"
echo "keep CLI linked: $BIN_DIR/keep -> $(pwd)/dist/cli/index.js"
case ":$PATH:" in
*":$BIN_DIR:"*) echo "$BIN_DIR is already on PATH — try: keep --help" ;;
*) echo "warning: $BIN_DIR is not on PATH — add it to your shell profile" ;;
esac

View File

@@ -1,4 +1,4 @@
import { adminFetch, expectOk } from '../signedFetch.js';
import { adminFetch, expectOk, serverUrl } from '../signedFetch.js';
export async function recipientAdd(label: string, publicKey: string): Promise<void> {
const data = await expectOk(await adminFetch('POST', '/api/admin/recipients', { label, publicKey }));
@@ -20,9 +20,18 @@ export async function recipientRemove(recipientId: string): Promise<void> {
}
export async function adminOverview(): Promise<void> {
// Printed before the fetch, not after -- which server this hit is exactly
// the thing that's ambiguous from the rest of the output alone (no vault
// or recipient here says "production" or "dev"), and defaults silently
// to localhost:3050 if KEEP_SERVER_URL isn't set. Printing it first means
// it still shows up even when the fetch itself fails ("error: fetch
// failed") -- the exact case where knowing the target mattered most and
// previously showed nothing at all.
console.log(`server: ${serverUrl()}`);
const data = await expectOk(await adminFetch('GET', '/api/admin/overview'));
console.log('recipients:');
console.log('\nrecipients:');
if (!data.recipients.length) console.log(' none yet.');
for (const r of data.recipients) {
console.log(` ${r.recipientId} ${r.label}`);