Compare commits

...

9 Commits

Author SHA1 Message Date
Fredrik Johansson
a640bc1faf keep pull: fill saved template with real values by default
All checks were successful
Docker / build-and-push (push) Successful in 1m54s
A bare key/value dump throws away the comments/structure a template
preserves; now a plain pull reconstitutes the real .env from the
template when the vault has one, falling back to the flat dump
otherwise. --template still returns the redacted structure alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 16:52:02 +02:00
Fredrik Johansson
04b48e05e4 Document env templating in the integration guide
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
2026-07-16 10:59:44 +02:00
Fredrik Johansson
8dcb675ad2 Save a redacted .env template alongside pushed secrets
All checks were successful
Docker / build-and-push (push) Successful in 1m51s
keep push now saves a comment/structure-preserving, value-redacted copy
of the source .env next to the encrypted secrets, so context like "why
this exists" or "get this from X" survives ingest instead of being
silently dropped. keep pull --template retrieves it. Old vaults pushed
before this existed keep working (flat payload, no template) and fail
with a clear error rather than crashing if --template is requested.
2026-07-15 20:41:45 +02:00
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
Fredrik Johansson
442c3b7cf5 Add fleet setup template and a one-shot project bootstrap script
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
SETUP.local.md.example is a fill-in-the-blanks runbook for rolling
keep out across real machines and projects (gitignored once copied to
SETUP.local.md — real vault keys/recipient ids/hostnames are fleet
topology, not something to commit). bootstrap-project.sh wraps push +
grant --read-only for one or more recipients into a single call for
onboarding a new project vault.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:21:55 +02:00
16 changed files with 426 additions and 30 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

6
.gitignore vendored
View File

@@ -1,4 +1,10 @@
node_modules/
dist/
dist-bundle/
.env
data/
# Personal rollout notes — real vault keys, recipient ids, VPS hostnames.
# Not secrets themselves, but fleet topology that doesn't belong in a
# public repo. See SETUP.local.md.example for the template.
SETUP.local.md

View File

@@ -31,6 +31,60 @@ 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.
When you do the first `keep push` for a project, push the *real*,
hand-maintained `.env` rather than a stripped-down one — any `# get this
from X` / `# rotate quarterly` comments in it get preserved (values
redacted) as a template alongside the secrets, retrievable later with
`keep pull <vault> --template`. That's the one moment this context is
available at all; a `.env` reconstructed later from memory won't have it.
## 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,24 +91,53 @@ 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
```
## Templates
`keep push` also saves a redacted copy of the source `.env` alongside
the secrets — same keys, comments, and blank lines, values stripped.
Comments explaining *where a value comes from* or *why it exists* are
exactly the kind of context a bare key/value pair throws away, so this
keeps it without keeping the secret itself around unencrypted anywhere.
A plain `keep pull` uses that template by default: if the vault has one,
you get real values filled back into it, comments and all — not just a
bare key/value dump. Ask for the redacted structure itself with:
```bash
keep pull myapp/production --template > .env.example
```
Vaults pushed before this existed just don't have a saved template —
`keep pull` on one of those falls back to a plain key/value dump, and
`keep pull --template` fails with a clear error instead of silently
returning nothing. No migration needed; push again and the template
gets backfilled from whatever `.env` you push next.
## Granting, revoking, rotating
```bash

80
SETUP.local.md.example Normal file
View File

@@ -0,0 +1,80 @@
# keep fleet setup — personal notes (copy to SETUP.local.md, gitignored)
Fill this in as you actually run the commands. It's your own record of
what recipient id belongs to which machine and which vault backs which
project — none of this is secret by itself (a recipient id or a vault
key doesn't grant access without the matching private key), but it's
real fleet topology that doesn't belong in a public repo.
## 0. Server
- [ ] Deployed at: `___________________` (e.g. https://keep.example.internal)
- [ ] `ADMIN_PASSWORD` stored in: `___________________` (password manager entry, not here)
- [ ] `KEEP_SERVER_URL` exported in shell profile on every machine below
## 1. Identities
One row per machine. `keep identity init` prints the public key; an
admin runs `keep recipient add`, which prints back a recipient id; that
id gets recorded locally with `keep identity set-id <id>`.
| Machine | Recipient id | Grant type (per vault, see §3) |
|---|---|---|
| this laptop | `___________________` | rw on everything it pushes |
| vps-1 (`___________________` hostname) | `___________________` | r- (read-only) |
| vps-2 | `___________________` | r- (read-only) |
Commands, run once per machine:
```bash
keep identity init # on the machine itself
KEEP_ADMIN_PASSWORD=... keep recipient add --label "<name>" --pubkey <hex> # from an admin machine
keep identity set-id <recipient-id> # back on the machine itself
```
## 2. Projects → vaults
One vault per project+environment. Vault key is a free-form string —
it does NOT need to match the repo name or the deploy directory name
(see IMPLEMENTATION.md — these are three independent strings in this
fleet on purpose).
| Project | Deploy dir | Vault key | `.keep-vault` written? |
|---|---|---|---|
| myapp | `/opt/docker/myapp` | `myapp/production` | [ ] |
| otherapp | `/opt/docker/otherapp` | `otherapp/production` | [ ] |
Bootstrap a project:
```bash
keep push myapp/production --file .env.production # from the laptop, becomes rw grantee
keep grant myapp/production <vps-1-recipient-id> --read-only
echo "myapp/production" > /opt/docker/myapp/.keep-vault # on vps-1, next to its docker-compose.yml
```
Or use `scripts/bootstrap-project.sh` (see below) to do the push+grant
step in one call for multiple VPS recipients at once.
## 3. CI announce (optional, per project)
Only needed if you want `keep watch` on a VPS to skip waiting for the
next scheduled sweep. Add to that project's own
`.gitea/workflows/docker.yml`, after the build-and-push step:
```yaml
- name: announce to keep
continue-on-error: true
run: keep announce myapp/production --tag ${{ steps.meta.outputs.short_sha }}
```
The vault key here must be the literal string from §2's table — not
derived from the repo name.
## 4. Sanity check
```bash
KEEP_ADMIN_PASSWORD=... keep overview
```
Confirm every project vault shows up with exactly the grants you
expect — laptop `rw`, each deploying VPS `r-`, nothing else.

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"
}

31
scripts/bootstrap-project.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Pushes a project's first env file and grants read-only access to one
# or more already-registered recipients (typically the VPS boxes that
# will deploy it), in one call.
#
# Usage:
# ./bootstrap-project.sh <vault> <env-file> <recipient-id> [recipient-id...]
#
# The pusher (this machine's identity) becomes the vault's first,
# read-write recipient automatically — see IMPLEMENTATION.md. Every
# recipient-id argument here is granted read-only; run `keep grant`
# by hand afterward for anyone who needs write access too.
set -euo pipefail
vault="${1:?usage: bootstrap-project.sh <vault> <env-file> <recipient-id> [recipient-id...]}"
file="${2:?usage: bootstrap-project.sh <vault> <env-file> <recipient-id> [recipient-id...]}"
shift 2
if [ "$#" -eq 0 ]; then
echo "error: at least one recipient id is required" >&2
exit 1
fi
keep push "$vault" --file "$file"
for recipient in "$@"; do
keep grant "$vault" "$recipient" --read-only
done
echo ""
echo "done. verify with: KEEP_ADMIN_PASSWORD=... keep overview"

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}`);

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs';
import { requireIdentityAndRecipientId } from '../identityStore.js';
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
import { parseEnv, serializeEnv } from '../envFormat.js';
import { parseEnv, serializeEnv, buildEnvTemplate, fillEnvTemplate } from '../envFormat.js';
import {
ready,
Identity,
@@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
const { identity, recipientId } = await requireIdentityAndRecipientId();
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
const values = parseEnv(fs.readFileSync(file, 'utf8'));
const payload = enc.fromUtf8(JSON.stringify(values));
const raw = fs.readFileSync(file, 'utf8');
const values = parseEnv(raw);
const template = buildEnvTemplate(raw);
// __keepEnvelope marks the new wrapped shape so vaultPull can tell it
// apart from an old vault pushed before templates existed, which is
// just the flat { KEY: value } object with no wrapper at all.
const payload = enc.fromUtf8(JSON.stringify({ __keepEnvelope: true, values, template }));
const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`));
@@ -55,7 +60,7 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}${purge ? ' (purged previous version)' : ''}.`);
}
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise<void> {
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean, wantTemplate: boolean): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
@@ -66,9 +71,27 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil
if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale');
const plaintext = secretboxDecrypt(symmetricKey, enc.fromBase64(data.nonce), enc.fromBase64(data.ciphertext));
const values = JSON.parse(enc.toUtf8(plaintext)) as Record<string, string>;
const parsed = JSON.parse(enc.toUtf8(plaintext)) as Record<string, unknown>;
const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
// Vaults pushed before templates existed are just the flat { KEY: value }
// object with no wrapper — __keepEnvelope tells the two shapes apart.
const values = (parsed.__keepEnvelope ? parsed.values : parsed) as Record<string, string>;
const template = parsed.__keepEnvelope ? (parsed.template as string | undefined) : undefined;
if (wantTemplate) {
if (template === undefined) throw new Error(`vault '${vaultKey}' has no saved template (pushed before template support, or the source file had no comments to preserve)`);
if (outFile) {
fs.writeFileSync(outFile, template);
console.error(`wrote template to ${outFile}${previous ? ' (previous version)' : ''}`);
} else {
process.stdout.write(template);
}
return;
}
const out = format === 'json'
? JSON.stringify(values, null, 2)
: template !== undefined ? fillEnvTemplate(template, values) : serializeEnv(values);
if (outFile) {
fs.writeFileSync(outFile, out);
console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`);

View File

@@ -20,3 +20,38 @@ export function serializeEnv(values: Record<string, string>): string {
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
.join('\n') + '\n';
}
// Redacts values while keeping comments, blank lines, and key order intact
// — the whole point is to preserve the "why"/"where to get this" context
// that plain parseEnv() throws away, without keeping the actual secret
// around in the template.
// Inverse of buildEnvTemplate: substitutes real values back into a
// template's `KEY=` lines while leaving comments/blank lines/order intact.
export function fillEnvTemplate(template: string, values: Record<string, string>): string {
return template
.split('\n')
.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = line.indexOf('=');
if (eq === -1) return line;
const key = line.slice(0, eq);
const value = values[key.trim()] ?? '';
return `${key}=${/[\s#"']/.test(value) ? JSON.stringify(value) : value}`;
})
.join('\n');
}
export function buildEnvTemplate(text: string): string {
return text
.split('\n')
.map(rawLine => {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = line.indexOf('=');
if (eq === -1) return line;
return `${line.slice(0, eq)}=`;
})
.join('\n');
}

View File

@@ -31,7 +31,7 @@ async function main(): Promise<void> {
if (cmd === 'overview') return await adminOverview();
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'));
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'), boolFlag(rest, 'template'));
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub);
@@ -54,7 +54,9 @@ function printUsage(): void {
keep identity set-id <recipient-id>
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
keep pull <vault> [--format env|json] [--out <path>] [--previous]
keep pull <vault> [--format env|json] [--out <path>] [--previous] [--template]
(fills the saved template with real values by default, if one exists;
--template: comments/structure with values redacted, not the secrets)
keep grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault>