Commit Graph

26 Commits

Author SHA1 Message Date
Benjamin Diedrichsen da9df57e11 [keyman] thread the configured keys and tmp directories through encrypt/decrypt
encryptKeys and decryptKeys each took `vaultDir` and rebuilt `<vault>/keys`
and `<vault>/tmp` from it, so `keysDir` and `tmpDir` in .keymanrc.json were
honoured by main and list and silently ignored by the two operations that
write. main was also passing vaultRoot where encrypt expected the keys
directory, which put encrypted keys one level above where list looks for
them: with any config at all, a key encrypted a second ago was invisible.

Both now take keysDir and tmpDir explicitly. The decrypt location prompt
names the real directories instead of the hardcoded `vault/tmp` and
`~/.ssh`, which meant its labels were also its values — hence LOCAL_MODE.

tests/vault-layout.test.ts is the regression: encrypt then list, driven
through keyman() with only age and the prompts mocked, against a config
using keysDir `encrypted` and tmpDir `plain`. Every unit suite passed
through this bug because each was told which directory to use; the seam
between them was untested. Verified it fails when main is reverted to pass
vaultRoot.
2026-07-30 14:45:27 +02:00
Benjamin Diedrichsen 653d348ecc [keyman] phase 4: decrypt stops destroying keys and stops the 0644 window
Verified before the fix: `age -d -o <existing>` overwrites without a word
("PRECIOUS EXISTING KEY" became "secret"), and the old `cp` for the public
key did the same. Decrypting a vault entry on top of a newer working key
in ~/.ssh destroyed it with no prompt, no backup and no mention. It is the
only finding in the audit that loses data the user never asked to touch.

Every collision — private and public, both output modes — is now settled
before anything is written, so the questions are asked about files that
still exist. Default is to keep what is there.

cp and chmod are gone. Three spawns per key become one, it works where
those binaries do not, and the chmod happens in-process immediately after
age returns: age creates its output 0644 regardless of umask, so a
plaintext private key was world-readable for the length of two spawns and
stayed 0644 whenever the chmod itself failed. ~/.ssh is created 0700 when
absent rather than assumed.

decrypt.test.ts stops asserting on which binaries were spawned. The age
stand-in now writes its -o file at 0644 the way age does, and the tests
assert the bytes and the mode on disk — the outcome rather than the
mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:39:30 +02:00
Benjamin Diedrichsen 77bd43818f [keyman] phase 3: derive the age recipient, and survive not having one
main.ts asserted the recipient non-null twice — extractAgePublicKey(...)!
— and the type already said null was possible. With no age.key the vault
encrypted to the string "null": execa stringifies it, age exits 1, and on
the generate path that happens *after* ssh-keygen has written a plaintext
private key into tmpDir, so the user is told the operation failed and left
with a key on disk. Now the recipient is resolved once, remembered on
success, and a null prints the remedy (age-keygen -o <path>) and returns
to the menu. list, copy and decrypt still work without one.

extractAgePublicKey now derives the public key with `age-keygen -y`
instead of scraping the `# public key:` comment. The comment is ordinary
text nothing re-checks; verified that rewriting it does not change what
-y reports, so a stale or forged comment silently encrypted the vault to
a recipient nobody holds the private half of.

The comment survives as a fallback for a machine with no age-keygen,
behind a warning that it is unverified — but not when age-keygen runs and
refuses the file. That means age cannot read the identity, and trusting
the comment there would encrypt to a recipient the vault could never
decrypt with.

runTool throws ToolNotFoundError for ENOENT so the two cases can be told
apart. Its own tests move to tool.test.ts, which keeps real processes;
utils.test.ts mocks execa, since the gate cannot require age installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:37:26 +02:00
Benjamin Diedrichsen 11c323b715 [keyman] phase 2: guard the directories nothing creates
encrypt read ~/.ssh and the tmp directory, and decrypt read <vault>/keys,
with no existsSync between them. main.ts created vaultRoot and tmpDir but
never keysDir, so decrypt on a fresh vault threw ENOENT instead of
printing the "no encrypted keys" message it already had — the message was
unreachable until something else created the directory.

Both functions now fall through to their warning. main.ts creates all
three directories, 0700: the vault holds the age identity and tmp holds
plaintext private keys.

age spawns go through runTool, which separates "not installed" (ENOENT,
whose message is `spawn age ENOENT`) from "age refused" (whose reason is
on stderr and nowhere in the thrown message). Tested against real
processes, not a mocked execa — the shape of the failure is the point.

list.ts kept statSync rather than switching to withFileTypes as planned:
withFileTypes reports a symlinked key directory as a link and would have
silently dropped it. `throwIfNoEntry: false` fixes the dangling-symlink
throw and keeps following the good ones. Both cases now have a test.

Also deletes the three debug logs (encrypt.ts printed both key arrays,
decrypt.ts printed every candidate path from inside a filter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:33:26 +02:00
Benjamin Diedrichsen 8fa0cfa271 [keyman] audit + remediation plan, and phase 1: CLI error boundary
docs/AUDIT.md and docs/PLAN.md record the review and the ten phases it
turns into. This commit is phase 1.

keyman.cli.ts fell through to an interactive session for --help, ignored
unknown flags, and called keyman() unawaited — so Ctrl-C at any prompt,
and any rejection inside the menu loop, became an unhandled-rejection
stack trace. flagValue() also read `--channel --force` as the channel
"--force", which reached the dist-tag lookup as a key that cannot exist
and reported an unreachable registry.

New keyman.args.ts owns the parse: both --flag value and --flag=value, a
UsageError for an unknown flag or command, --channel validated against
the three real channels, and self-update-only flags rejected rather than
silently ignored. It is a separate module because cli.ts is excluded from
coverage and these are rules, not wiring. --help short-circuits before
tokenising, so it answers a line the parser would otherwise reject.

Usage errors exit 2; ExitPromptError is caught by name (@inquirer/core is
transitive here and does not resolve) and prints Goodbye.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:17:52 +02:00
Benjamin Diedrichsen 75983ab3b1 make stable vagrant machine host key for better dx on vagrant vm spawning 2026-07-29 14:25:34 +02:00
Benjamin Diedrichsen 4c0fe528dc fixing documentation
Publish snapshot / snapshot (push) Successful in 1m2s
2026-07-29 13:21:04 +02:00
Benjamin Diedrichsen 7e703c93b1 streamline package naming 2026-07-29 13:07:34 +02:00
Benjamin Diedrichsen 1ba1c2a32a [chore] commit id display on version
Publish snapshot / snapshot (push) Successful in 1m26s
2026-07-29 13:00:10 +02:00
Benjamin Diedrichsen ea08e76a2f [feat] nopy update command and auto-update pipeline 2026-07-29 11:16:52 +02:00
Benjamin Diedrichsen 6ecb2c366f [refactor] moving cubes into own package"
Publish snapshot / snapshot (push) Successful in 1m2s
[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
2026-07-28 12:18:10 +02:00
Benjamin Diedrichsen ac050c4459 [wip] cubes packaging and distribution via registry
Publish snapshot / snapshot (push) Successful in 1m21s
2026-07-28 09:37:42 +02:00
Benjamin Diedrichsen 30d93dddc5 implementing --use-defaults 2026-07-28 09:27:05 +02:00
Benjamin Diedrichsen 5ed68c0065 improving documentation consistency. auditing documentation drifts. planning cube packaging 2026-07-27 21:58:54 +02:00
Benjamin Diedrichsen fcc181700e test release nopy-alpha5
Release / release (push) Successful in 1m0s
nopy-v1.0.0-alpha5
2026-07-27 17:07:18 +02:00
Benjamin Diedrichsen a4ce4879a4 test release nopy-alpha4
Release / release (push) Failing after 48s
nopy-v1.0.0-alpha4
2026-07-27 17:05:29 +02:00
Benjamin Diedrichsen 95aed2867d test release nopy-alpha3
Release / release (push) Failing after 38s
nopy-v1.0.0-alpha3
2026-07-27 16:56:51 +02:00
Benjamin Diedrichsen ca14a84863 test release nopy-alpha
Publish snapshot / snapshot (push) Successful in 58s
Release / release (push) Failing after 6s
nopy-v-1.0.0-alpha2
2026-07-27 16:54:32 +02:00
Benjamin Diedrichsen c04b458041 test release nopy-alpha
Publish snapshot / snapshot (push) Has been skipped
2026-07-27 16:53:35 +02:00
Benjamin Diedrichsen a72216c0cc [release]
Publish snapshot / snapshot (push) Successful in 57s
2026-07-27 16:46:50 +02:00
Benjamin Diedrichsen b384ec584f [release]
Publish snapshot / snapshot (push) Has been skipped
2026-07-27 16:45:37 +02:00
Benjamin Diedrichsen 04f69c8a6a [snapshot]
Publish snapshot / snapshot (push) Successful in 57s
2026-07-27 16:28:32 +02:00
Benjamin Diedrichsen 2c1d27b4fb workflow update
Publish snapshot / snapshot (push) Failing after 37s
2026-07-27 16:21:00 +02:00
Benjamin Diedrichsen 587ff2cf47 Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s
Publishing infrastructure
- Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml
  (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs)
- Tag-driven releases as <package-dir>-v<version>; the manifest stays the
  source of truth and release.yml refuses to run if tag and manifest disagree
- Every publish is idempotent: each step checks the registry first, so a run
  that fails on the second registry can simply be re-run
- Hard coverage gate (85% branches) shared by CI, the pre-push hook and local
  runs, since the thresholds live in vitest.config.ts rather than a CI flag
- README.PUBLISH.md documents the whole mechanism

Toolchain
- TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs
- Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0
- Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9,
  with enquirer's AutoComplete; the CubeSelection contract is unchanged
- Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema

Repo hygiene
- Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json
- Drop package-lock.json in favour of pnpm-lock.yaml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:17:14 +02:00
Benjamin Diedrichsen 736c01216a initial transfer 2026-07-27 13:09:00 +02:00
benjamie 9f25d48dc2 Initial commit 2026-07-27 13:01:07 +02:00