diff --git a/.gitignore b/.gitignore index 63ea713..34be5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ coverage/ .claude/ lastchat.txt AGENTS.md +EDITORS-REPORT +EDITORS-REPORT.* diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f2f86..ee32eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.3.3] — Unreleased ### Added + +- **`git cas agent recipient ...`** — added machine-facing recipient inspection and mutation commands so Relay can list recipients and perform add/remove flows through structured protocol data instead of human CLI text. +- **`git cas agent rotate`** — added a machine-facing rotation flow so Relay can rotate recipient keys by slug or detached tree OID and expose the resulting tree and vault side effects explicitly. +- **`git cas agent vault rotate`** — added a machine-facing vault passphrase rotation flow so Relay can rotate encrypted vault state with explicit commit, KDF, and rotated/skipped-entry results. +- **`git cas agent vault init|remove`** — added machine-facing vault lifecycle commands so Relay can initialize encrypted or plaintext vaults and remove entries without scraping human CLI output. +- **Workflow model** — added [WORKFLOW.md](./WORKFLOW.md), explicit legends/backlog/invariants directories, and a cycle-first planning model for fresh work. - **Review automation baseline** — added `.github/CODEOWNERS` with repo-wide ownership for `@git-stunts`. - **Release runbook** — added `docs/RELEASE.md` and linked it from `CONTRIBUTING.md` as the canonical patch-release workflow. - **`pnpm release:verify`** — new maintainer-facing release helper runs the full release checklist, captures observed test counts, and prints a Markdown summary that can be pasted into release notes or changelog prep. @@ -16,11 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Deterministic property-based envelope coverage** — added a `fast-check`-backed property suite for envelope-encrypted store/restore round-trips and tamper rejection across empty, boundary-adjacent, and multi-chunk payload sizes. ### Changed + - **GitHub Actions runtime maintenance** — CI and release workflows now run on `actions/checkout@v6` and `actions/setup-node@v6`, clearing the Node 20 deprecation warnings from GitHub-hosted runners. - **Ubuntu-based Docker test stages** — the local/CI Node, Bun, and Deno test images now build on `ubuntu:24.04`, copying runtime binaries from the official upstream images instead of inheriting Debian-based runtime images directly, and the final test commands now run as an unprivileged `gitstunts` user. - **Test conventions expanded** — `test/CONVENTIONS.md` now documents Git tree filename ordering, Docker-only integration policy, pinned integration `fileParallelism: false`, and direct-argv subprocess helpers. ### Fixed + +- **CLI credential edge cases** — `store --recipient` now ignores ambient `GIT_CAS_PASSPHRASE` state when no explicit vault passphrase flag/file was provided, store/restore/init now reject ambiguous explicit credential combinations consistently, `vault init --algorithm` no longer silently falls back to plaintext without a passphrase source, and `vault rotate` now rejects whitespace-only old/new passphrase inputs instead of treating them as valid credentials. - **Bun blob writes in Git persistence** — `GitPersistenceAdapter.writeBlob()` now hashes temp files instead of piping large buffers through `git hash-object --stdin` under Bun, avoiding unhandled `EPIPE` failures during real Git-backed stores. - **Release verification runner failures** — `runReleaseVerify()` now converts thrown step-runner errors into structured step failures with a `ReleaseVerifyError` summary instead of letting raw exceptions escape. - **Machine-readable release verification** — `pnpm release:verify --json` now emits structured JSON on both success and failure paths, making CI automation and release-note tooling consume the same verification source of truth. @@ -29,15 +38,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.3.2] — 2026-03-15 ### Changed + - **Vitest workspace split** — unit, integration, and benchmark suites now live in explicit workspace projects so the integration suite always runs with `fileParallelism: false`, regardless of the exact CLI invocation shape. - **Status semantics** — `STATUS.md` now distinguishes the last released version (`v5.3.1`) from the current branch version (`v5.3.2`). ### Fixed + - **CLI version drift** — `bin/git-cas.js` now reads the package version instead of carrying a stale hardcoded literal, so `git-cas --version` tracks the in-repo release line correctly. ## [5.3.1] — 2026-03-15 ### Fixed + - **Repeated chunk tree emission** — `createTree()` and `_createMerkleTree()` now emit one chunk blob tree entry per unique digest, preserving first-seen order at write time while leaving the manifest unchanged as the authoritative ordered index of chunk occurrences. - **Invalid Git trees for repetitive content** — repetitive files no longer produce duplicate tree entry names, so emitted trees pass `git fsck --full` without `duplicateEntries` failures. - **Regression coverage for tree reachability** — added unit tests for first-seen dedupe behavior and integration tests that store repetitive content, verify restore correctness, and assert clean `git fsck` results on a real Git repository. @@ -45,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.3.0] — 2026-03-08 ### Added + - **Vault rotate passphrase-file support** — `vault rotate` now accepts `--old-passphrase-file` and `--new-passphrase-file` flags, bringing it to parity with the store/restore passphrase-file support. - **CLI store flags** — `--gzip`, `--strategy `, `--chunk-size `, `--concurrency `, `--codec `, `--merkle-threshold `, `--target-chunk-size `, `--min-chunk-size `, `--max-chunk-size `. All library-level chunking, compression, codec, and concurrency options are now accessible from the CLI. - **CLI restore flags** — `--concurrency `, `--max-restore-buffer `. Parallel I/O and restore buffer limit now configurable from CLI. @@ -63,6 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **16.7 — Lifecycle method naming** — Added `inspectAsset()` (replaces `deleteAsset()`) and `collectReferencedChunks()` (replaces `findOrphanedChunks()`) as canonical names on both `CasService` and the facade. Old names are preserved as deprecated aliases that emit observability warnings. Type definitions updated with `@deprecated` JSDoc. ### Changed + - **`runAction` injectable delay** — `runAction()` now accepts an optional `{ delay }` dependency, replacing the hardcoded `setTimeout` call. Tests inject a spy instead of using `vi.useFakeTimers()`, making INTEGRITY_ERROR rate-limit tests deterministic across Node, Bun, and Deno. - **Test conventions** — Added `test/CONVENTIONS.md` documenting rules for deterministic, cross-runtime tests: inject time dependencies, use `chmod()` instead of `writeFile({ mode })`, avoid global state patching. - **VaultService test observability wiring** — `VaultService.test.js` now passes a `mockObservability()` port to all tests instead of relying on the silent no-op default. `rotateVaultPassphrase.test.js` now passes `SilentObserver` explicitly. If observability wiring breaks, the test suite will catch it. @@ -73,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **16.4 — FixedChunker pre-allocated buffer** — Replaced `Buffer.concat()` loop with a pre-allocated `Buffer.allocUnsafe(chunkSize)` working buffer, eliminating O(n²) copies for many small input buffers. Matches the allocation strategy used by `CdcChunker`. ### Fixed + - **Post-decompression size guard** — `_restoreBuffered` now enforces `maxRestoreBufferSize` after decompression, not just before. Compressed payloads that inflate beyond the configured limit now throw `RESTORE_TOO_LARGE` instead of silently allocating unbounded memory. - **CLI passphrase prompt deferral** — `resolveEncryptionKey` now checks vault metadata before calling `resolvePassphrase`, avoiding unnecessary TTY prompts for unencrypted vaults. Store action recipient-conflict check inspects flags/env without consuming stdin. - **CRLF passphrase normalization** — `readPassphraseFile` now strips trailing `\r\n` (Windows line endings) in addition to `\n`, preventing passphrase mismatches from Windows-edited files. @@ -95,6 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.2.4] — Prism polish (2026-03-03) ### Fixed + - **`CryptoPortBase.sha256()` type** — `index.d.ts` declaration corrected from `string | Promise` to `Promise`, matching the async implementation since v5.2.3. - **`keyLength` passthrough** — `KeyResolver.#resolveKeyFromPassphrase` and `deriveKekFromKdf` now forward `kdf.keyLength` to `deriveKey()`, fixing a latent bug for vaults configured with non-default key lengths. - **Deno test compatibility** — `createCryptoAdapter.test.js` no longer crashes on Deno by guarding immutable `globalThis.Deno` restoration with try/catch and skipping Node-only tests on non-Node runtimes. @@ -103,6 +119,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Vestigial `lastchat.txt`** removed from `jsr.json` exclude list. ### Changed + - **`keyResolver` is now private** — `CasService.keyResolver` changed to `#keyResolver`, preventing external access to an internal implementation detail. - **`VaultPassphraseRotator.js` → `rotateVaultPassphrase.js`** — renamed to follow camelCase convention for files that export a function (PascalCase is reserved for classes). - **`resolveChunker` validation** — `chunkSize` now validated as a finite positive number before constructing `FixedChunker`; invalid values fall through to CasService default. @@ -114,6 +131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.2.3] — Prism refactor (2026-03-03) ### Changed + - **Async `sha256()` across all adapters** — `NodeCryptoAdapter.sha256()` now returns `Promise` (was sync `string`), matching Bun and Web adapters. Fixes Liskov Substitution violation; all callers already `await`. `CryptoPort` JSDoc and `CasService.d.ts` updated to `Promise`. - **Extract `KeyResolver`** — ~170 lines of key resolution logic (`wrapDek`, `unwrapDek`, `resolveForDecryption`, `resolveForStore`, `resolveRecipients`, `resolveKeyForRecipients`, passphrase derivation, mutual-exclusion validation) extracted from `CasService` into `src/domain/services/KeyResolver.js`. CasService delegates via `this.keyResolver`. No public API changes. 24 new unit tests. - **Move `createCryptoAdapter`** — runtime crypto detection moved from `index.js` to `src/infrastructure/adapters/createCryptoAdapter.js`; test helper now delegates instead of duplicating. @@ -130,12 +148,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.2.2] — JSDoc total coverage (2026-02-28) ### Added + - `tsconfig.checkjs.json` — strict `checkJs` configuration; `tsc --noEmit` passes with zero errors. - `src/types/ambient.d.ts` — ambient type declarations for `@git-stunts/plumbing` and `bun` modules. - `@types/node` dev dependency for typecheck support. - JSDoc `@typedef` types: `EncryptionMeta`, `KdfParamSet`, `DeriveKeyParams` (CryptoPort); `VaultMetadata`, `VaultState`, `VaultEncryptionMeta` (VaultService). ### Changed + - Every exported and internal function, class method, and callback across all 32 source files now has complete JSDoc `@param`/`@returns` annotations. - CryptoPort return types widened to `string | Promise` (sha256), `Buffer | Uint8Array` (randomBytes), sync-or-async for encrypt/decrypt — accurately reflecting adapter implementations. - Port `@param` names corrected to match underscore-prefixed abstract parameters (fixes TS8024). @@ -145,19 +165,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.2.1] — Carousel polish (2026-02-28) ### Added + - CLI reference in `docs/API.md` for `git cas rotate` and `git cas vault rotate` flags. ### Changed + - Rotation helpers in `CasService` use native `#private` methods, matching the facade's style. - `VAULT_CONFLICT` and `VAULT_METADATA_INVALID` error code docs now list `rotateVaultPassphrase()`. ### Fixed + - `rotateVaultPassphrase` now honours `kdfOptions.algorithm` instead of silently using the old algorithm. - Rotation integration test no longer flaps under CI load (reduced test-only KDF iterations). ## [5.2.0] — Carousel (2026-02-28) ### Added + - **Key rotation without re-encrypting data** — `CasService.rotateKey()` re-wraps the DEK with a new KEK, leaving data blobs untouched. Enables key compromise response without re-storing assets. - **`keyVersion` tracking** — manifest-level and per-recipient `keyVersion` counters track rotation history for audit compliance. Optional field, backward-compatible with existing manifests. - **`git cas rotate` CLI command** — rotate a recipient's key via `--slug` (vault round-trip) or `--oid` (manifest-only). Supports `--label` for targeted single-recipient rotation. @@ -169,6 +193,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.1.0] — Locksmith (2026-02-28) ### Added + - **Envelope encryption (DEK/KEK)** — multi-recipient model where a random DEK encrypts content and per-recipient KEKs wrap the DEK. Recipients can be added/removed without re-encrypting data. - **`RecipientSchema`** — Zod schema for validating recipient entries in manifests. - **`recipients` field on `EncryptionSchema`** — optional array of `{ label, wrappedDek, nonce, tag }` entries. @@ -179,6 +204,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 48 new unit tests covering envelope store/restore, recipient management, edge cases, and fuzz round-trips. ### Fixed + - **`_wrapDek` / `_unwrapDek` missing `await`** — these called async `encryptBuffer()` / `decryptBuffer()` without `await`, silently producing garbage on Bun/Deno runtimes where crypto is async. - **`--recipient` + `--vault-passphrase` not guarded** — CLI now rejects combining `--recipient` with `--key-file` or `--vault-passphrase`. - **Dead `_resolveEncryptionKey` method removed** — superseded by `_resolveDecryptionKey` but left behind. @@ -194,10 +220,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.0.0] — Hydra (2026-02-28) ### Breaking Changes + - **`CasService` constructor accepts `chunker` port** — a new optional `ChunkingPort` parameter controls chunking strategy. Existing code that does not pass `chunker` is unaffected (defaults to `FixedChunker`). - **Major version bump** — new hexagonal port (`ChunkingPort`) and manifest schema extension warrant a semver-major release for downstream tooling awareness. ### Added + - **Content-defined chunking (CDC)** — Buzhash rolling-hash engine with configurable `minChunkSize` (64 KiB), `maxChunkSize` (1 MiB), and `targetChunkSize` (256 KiB). CDC limits the dedup blast radius to 1–2 chunks on incremental edits vs. total invalidation with fixed-size chunking. Benchmarked at 265 MB/s and 98.4% chunk reuse on small edits. - **`ChunkingPort`** — new hexagonal port (`src/ports/ChunkingPort.js`) with `async *chunk(source)`, `strategy`, and `params`. Abstracts chunking behind a pluggable interface. - **`FixedChunker`** — adapter wrapping existing fixed-size buffer slicing behind `ChunkingPort`. @@ -210,12 +238,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 90 new unit tests (709 total). ### Changed + - `CasService._chunkAndStore()` refactored to delegate to `ChunkingPort` instead of inline buffer slicing. - `ChunkingPort`, `FixedChunker`, `CdcChunker` exported from the main entry point. ## [4.0.1] — M8 Spit Shine + M9 Cockpit (2026-02-28) ### Added + - **`git cas verify`** command — verify stored asset integrity from the CLI (checks blob hashes; no key needed). - **`--json` global flag** — structured JSON output for all commands (`store`, `restore`, `verify`, `inspect`, `vault list/init/remove/info/history`). - **`runAction` error handler** (`bin/actions.js`) — centralized `try`/`catch` with CasError code display and actionable hints for 5 common errors. @@ -227,6 +257,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `WebCryptoAdapter.finalize()` guard — throws `STREAM_NOT_CONSUMED` if called before encrypt stream is fully consumed. ### Fixed + - `verify` command uses `process.exitCode = 1` instead of `process.exit(1)` to allow stdout to drain on pipes. - `runAction` uses `process.exitCode = 1` for consistent exit behavior across all commands. - `vault info --json --encryption` now includes encryption metadata in JSON output. @@ -240,16 +271,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `_doDeriveKey` in `NodeCryptoAdapter` now properly `await`s promisified calls. ### Changed + - `CryptoPort` is now the single source of truth for key validation, metadata building, and KDF parameter normalization. All three adapters override only `_doDeriveKey()`. - ROADMAP.md pruned: completed M1–M7 task cards moved to COMPLETED_TASKS.md. ## [4.0.0] — Conduit (2026-02-27) ### Breaking Changes + - **`CasService` no longer extends `EventEmitter`** — event subscriptions must use the new `ObservabilityPort` adapters instead of `service.on()`. The `EventEmitterObserver` adapter provides full backward compatibility for existing event-based code. - **`observability` is a required constructor port** for `CasService`. The facade (`ContentAddressableStore`) defaults to `SilentObserver` when omitted. ### Added + - **ObservabilityPort** — new hexagonal port (`src/ports/ObservabilityPort.js`) with `metric(channel, data)`, `log(level, msg, meta?)`, and `span(name)` methods. Decouples the domain layer from Node's event infrastructure. - **SilentObserver** — no-op adapter (default). Zero overhead when observability is not needed. - **EventEmitterObserver** — bridges `metric()` calls to EventEmitter events (`chunk:stored`, `file:restored`, etc.) for backward-compatible progress tracking. Exposes `.on()`, `.removeListener()`, `.listenerCount()`. @@ -261,6 +295,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 43 new unit tests (567 total). ### Changed + - CLI `store` and `restore` commands now create an `EventEmitterObserver` and pass it to the CAS instance, attaching progress tracking to the observer instead of the service. - `restore()` reimplemented as a collector over `restoreStream()`. - `_chunkAndStore()` refactored to use semaphore-gated parallel writes with `Promise.all`, sorting results by index after completion. @@ -269,6 +304,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.1.0] — Bijou (2026-02-27) ### Added + - **Interactive vault dashboard** (`git cas vault dashboard`) — TEA-based TUI with split-pane layout, manifest detail view, keyboard navigation (`j`/`k`/`Enter`/`/`), and real-time filtering. - **Manifest inspector** (`git cas inspect `) — renders manifest details with chunk table, encryption info, and compression badges. - **Progress bars** for `store` and `restore` operations — animated progress with throughput reporting, auto-disabled in non-TTY environments. @@ -280,6 +316,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New runtime dependencies: `@flyingrobots/bijou`, `@flyingrobots/bijou-node`, `@flyingrobots/bijou-tui`. ### Fixed + - CLI `restore` now uses the canonical `readManifest` path instead of duplicating manifest resolution logic. - Progress trackers wrapped in `try`/`finally` to prevent event listener leaks when `storeFile` or `restoreFile` throws. - Dashboard filter and error lines clamped to pane width to prevent wrapping artifacts in narrow terminals. @@ -293,6 +330,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.0.0] — Vault (2026-02-08) ### Added + - **Vault** — GC-safe ref-based storage via `refs/cas/vault`. A single Git ref pointing to a commit chain indexes all stored assets by slug. `git gc` can no longer silently discard stored data. - `initVault()` — initialize the vault, optionally with passphrase-based encryption (vault-level KDF policy). - `addToVault()` — add or update an entry by slug + tree OID, with `force` flag for overwrites. @@ -316,6 +354,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 46 vault unit tests + facade delegation smoke test. ### Fixed + - `#validateMetadata` now requires `kdf.keyLength` in encryption metadata, preventing downstream KDF failures from manually edited `.vault.json` files. - `#casUpdateRef` now preserves the original error in `VAULT_CONFLICT` meta for better diagnostics. - CLI `--vault-passphrase` now emits a stderr warning when the vault is not encrypted, instead of silently ignoring the passphrase. @@ -329,6 +368,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI uses `program.parseAsync()` instead of `program.parse()` to prevent Bun from hanging on async action handlers. ### Changed + - **Vault promoted to domain layer** — all vault logic extracted from facade (`index.js`) into `VaultService` (`src/domain/services/VaultService.js`) with `GitRefPort`/`GitRefAdapter` for ref operations. Facade now delegates to VaultService. - CLI `restore` command no longer takes a positional `` argument. Use `--oid ` or `--slug ` instead. - Purged completed milestones (M1–M7) and their task cards from ROADMAP.md, reducing it from 3,153 to 1,675 lines. @@ -336,6 +376,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] — M7 Horizon (2026-02-08) ### Added + - **Compression support** (Task 7.1): Optional gzip compression pipeline via `compression: { algorithm: 'gzip' }` option on `store()`. Compression is applied before encryption when both are enabled. Manifests include a new optional `compression` field. Decompression on `restore()` is automatic. - **KDF support** (Task 7.2): Passphrase-based encryption using PBKDF2 or scrypt via `deriveKey()` method and `passphrase` option on `store()`/`restore()`. KDF parameters are stored in `manifest.encryption.kdf` for deterministic re-derivation. All three crypto adapters (Node, Bun, Web) implement `deriveKey()`. - **Merkle tree manifests** (Task 7.3): Large manifests (chunk count exceeding `merkleThreshold`, default 1000) are automatically split into sub-manifests stored as separate blobs. Root manifest uses `version: 2` with `subManifests` references. `readManifest()` transparently reconstitutes v2 manifests into flat chunk lists. Full backward compatibility with v1 manifests. @@ -345,6 +386,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated API reference (`docs/API.md`), guide (`GUIDE.md`), and README with v2.0.0 feature documentation. ### Changed + - **BREAKING**: Manifest schema now includes `version` field (defaults to 1). Existing v1 manifests are fully backward-compatible. - `CasService` constructor accepts new `merkleThreshold` option (must be a positive integer). - `ContentAddressableStore` constructor now accepts and forwards `merkleThreshold` to `CasService`. @@ -353,6 +395,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Static imports for `createGzip` and `Readable` in `CasService` (previously dynamic imports on every call). ### Fixed + - **Sub-manifest blobs are now included as tree entries** (`sub-manifest-N.json`), preventing them from being garbage-collected by `git gc`. - `storeFile()` now forwards `passphrase`, `kdfOptions`, and `compression` options to `store()` (previously silently dropped). - `store()` and `restore()` reject when both `passphrase` and `encryptionKey` are provided (`INVALID_OPTIONS`). @@ -369,19 +412,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.6.2] — OIDC publishing + JSR docs coverage (2026-02-07) ### Added + - JSDoc comments on all exported TypeScript interfaces (`CryptoPort`, `CodecPort`, `GitPersistencePort`, `CasServiceOptions`, `EncryptionMeta`, `ManifestData`, `ContentAddressableStoreOptions`) to reach 100% JSR symbol documentation coverage. ### Fixed + - npm publish workflow now uses OIDC trusted publishing (no stored token). Upgrades npm to >=11.5.1 at publish time since pnpm does not yet support OIDC natively. ## [1.6.1] — JSR quality fixes (2026-02-07) ### Added + - TypeScript declaration files (`.d.ts`) for all three entrypoints and shared value objects, resolving JSR "slow types" scoring penalty. - `@ts-self-types` directives in `index.js`, `CasService.js`, and `ManifestSchema.js`. - `@fileoverview` module doc to `CasService.js` (required by JSR for module docs scoring). ### Fixed + - JSR package name corrected to `@git-stunts/git-cas`. - JSR publication now excludes tests, docs, CI configs, and other non-distribution files via `jsr.json` exclude list. - `index.d.ts` added to `package.json` files array for npm distribution. @@ -389,6 +436,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.6.0] — M4 Compass + M5 Sonar + M6 Cartographer (2026-02-06) ### Added + - `CasService.readManifest({ treeOid })` — reads a Git tree, locates and decodes the manifest, returns a validated `Manifest` value object. - `CasService.deleteAsset({ treeOid })` — returns logical deletion metadata (`{ slug, chunksOrphaned }`) without performing destructive Git operations. - `CasService.findOrphanedChunks({ treeOids })` — aggregates referenced chunk blob OIDs across multiple assets, returning `{ referenced: Set, total: number }`. @@ -411,18 +459,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.3.0] — M3 Launchpad (2026-02-06) ### Added + - Native Bun support via `BunCryptoAdapter` (uses `Bun.CryptoHasher`). - Native Deno/Web standard support via `WebCryptoAdapter` (uses `crypto.subtle`). - Automated, secure release workflow (`.github/workflows/release.yml`) with: - - **NPM OIDC support** including build provenance. - - **JSR support** via `jsr.json` and automated publishing. - - **GitHub Releases** with automated release notes. - - **Idempotency & Version Checks** to prevent failed partial releases. + - **NPM OIDC support** including build provenance. + - **JSR support** via `jsr.json` and automated publishing. + - **GitHub Releases** with automated release notes. + - **Idempotency & Version Checks** to prevent failed partial releases. - Dynamic runtime detection in `ContentAddressableStore` to pick the best adapter automatically. - Hardened `package.json` with repository metadata, engine constraints, and explicit file inclusion. - Local quality gates via `pre-push` git hook and `scripts/install-hooks.sh`. ### Changed + - **Breaking Change:** `CasService` cryptographic methods (`sha256`, `encrypt`, `decrypt`, `verifyIntegrity`) are now asynchronous to support Web Crypto and native optimizations. - `ContentAddressableStore` facade methods are now asynchronous to accommodate lazy service initialization and async crypto. - Project migrated from `npm` to `pnpm` for faster, more reliable dependency management. @@ -430,6 +480,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Dockerfile` now uses `corepack` for pnpm management. ### Fixed + - Fixed recursion bug in `BunCryptoAdapter` where `randomBytes` shadowed the imported function. - Resolved lazy-initialization race condition in `ContentAddressableStore` via promise caching. - Fixed state leak in `WebCryptoAdapter` streaming encryption. @@ -439,6 +490,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.0] — M2 Boomerang (v1.2.0) ### Added + - `CryptoPort` interface and `NodeCryptoAdapter` — extracted all `node:crypto` usage from the domain layer. - `CasService.store()` — accepts `AsyncIterable` sources (renamed from `storeFile`). - Multi-stage Dockerfile (Node 22, Bun, Deno) with `docker-compose.yml` for per-runtime testing. @@ -451,14 +503,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deterministic test digest helper (`digestOf`). ### Changed + - `CasService` domain layer has zero `node:*` imports — all platform dependencies injected via ports. - Constructor requires `crypto` and `codec` params (no defaults); facade supplies them. - Facade `storeFile()` now opens the file and delegates to `CasService.store()`. ### Fixed + - None. ### Security + - None. ## [1.0.0] - 2025-05-30 @@ -475,10 +530,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Docker-based test runner for reproducible CI builds. ### Changed + - None. ### Fixed + - None. ### Security + - None. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e45ed12..cbafa78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,335 @@ -# Contributing to @git-stunts/cas - -## Philosophy -- **Domain Purity**: Keep crypto and chunking logic independent of Git implementation details. -- **Portability**: The `GitPersistencePort` allows swapping the storage backend. - -## Development Workflow - -1. **Install Dependencies**: Use `pnpm install` to ensure consistent dependency management. -2. **Install Git Hooks**: Run `bash scripts/install-hooks.sh` to set up local quality gates. This will ensure that linting and unit tests pass before every push. -3. **Run Tests Locally**: - - `pnpm test` for unit tests. - - `pnpm run test:integration:node` for Node integration tests (requires Docker). - - `pnpm run test:integration:bun` for Bun integration tests. - - `pnpm run test:integration:deno` for Deno integration tests. -4. **Prepare Releases**: - - `pnpm release:verify` for the full release checklist and release-note summary output. - - Follow [docs/RELEASE.md](./docs/RELEASE.md) for the canonical patch-release flow. - -## Quality Gates -We enforce high standards for code quality: -- **Linting**: Must pass `pnpm run lint`. -- **Unit Tests**: All unit tests must pass. -- **Integration Tests**: Must pass across Node, Bun, and Deno runtimes. -- **Release Prep**: `pnpm release:verify` must pass before a tag is created. - -These gates are enforced both locally via git hooks and in CI/CD. +# Contributing to @git-stunts/git-cas + +`git-cas` is not just a bag of Git tricks. + +It is a deterministic artifact system built on Git's object database, with two +product surfaces over one shared core: + +- a human CLI/TUI +- a machine-facing agent surface + +If you contribute here, the job is not just to make code pass. The job is to +protect that product shape while making the system more capable. + +## Core Product Philosophy + +- Git is the substrate, not the product. +- Integrity is sacred. +- Restore must be deterministic. +- Provenance matters. +- Verification matters. +- GC-safe storage is non-negotiable. +- Human and agent surfaces are separate products over one domain core. +- The substrate may be sophisticated; the default UX must still feel boring, + trustworthy, and legible. + +The highest-level rule is simple: + +If a change makes storage less trustworthy, restore less deterministic, +automation less explicit, or the normal operator flow more demanding, it is +probably the wrong change. + +## Development Philosophy + +This project prefers: + +- DX over ceremony +- behavior over architecture theater +- explicit boundaries over clever coupling +- local-first, self-contained operation over service dependency +- boring human defaults over impressive internals +- machine contracts over scraped text + +In practice, that means: + +- keep commands small and obvious +- keep the default human UX boring and legible +- keep Git internals out of normal UX unless they are operationally necessary +- keep future automation concerns out of the human path until they are earned +- keep every human CLI command machine-readable through `--json` +- keep the `git cas agent` surface JSONL-first and non-interactive + +## Architectural Principles + +### Hexagonal Architecture + +The product should keep clear boundaries between: + +- domain behavior +- application/use-case orchestration +- ingress adapters such as the human CLI/TUI and the agent CLI +- infrastructure such as Git persistence, refs, codecs, crypto, and filesystem + +Do not let UI concerns leak into persistence. +Do not let storage details leak into normal UX. +Do not let terminal behavior define the application boundary. + +### SOLID, Pragmatically Applied + +Use SOLID as boundary discipline, not as a pretext for abstraction sprawl. + +Good: + +- narrow modules +- explicit seams +- dependency inversion around important adapters +- shared application behavior consumed by multiple surfaces + +Bad: + +- abstraction for its own sake +- indirection before there is pressure for it +- architecture rituals that slow delivery without protecting behavior + +## Planning And Delivery Model + +This project now plans fresh work through: + +- legends +- cycles +- backlog items +- invariants + +The working source of truth is [WORKFLOW.md](./WORKFLOW.md). + +That means: + +- legends carry broad thematic efforts +- cycles are the implementation and design loop +- backlog items are cheap, single-file work candidates +- invariants are explicit project truths that work cannot violate + +This project still uses IBM Design Thinking framing, but it is now applied at +the cycle level with both human and agent passes: + +- human users, jobs, and hills +- agent users, jobs, and hills +- human playback +- agent playback +- explicit non-goals + +Fresh work should be grounded in human or agent value, not backend vanity. + +Before promoting a new direction, ask: + +- which legend does this support? +- which cycle hill does this support? +- what human or agent behavior does this improve? +- what trust does this increase? +- what invariant does this depend on or risk violating? + +If the answer is unclear, the work probably belongs in +[`docs/BACKLOG/`](./docs/BACKLOG/), not in an active cycle doc. + +## Directory Model + +New planning work uses: + +- [`docs/legends/`](./docs/legends/) +- [`docs/BACKLOG/`](./docs/BACKLOG/) +- [`docs/design/`](./docs/design/) +- [`docs/invariants/`](./docs/invariants/) +- [`test/cycles/`](./test/cycles/) + +`ROADMAP.md` and `STATUS.md` remain useful sequence and snapshot documents, but +they are now migration surfaces for planning, not the primary place where fresh +cycle planning starts. + +## Build Order + +The expected order of work is: + +1. Write or revise design docs first. +2. Encode behavior as executable tests second. +3. Implement third. + +Tests are the spec. + +Do not insert a second prose-spec layer between design and tests. +Do not treat implementation details as the primary unit of correctness. + +## Cycle Development Loop + +Each cycle should follow the same explicit loop: + +1. design docs first +2. tests as spec second +3. implementation third +4. human and agent playbacks +5. retrospective after delivery +6. update `docs/BACKLOG/` with debt and follow-on work +7. update the root [CHANGELOG.md](./CHANGELOG.md) +8. rewrite the root README when reality changed materially + +This loop is part of the process, not optional cleanup. + +The point is to keep the repo honest about: + +- what is planned +- what is specified +- what is actually implemented +- what was learned + +## Release Discipline + +Cycle closure and release discipline are coupled when a landed cycle materially +changes the product. + +Rules: + +- keep the root [CHANGELOG.md](./CHANGELOG.md) +- keep `package.json` and `jsr.json` versioned to reality, not aspiration +- when a release-worthy cycle or grouped set of cycles is closed, bump the + in-flight version on the release commit +- create a Git tag on the commit that lands on `main` for that release +- follow [docs/RELEASE.md](./docs/RELEASE.md) instead of improvising release flow + +The version and tag should reflect shipped reality, not hopeful scope. + +## Testing Rules + +Tests must be deterministic. + +That means: + +- no real network dependency +- no ambient home-directory state +- no ambient Git config assumptions +- no interactive shell expectations in the core suite +- no timing-based flakes +- no shared mutable repository state between tests + +Every test that touches storage should use isolated temp state. + +Prefer: + +- throwaway local repos +- throwaway bare remotes when needed +- fixed env and fixed IDs where practical +- direct argv subprocess execution instead of shell-wrapped commands + +Tests should pin: + +- user-visible behavior +- integrity and restore correctness +- provenance and verification behavior +- immutability boundaries +- honest backup/storage semantics +- `--json` output contracts for the human CLI +- JSONL protocol contracts for the agent CLI as it lands + +Tests should not overfit: + +- class layout +- file-private helpers +- incidental implementation structure + +Local testing policy: + +- `npm test` is the default fast suite +- `npx eslint .` must stay clean +- integration tests run through Docker-backed runtime targets +- `pnpm release:verify` is the release truth source +- install hooks with `bash scripts/install-hooks.sh` + +## Human Surface Guardrails + +Do not introduce any of the following into the normal operator path unless +explicitly re-approved: + +- hidden side effects +- smart guessing in place of explicit state +- TUI-only access to essential behavior +- substrate jargon when plain language will do +- prompts where flags or files should be accepted + +The human path should feel trustworthy and boring, not magical. + +## Agent Surface Guardrails + +The `git cas agent` surface is the automation contract. + +That implies: + +- no TTY branching +- no implicit prompts +- stdout carries only protocol data +- stderr carries structured warnings and errors +- side effects must be explicit +- failure modes must be actionable without scraping prose +- binary payloads do not share protocol stdout + +Do not let the agent surface become “human CLI plus `--json`.” + +## UX Language Rules + +Default human-facing language should prefer artifact and storage language over +Git internals. + +Prefer: + +- `stored` +- `verified` +- `restored` +- `encrypted` +- `vault` +- `backup pending` or `not yet backed up`, when such language is accurate + +Avoid leading with: + +- raw object-database trivia +- refs, trees, blobs, and OIDs unless the operator actually needs them + +Every human CLI command must also support `--json`. + +In `--json` mode: + +- human-readable text should be suppressed +- stdout should carry only the structured result payload +- stderr should carry warnings and errors + +For the agent CLI, the automation contract is JSONL-first and should stay +separate from the human `--json` surface. + +## Git Workflow + +Prefer small, honest commits. + +Do not rewrite shared history casually. +Prefer additive commits over history surgery. +Prefer merges over rebases for shared collaboration unless there is a compelling, +explicitly discussed reason otherwise. + +The point is not aesthetic Git history. The point is trustworthy collaboration. + +## What To Read First + +Before making non-trivial changes, read: + +- [README.md](./README.md) +- [WORKFLOW.md](./WORKFLOW.md) +- [STATUS.md](./STATUS.md) +- [ROADMAP.md](./ROADMAP.md) +- [docs/legends/README.md](./docs/legends/README.md) +- [docs/invariants/README.md](./docs/invariants/README.md) +- [docs/BACKLOG/README.md](./docs/BACKLOG/README.md) +- [docs/design/README.md](./docs/design/README.md) +- [docs/design/0001-m18-relay-agent-cli.md](./docs/design/0001-m18-relay-agent-cli.md) +- [docs/API.md](./docs/API.md) +- [docs/RELEASE.md](./docs/RELEASE.md) +- [COMPLETED_TASKS.md](./COMPLETED_TASKS.md) +- [CODE-EVAL.md](./CODE-EVAL.md) + +## Decision Rule + +When in doubt: + +- choose more trustworthy behavior +- choose clearer boundaries +- choose lower ceremony +- choose fewer hidden behaviors +- choose deterministic outputs +- choose main as the playback truth +- choose behavior over architecture theater +- protect the human path from unnecessary sophistication +- protect the agent path from ambiguity diff --git a/ROADMAP.md b/ROADMAP.md index 8c8a5d5..97efbf5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,19 +1,64 @@ -# @git-stunts/cas — ROADMAP +# @git-stunts/git-cas — ROADMAP -This document tracks the real current state of `git-cas` and the sequenced work that remains. -Completed milestone detail lives in [COMPLETED_TASKS.md](./COMPLETED_TASKS.md). Superseded work -lives in [GRAVEYARD.md](./GRAVEYARD.md). +This document tracks the real current state of `git-cas` and the sequenced work +that remains. -## Current Reality +Fresh planning now follows [WORKFLOW.md](./WORKFLOW.md), not roadmap-first +milestone writing. -- **Current release:** `v5.3.2` (2026-03-15) -- **Current line:** M16 Capstone shipped in `v5.3.0`; `v5.3.1` fixed repeated-chunk tree emission for repetitive content; `v5.3.2` stabilized test/runtime tooling; `v5.3.3` is the remaining M17 Ledger closeout in flight. -- **Supported runtimes:** Node.js 22.x (primary), Bun, Deno -- **Current operator experience:** the human-facing CLI/TUI is shipped now; the machine-facing agent CLI is planned next. +That means this file is now: + +- sequence context +- release-line context +- migration context + +It is not the primary source of truth for new cycle planning. -## Interface Strategy +It now follows the workflow defined in [CONTRIBUTING.md](./CONTRIBUTING.md): -`git-cas` now has an explicit two-surface direction: +- sponsor user +- sponsor agent +- hills +- playback questions +- explicit non-goals +- design docs first, tests second, implementation third + +`main` is the playback truth. If code lands out of order, the roadmap adjusts to +match reality instead of pretending the original sequence still happened. + +Completed milestone detail lives in [COMPLETED_TASKS.md](./COMPLETED_TASKS.md). +Superseded work lives in [GRAVEYARD.md](./GRAVEYARD.md). + +## Current Reality + +- **Last tagged release:** `v5.3.2` (`2026-03-15`) +- **Current package version on `main`:** `v5.3.3` +- **Supported runtimes:** Node.js 22.x (primary), Bun, Deno +- **Human surface reality:** the human CLI/TUI is already substantial and now + includes early repo-explorer work that belongs closer to the later UX line + than to M17 closeout. +- **Agent surface reality:** there is still no first-class `git cas agent` + contract. The main product gap is machine-facing determinism, not human + surface richness. +- **M17 reality:** the M17 closeout work is materially present on `main` + (`CODEOWNERS`, release verification, test conventions, property coverage), + even though release bookkeeping and docs drifted. +- **Next deliberate focus:** the next few cycles are agent-first. The human + surface should now follow the application boundaries that fall out of the + machine surface, not the other way around. + +## Product Doctrine + +- Git is the substrate, not the product. +- Integrity is sacred. +- Restore must be deterministic. +- Provenance matters. +- Verification matters. +- Human CLI/TUI and agent CLI are separate surfaces over one shared domain core. +- The default human UX should stay boring and trustworthy. +- The default machine UX should stay deterministic and replayable. + +## Two-Surface Strategy ### Human CLI/TUI @@ -21,233 +66,282 @@ This is the current public operator surface. - Existing `git cas ...` commands remain the stable human workflow. - Bijou formatting, prompts, dashboards, and TTY-aware behavior stay here. -- `--json` remains supported as convenience structured output for humans and simple scripts. -- Human-facing improvements continue under the Bijou/TUI roadmap. +- The human `--json` flag remains convenience structured output for humans and + simple scripts. +- Future human-surface work should reuse shared app-layer behavior instead of + inventing parallel logic in the TUI. ### Agent CLI -This is planned work starting in **M18 Relay**. +This is now the priority surface. - Namespace: `git cas agent` -- Output: JSONL on `stdout` only, one record per line -- No Bijou formatting, no TTY-mode branching, no implicit prompts -- Stable event envelope: `protocol`, `command`, `type`, `seq`, `ts`, `data` -- Reserved record types: `start`, `progress`, `warning`, `needs-input`, `result`, `error`, `end` -- One-shot commands in v1: stream records during execution, then exit -- Non-interactive secret/input handling: - - missing required input -> emit `needs-input`, exit `2` - - fatal execution failure -> emit `error`, exit `1` - - integrity/verification failure -> exit `3` - - success -> exit `0` -- Request input supports normal flags plus `--request -` and `--request @file.json` - -The agent CLI is a first-class workflow, not an extension of the human `--json` mode. - -## Shipped Summary - -| Version | Milestone | Codename | Theme | Status | -|---------|-----------|----------|-------|--------| -| v3.1.0 | M13 | Bijou | TUI dashboard and animated progress | ✅ Shipped | -| v4.0.0 | M14 | Conduit | Streaming restore, observability, parallel chunk I/O | ✅ Shipped | -| v4.0.1 | M8 + M9 | Spit Shine + Cockpit | Review hardening, `verify`, `--json`, CLI polish | ✅ Shipped | -| v5.0.0 | M10 | Hydra | Content-defined chunking | ✅ Shipped | -| v5.1.0 | M11 | Locksmith | Envelope encryption and recipient management | ✅ Shipped | -| v5.2.0 | M12 | Carousel | Key rotation without re-encrypting data | ✅ Shipped | -| v5.3.0 | M16 | Capstone | Audit remediation and security hardening | ✅ Shipped | -| v5.3.1 | — | Maintenance | Repeated-chunk tree integrity fix | ✅ Shipped | -| v5.3.2 | — | Maintenance | Vitest workspace split, CLI version sync, and runtime/tooling stabilization | ✅ Shipped | +- Output: JSONL on `stdout`, one protocol record per line +- `stderr`: structured warnings and errors only +- No TTY branching, no implicit prompts, no Bijou rendering +- Stable record envelope: `protocol`, `command`, `type`, `seq`, `ts`, `data` +- Reserved record types: `start`, `progress`, `warning`, `needs-input`, + `result`, `error`, `end` +- Missing required input emits `needs-input` and exits with a distinct code +- Integrity and verification failures get their own exit-code semantics +- The agent CLI is a first-class workflow, not an extension of the human + `--json` path + +## Honest State of `main` + +### Human Surface + +What is already true on `main`: + +- chunked Git-backed storage, restore, verify, encryption, recipients, and + rotation are already shipped in the domain/library +- the vault workflow is real and GC-safe +- diagnostics and release verification already exist +- the TUI has already moved beyond a simple vault inspector into a richer + repository explorer with refs browsing, source inspection, treemap views, and + a stronger theme layer + +This means the human surface is no longer the thing waiting to become real. It +is already real and ahead of the planning docs. + +### Agent Surface + +What is still missing: + +- a first-class machine runner +- a JSONL protocol contract +- exact machine-facing exit-code semantics +- non-interactive input handling as a core design constraint +- parity for the operational command set without scraping human CLI output + +This is the current product bottleneck. + +## Tagged Releases + +| Version | Milestone | Theme | Status | +| -------- | ------------- | ----------------------------------------------------------------------- | --------- | +| `v5.3.2` | Maintenance | Vitest workspace split, CLI version sync, runtime/tooling stabilization | ✅ Tagged | +| `v5.3.1` | Maintenance | Repeated-chunk tree integrity fix | ✅ Tagged | +| `v5.3.0` | M16 Capstone | Audit remediation and security hardening | ✅ Tagged | +| `v5.2.0` | M12 Carousel | Key rotation without re-encrypting data | ✅ Tagged | +| `v5.1.0` | M11 Locksmith | Envelope encryption and recipient management | ✅ Tagged | +| `v5.0.0` | M10 Hydra | Content-defined chunking | ✅ Tagged | +| `v4.0.1` | M8 + M9 | Review hardening, `verify`, `--json`, CLI polish | ✅ Tagged | +| `v4.0.0` | M14 Conduit | Streaming restore, observability, parallel chunk I/O | ✅ Tagged | +| `v3.1.0` | M13 Bijou | TUI dashboard and animated progress | ✅ Tagged | Older history remains in [CHANGELOG.md](./CHANGELOG.md). -## Planned Release Sequence - -| Version | Milestone | Codename | Theme | Status | -|---------|-----------|----------|-------|--------| -| v5.3.3 | M17 | Ledger | Planning and ops reset | 📝 Planned | -| v5.4.0 | M18 | Relay | LLM-native CLI foundation | 📝 Planned | -| v5.5.0 | M19 | Nouveau | Bijou v3 human UX refresh | 📝 Planned | -| v5.6.0 | M20 | Sentinel | Vault health and safety | 📝 Planned | -| v5.7.0 | M21 | Atelier | Vault ergonomics and publishing | 📝 Planned | -| v5.8.0 | M22 | Cartographer | Repo intelligence and change analysis | 📝 Planned | -| v5.9.0 | M23 | Courier | Artifact sets and transfer | 📝 Planned | -| v5.10.0 | M24 | Spectrum | Storage and observability extensibility | 📝 Planned | -| v5.11.0 | M25 | Bastion | Enterprise key management research | 📝 Planned | - -## Dependency Sequence - -```text -M16 Capstone + v5.3.1/v5.3.2 maintenance ✅ - | - M17 Ledger - | - M18 Relay - | - M19 Nouveau - | - M20 Sentinel - | - M21 Atelier - | - M22 Cartographer - | - M23 Courier - | - M24 Spectrum - | - M25 Bastion -``` - -This sequence is intentionally linear. It forces the docs/ops reset first, then the machine -interface split, then the human TUI refresh, and only then the broader feature expansion. +## Untagged `main` Line + +The current `main` branch is ahead of the last tagged release. + +It currently includes: + +- the M17 closeout work that was previously tracked as pending +- package version `5.3.3` +- early human-surface repo-explorer work that landed ahead of the old planned + sequence + +The roadmap therefore treats the next planning cycle as a recentering cycle, +not as a continuation of stale milestone fiction. + +## Near-Term Priority Stack + +1. **M18 Relay foundation** + Build the first credible agent contract. +2. **Relay follow-through** + Stay agent-first until the machine surface can handle core workflows without + scraping or prompting. +3. **M19 Nouveau** + Resume major human-surface work only after the agent surface has forced + cleaner application boundaries. ## Open Milestones -### M17 — Ledger (`v5.3.3`) +### M18 — Relay (`v5.4.0` target) + +**Theme:** first-class agent CLI foundation. + +**Sponsor user** + +- A maintainer or release engineer who wants to automate `git-cas` operations + without scraping terminal text. + +**Sponsor agent** + +- A coding agent, CI job, release bot, or backup workflow that needs exact, + replayable outcomes and explicit side effects. + +**Hills** + +- A sponsor agent can inspect, verify, and query `git-cas` state through a + stable JSONL protocol without depending on TTY behavior or human-readable + formatting. +- A sponsor user can trust automation built on `git-cas` because failures, + warnings, and requested inputs are explicit and machine-actionable. + +**Playback questions** + +- Can an agent complete `inspect`, `verify`, `vault list`, `vault info`, + `vault history`, `doctor`, and `vault stats` without scraping prose? +- Are protocol records ordered, typed, and stable across Node, Bun, and Deno? +- Does `stdout` remain pure protocol output after the first record? +- Are missing inputs and integrity failures distinguished cleanly by both record + type and exit code? + +**Explicit non-goals** -**Theme:** planning and operational reset after Capstone. +- No long-lived session protocol. +- No TUI redesign. +- No attempt to turn the human `--json` path into the automation contract. +- No binary restore payload over protocol `stdout`. -Deliverables: +**Work order** -- Close M16 in docs and reconcile [ROADMAP.md](./ROADMAP.md), [STATUS.md](./STATUS.md), and the shipped version history. -- Add `CODEOWNERS` or equivalent review-assignment automation. -- Document Git tree filename ordering semantics in test conventions to prevent future false positives. -- Define a release-prep workflow for `CHANGELOG` updates and version bump timing. -- Automate test-count injection into release notes or changelog prep. -- Add property-based fuzz coverage for envelope-encryption round-trips. +1. Write the agent protocol design doc. +2. Write contract tests for record order, shapes, `stdout` purity, `stderr` + behavior, and exit codes. +3. Implement a dedicated machine runner. +4. Ship read-heavy parity first: + `agent inspect`, `agent verify`, `agent vault list`, `agent vault info`, + `agent vault history`, `agent doctor`, `agent vault stats`. -### M18 — Relay (`v5.4.0`) +**Acceptance** -**Theme:** first-class LLM-native CLI. +- The protocol contract is documented in-repo. +- The read-heavy agent commands are JSONL-first and non-interactive. +- Contract tests pass on Node, Bun, and Deno. +- The human CLI continues to work unchanged outside explicitly shared internals. -Deliverables: +### Relay Follow-through (`v5.5.0` target) -- Introduce `git cas agent` as a separate machine-facing namespace. -- Add a dedicated machine command runner instead of extending the current human `runAction()` path. -- Define and implement the JSONL envelope contract: - `protocol`, `command`, `type`, `seq`, `ts`, `data`. -- Implement reserved record types: - `start`, `progress`, `warning`, `needs-input`, `result`, `error`, `end`. -- Enforce non-interactive behavior for secrets and missing inputs. -- Support flags plus `--request -` / `--request @file.json`. -- Deliver parity for: - `agent store`, `agent tree`, `agent inspect`, `agent restore`, `agent verify`, - `agent vault list`, `agent vault info`, `agent vault history`. -- Publish contract docs with exact exit-code behavior. +**Theme:** bring the agent surface to operational parity before more large +human-surface pushes. -Acceptance: +**Sponsor user** -- JSONL contract tests must verify record order, record shapes, `stdout` purity, `stderr` silence after protocol start, and exit codes on Node, Bun, and Deno. +- A maintainer who wants to wire `git-cas` into repeatable backup, restore, + publish, or release flows. -### M19 — Nouveau (`v5.5.0`) +**Sponsor agent** -**Theme:** Bijou v3 refresh for the human-facing experience. +- An autonomous system that must perform state-changing workflows end-to-end + with explicit inputs and replayable outcomes. -Deliverables: +**Hills** -- Upgrade `@flyingrobots/bijou`, `@flyingrobots/bijou-node`, and `@flyingrobots/bijou-tui` to `3.0.0`. -- Add `@flyingrobots/bijou-tui-app` for the refreshed shell. -- Move inspector/dashboard rendering onto the v3 `ViewOutput` contract. -- Split the current inspector into sub-apps for list, detail, history, and health panes. -- Add BCSS-driven responsive styling and layout presets. -- Add motion for focus shifts, pane changes, and shell transitions where it improves legibility. -- Add session restore for the human TUI layout. -- Replace the current low-fidelity heatmap/detail composition with a higher-fidelity surface-native view. +- A sponsor agent can complete the core `git-cas` operational loop + non-interactively: store, restore, rotate, recipient management, and vault + administration. +- A sponsor user can build automation on top of `git-cas` without needing a + human escape hatch for normal success paths. -Acceptance: +**Playback questions** -- Existing human CLI behavior stays stable outside the refreshed TUI. -- PTY smoke coverage must exercise inspect/dashboard navigation, filtering, resize, pane composition, and non-TTY fallback. +- Can an agent complete encrypted store and restore flows without prompting? +- Are passphrase files, request payloads, and missing-input branches explicit? +- Are state-changing side effects obvious in protocol output? +- Can agents reason about failures without parsing human error text? -### M20 — Sentinel (`v5.6.0`) +**Explicit non-goals** -**Theme:** vault health, crypto hygiene, and safety workflows. +- No long-lived interactive agent session. +- No human-surface expansion that bypasses the shared command/model layer. +- No hidden convenience prompting in the machine path. -Deliverables: +**Work order** -- `git cas vault status` -- `git cas gc` -- `encryptionCount` auto-rotation policy -- `.casrc` KDF parameter tuning with safe validation -- Human CLI warnings for nonce budget and KDF health -- Agent CLI warnings/results for the same health signals +1. Extend the design doc to cover write flows and input request semantics. +2. Extend contract tests to state-changing commands and failure branches. +3. Implement: + `agent store`, `agent tree`, `agent restore`, `agent rotate`, + `agent recipient ...`, and the vault write flows that belong in the machine + surface. +4. Add structured warnings for safety and policy signals that agents can act on. -### M21 — Atelier (`v5.7.0`) +**Acceptance** -**Theme:** vault ergonomics and publishing workflows. +- Core state-changing workflows are machine-accessible without prompting. +- Input request behavior is explicit and documented. +- Cross-runtime contract tests cover both read and write paths. +- The machine surface is credible enough to become the app-layer reference for + later human-surface work. -Deliverables: +### M19 — Nouveau (after Relay is credible) -- Named vaults -- `git cas vault add` to adopt existing trees -- Vault export flows: - - whole vault export - - single-entry export - - bulk export -- Publish flows: - - publish to working tree - - publish to branch - - auto-publish hook support -- File-level `--passphrase` CLI for standalone encrypted store flows +**Theme:** human UX refresh on top of agent-native application boundaries. -### M22 — Cartographer (`v5.8.0`) +Some groundwork has already landed on `main`: -**Theme:** repo intelligence and artifact comparison. +- repo explorer shell +- refs browser +- source inspection +- treemap atlas and drilldown +- stronger theme and motion work -Deliverables: +That work should now be treated as input, not as permission to keep pushing the +human surface ahead of the machine surface. -- Duplicate-detection warnings during store -- `git cas scan` / dedup advisor -- Manifest diff engine -- Machine diff stream for the agent CLI -- Human compare view layered on the M19 shell +**Sponsor user** -### M23 — Courier (`v5.9.0`) +- An operator who wants to inspect, understand, and recover artifact state with + less uncertainty and less CLI memorization. -**Theme:** artifact sets and transport. +**Sponsor agent** -Deliverables: +- An agent that benefits when the human surface reuses the same shared + application operations instead of bespoke TUI behavior. -- Snapshot trees for directory-level store and restore -- Portable bundles for air-gap transfer -- Watch mode built on snapshot-root semantics rather than ad hoc per-file state +**Hill** -### M24 — Spectrum (`v5.10.0`) +- The human surface becomes easier to trust because it sits on top of cleaner, + explicit app-layer behavior that was first forced into shape by the agent CLI. -**Theme:** storage and observability extensibility. +**Explicit non-goals** -Deliverables: +- No bespoke TUI-only behavior that bypasses shared command/model boundaries. +- No large human-surface push before Relay and Relay follow-through are credible. -- `CompressionPort` -- Additional codecs: `zstd`, `brotli`, `lz4` -- Prometheus/OpenTelemetry adapter for `ObservabilityPort` +## Later Lines -### M25 — Bastion (`v5.11.0`) +The later roadmap remains directionally the same, but detailed scoping stays +light until the agent-first line is delivered. -**Theme:** enterprise key-management research with hard exit criteria. +| Line | Theme | +| ------------ | -------------------------------------------------- | +| Sentinel | Vault health, crypto hygiene, and safety workflows | +| Atelier | Vault ergonomics and publishing workflows | +| Cartographer | Repo intelligence and artifact comparison | +| Courier | Artifact sets and transport | +| Spectrum | Storage and observability extensibility | +| Bastion | Enterprise key-management research | -Deliverables: +## Cycle Delivery Rules -- ADR for external key-management support -- Threat model for HSM/Vault-backed key flows -- Proof-of-concept `KeyManagementPort` adapter -- Decision memo on whether enterprise key management should become a product milestone +Every cycle follows the repository workflow discipline: -## Delivery Standards +1. design docs first +2. tests as spec second +3. implementation third +4. retrospective after delivery +5. update `docs/BACKLOG/` with follow-on work and debt +6. rewrite the root README to reflect reality when needed +7. update the root changelog -Every planned milestone follows the repository release discipline: +Additional release discipline: -- Human CLI/TUI behavior remains backward compatible unless a release explicitly declares otherwise. -- The human `--json` flag remains convenience output, not the automation contract. -- The first machine interface release is JSONL-only and one-shot; no session protocol is planned before the contract proves useful. -- `agent restore` writes to the filesystem in v1; binary payloads do not share protocol `stdout`. -- Any user-visible feature added after M18 must include: - - at least one human CLI/TUI test, and - - at least one agent-protocol test when the feature is exposed to the machine surface. +- tagged releases reflect reality, not aspiration +- the human `--json` flag remains convenience output, not the automation + contract +- the machine surface stays JSONL-first and one-shot until a stronger protocol + is justified by playback ## Document Boundaries - [ROADMAP.md](./ROADMAP.md): current reality plus future sequence - [STATUS.md](./STATUS.md): compact project snapshot +- [WORKFLOW.md](./WORKFLOW.md): planning and delivery source of truth for fresh work - [COMPLETED_TASKS.md](./COMPLETED_TASKS.md): shipped milestone details - [GRAVEYARD.md](./GRAVEYARD.md): superseded or merged-away work - [CHANGELOG.md](./CHANGELOG.md): release-by-release history diff --git a/STATUS.md b/STATUS.md index a545122..fe23c33 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,85 +1,96 @@ -# @git-stunts/cas — Project Status +# @git-stunts/git-cas — Project Status -**Current release:** `v5.3.2` -**Current branch version:** `v5.3.3` -**Last release:** `2026-03-15` -**Current line:** M16 Capstone shipped in `v5.3.0`; `v5.3.1` fixed repeated-chunk tree emission; `v5.3.2` stabilized test/runtime tooling; `v5.3.3` is the remaining M17 closeout in flight. +**Last tagged release:** `v5.3.2` (`2026-03-15`) +**Current package version on `main`:** `v5.3.3` +**Playback truth:** `main` **Runtimes:** Node.js 22.x, Bun, Deno +**Current strategic focus:** agent-first for the next few cycles +**Fresh planning workflow:** [WORKFLOW.md](./WORKFLOW.md) --- -## Interface Strategy +`STATUS.md` remains a compact snapshot, but new planning now starts from +`WORKFLOW.md`, legends, backlog items, invariants, and cycle docs. -- **Human CLI/TUI:** the current public operator surface. Existing `git cas ...` commands, Bijou formatting, prompts, dashboards, and `--json` convenience output stay here. -- **Agent CLI:** planned next as `git cas agent`. It will be JSONL-first, non-interactive by default, and independent from Bijou rendering or TTY-only behavior. +## Honest State + +- The human CLI/TUI is already real and ahead of the old planning docs. +- M17 closeout work is materially on `main`, even though the release/docs + bookkeeping drifted. +- Early repo-explorer and TUI refresh work also landed on `main` ahead of the + old sequence. +- The biggest product gap is now the missing first-class agent CLI. + +--- + +## Two Surfaces + +- **Human CLI/TUI:** stable operator surface, boring by default, `--json` kept + as convenience structured output for humans and simple scripts. +- **Agent CLI:** next priority surface, JSONL-first, non-interactive, and + separate from the human `--json` path. --- -## Recently Shipped +## Current Hills + +### Human Hill -| Version | Milestone | Highlights | -|---------|-----------|------------| -| `v5.3.2` | Maintenance | Vitest workspace split for deterministic integration runs; CLI version sync; test/runtime tooling stabilization | -| `v5.3.1` | Maintenance | Repeated-chunk tree integrity fix; unique chunk tree entries; `git fsck` regression coverage | -| `v5.3.0` | M16 Capstone | Audit remediation, `.casrc`, passphrase-file support, restore guards, `encryptionCount`, lifecycle rename | -| `v5.2.0` | M12 Carousel | Key rotation without re-encrypting data | -| `v5.1.0` | M11 Locksmith | Envelope encryption and recipient management | -| `v5.0.0` | M10 Hydra | Content-defined chunking | -| `v4.0.1` | M8 + M9 | Review hardening, `verify`, `--json`, CLI polish | -| `v4.0.0` | M14 Conduit | Streaming restore, observability, parallel chunk I/O | -| `v3.1.0` | M13 Bijou | Interactive dashboard and animated progress | +A human operator can store, inspect, verify, restore, and manage artifacts with +confidence and without memorizing Git plumbing. -Milestone labels are thematic and non-sequential; the versions above are listed in release order. +### Agent Hill + +A coding agent, CI job, or release bot can execute core `git-cas` workflows +through a stable machine contract without scraping prose or depending on TTY +behavior. --- ## Next Up -### M17 — Ledger (`v5.3.3`) +### M18 — Relay (`v5.4.0` target) + +**Sponsor user** + +- Maintainer or release engineer building automation around `git-cas` + +**Sponsor agent** + +- Coding agent, CI job, release bot, or backup workflow -Planning and ops reset: +**Hill** -- Reconcile `ROADMAP.md`, `STATUS.md`, and release messaging -- Add review automation (`CODEOWNERS` or equivalent) -- Document Git tree ordering test conventions -- Define release-prep workflow for changelog/version timing -- Automate test-count injection into release notes or changelog prep -- Add property-based fuzz coverage for envelope encryption +- Read-heavy `git-cas` operations become available through a first-class + JSONL-first machine protocol with explicit exit-code semantics. -### M18 — Relay (`v5.4.0`) +**Immediate work order** -LLM-native CLI foundation: +1. protocol design doc +2. contract tests +3. dedicated machine runner +4. read-heavy command parity -- Introduce `git cas agent` -- Define the JSONL protocol envelope and exit codes -- Add machine-facing parity for the current operational command set -- Enforce strict non-interactive input handling +### Relay Follow-through (`v5.5.0` target) -### M19 — Nouveau (`v5.5.0`) +- Stay agent-first until state-changing flows are also credible for automation. -Human UX refresh: +### M19 — Nouveau (after Relay is credible) -- Upgrade Bijou packages to `3.0.0` -- Move the inspector shell to the v3 `ViewOutput` model -- Split the dashboard into sub-apps -- Add better styling, motion, layout persistence, and richer heatmap/detail rendering +- Resume major human-surface work only after the agent surface has forced + cleaner app-layer boundaries. --- -## Sequenced Roadmap +## Sequence Snapshot -| Version | Milestone | Theme | -|---------|-----------|-------| -| `v5.3.3` | M17 Ledger | Planning and ops reset | -| `v5.4.0` | M18 Relay | LLM-native CLI foundation | -| `v5.5.0` | M19 Nouveau | Bijou v3 human UX refresh | -| `v5.6.0` | M20 Sentinel | Vault health and safety | -| `v5.7.0` | M21 Atelier | Vault ergonomics and publishing | -| `v5.8.0` | M22 Cartographer | Repo intelligence and change analysis | -| `v5.9.0` | M23 Courier | Artifact sets and transfer | -| `v5.10.0` | M24 Spectrum | Storage and observability extensibility | -| `v5.11.0` | M25 Bastion | Enterprise key-management research | +| Order | Focus | +| ----- | ----------------------------------------------------------- | +| Now | Relay foundation | +| Next | Relay follow-through | +| Then | Nouveau | +| Later | Sentinel, Atelier, Cartographer, Courier, Spectrum, Bastion | --- -*Future details: [ROADMAP.md](./ROADMAP.md) | Shipped detail: [COMPLETED_TASKS.md](./COMPLETED_TASKS.md) | Superseded: [GRAVEYARD.md](./GRAVEYARD.md)* +_Future detail: [ROADMAP.md](./ROADMAP.md) | Shipped detail: [COMPLETED_TASKS.md](./COMPLETED_TASKS.md) | Release history: [CHANGELOG.md](./CHANGELOG.md)_ diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 0000000..424caaf --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,137 @@ +# Git-CAS Workflow + +_The planning and delivery model for `git-cas`_ + +## Planning Model + +`git-cas` now plans new work through: + +- **Legends** + - broad thematic efforts such as Relay, Nouveau, Sentinel, or Atelier +- **Cycles** + - short design and implementation loops focused on one deliverable +- **Backlog items** + - single-file work items that can be rough, partial, or speculative +- **Invariants** + - project-wide truths that design and implementation are not allowed to + violate + +This is a forward-looking workflow change. + +Older milestone language can remain in historical docs where useful for release +history, but new planning should start from legends, cycles, backlog items, and +invariants instead. + +## Directory Model + +- `docs/legends/` + - one document per legend +- `docs/BACKLOG/` + - one file per backlog item +- `docs/design/` + - active and landed cycle design docs +- `docs/invariants/` + - explicit project-wide invariants +- `test/cycles//` + - cycle-owned playback, regression, and spec tests + +This repo uses `test/`, not `tests/`, so cycle-owned tests live under +`test/cycles/`. + +## Naming Conventions + +### Backlog Items + +Backlog items are named: + +`--.md` + +Example: + +`RL-001-recipient-lifecycle.md` + +### Cycle Docs + +Cycle docs use the same code and live in `docs/design/`. + +When a cycle begins: + +1. pick a backlog item +2. move or copy that file into `docs/design/` +3. enrich it with the information required to implement the cycle + +### Cycle Tests + +Cycle-owned tests live under: + +`test/cycles//` + +Package-local unit and integration tests can still live in the normal test +locations when that is the better fit. + +## Required Design Sections + +Every active cycle design doc should include: + +- linked legend +- human users, jobs, and hills +- agent users, jobs, and hills +- human playback +- agent playback +- linked invariants +- implementation outline +- tests to write first +- risks and unknowns +- retrospective + +Design here follows IBM Design Thinking twice: + +- once for humans +- once for agents + +Agents are first-class users of `git-cas`, not a derived audience. + +## Cycle Workflow + +1. Design docs first, using the human and agent IBM Design Thinking passes. +2. Tests are the spec. Write failing tests first. +3. Green the tests. +4. Run human and agent playbacks. +5. Write a retrospective and assess drift. +6. Update `docs/BACKLOG/` with debt, follow-on work, and new questions. +7. Update [CHANGELOG.md](./CHANGELOG.md). +8. Iterate through review until accepted. +9. Merge and sync. +10. Bump version or cut a release if needed. +11. Triage the backlog and pick the next cycle. + +## Process Rules + +- No new milestone planning for fresh work. +- No new roadmap-first planning artifacts for fresh work. +- Legends deserve their own docs and should be linked when referenced. +- Important project-wide invariants must be documented explicitly and linked + when referenced. +- `main` is the playback truth when docs and branches drift. +- Human CLI/TUI and agent CLI are separate surfaces over one shared core. +- The human `--json` surface and the agent JSONL surface are not the same + contract. + +## Relationship To Existing Docs + +Some older documents still reflect the previous planning model: + +- [ROADMAP.md](./ROADMAP.md) +- [STATUS.md](./STATUS.md) +- legacy numeric cycle docs in `docs/design/` + +Those remain migration surfaces and historical context, not the source of truth +for new planning work. + +The source of truth for new planning is: + +- this file +- `docs/legends/` +- `docs/BACKLOG/` +- `docs/design/` +- `docs/invariants/` diff --git a/bin/actions.js b/bin/actions.js index 35dea70..4745863 100644 --- a/bin/actions.js +++ b/bin/actions.js @@ -6,6 +6,7 @@ /** @type {Readonly>} */ const HINTS = { + INVALID_INPUT: 'Check the agent command name and required input fields', MISSING_KEY: 'Provide --key-file or --vault-passphrase', MANIFEST_NOT_FOUND: 'Verify the tree OID contains a manifest', VAULT_ENTRY_NOT_FOUND: "Run 'git cas vault list' to see available entries", @@ -16,7 +17,10 @@ const HINTS = { RECIPIENT_NOT_FOUND: 'No recipient with that label exists in the manifest', RECIPIENT_ALREADY_EXISTS: 'A recipient with that label already exists', CANNOT_REMOVE_LAST_RECIPIENT: 'At least one recipient must remain in the manifest', - ROTATION_NOT_SUPPORTED: 'Key rotation requires envelope encryption — store with --recipient first', + ROTATION_NOT_SUPPORTED: + 'Key rotation requires envelope encryption — store with --recipient first', + VAULT_METADATA_INVALID: 'Initialize an encrypted vault before rotating its passphrase', + VAULT_CONFLICT: 'Retry the vault rotation after concurrent vault updates settle', }; /** @@ -31,7 +35,9 @@ function writeError(err, json) { if (json) { /** @type {{ error: string, code?: string }} */ const obj = { error: message }; - if (code) { obj.code = code; } + if (code) { + obj.code = code; + } process.stderr.write(`${JSON.stringify(obj)}\n`); } else { const prefix = code ? `error [${code}]: ` : 'error: '; @@ -62,7 +68,9 @@ function getHint(code) { * @returns {Promise} */ function defaultDelay(ms) { - return new Promise((resolve) => { setTimeout(resolve, ms); }); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } /** @@ -73,10 +81,16 @@ function defaultDelay(ms) { * @param {{ delay?: (ms: number) => Promise, setExitCode?: (code: number) => void }} [options] - Injectable dependencies. * @returns {(...args: any[]) => Promise} Wrapped action. */ -export function runAction(fn, getJson, { - delay = defaultDelay, - setExitCode = (code) => { process.exitCode = code; }, -} = {}) { +export function runAction( + fn, + getJson, + { + delay = defaultDelay, + setExitCode = (code) => { + process.exitCode = code; + }, + } = {} +) { return async (/** @type {any[]} */ ...args) => { try { await fn(...args); diff --git a/bin/agent/cli.js b/bin/agent/cli.js new file mode 100644 index 0000000..848c39d --- /dev/null +++ b/bin/agent/cli.js @@ -0,0 +1,2087 @@ +import { readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import ContentAddressableStore from '../../index.js'; +import Manifest from '../../src/domain/value-objects/Manifest.js'; +import { createGitPlumbing } from '../../src/infrastructure/createGitPlumbing.js'; +import { buildVaultStats, inspectVaultHealth } from '../ui/vault-report.js'; +import { filterEntries } from '../ui/vault-list.js'; +import { AGENT_EXIT_CODES, createAgentSession, getAgentExitCode } from './protocol.js'; + +const AVAILABLE_COMMANDS = Object.freeze([ + 'store', + 'tree', + 'restore', + 'rotate', + 'inspect', + 'verify', + 'doctor', + 'recipient add', + 'recipient remove', + 'recipient list', + 'vault init', + 'vault list', + 'vault info', + 'vault history', + 'vault remove', + 'vault rotate', + 'vault stats', +]); + +const REQUEST_OPTION = { request: { type: 'string' } }; +const INPUT_ALIAS_MAP = Object.freeze({ + passphrase: 'passphrase', + passphraseFile: 'passphrase-file', + keyFile: 'key-file', + oldKeyFile: 'old-key-file', + newKeyFile: 'new-key-file', + existingKeyFile: 'existing-key-file', + oldPassphrase: 'old-passphrase', + newPassphrase: 'new-passphrase', + oldPassphraseFile: 'old-passphrase-file', + newPassphraseFile: 'new-passphrase-file', + vaultPassphrase: 'vault-passphrase', + vaultPassphraseFile: 'vault-passphrase-file', +}); + +const START_REDACTED_FIELDS = new Set([ + 'passphrase', + 'passphraseFile', + 'keyFile', + 'oldKeyFile', + 'newKeyFile', + 'existingKeyFile', + 'oldPassphrase', + 'newPassphrase', + 'oldPassphraseFile', + 'newPassphraseFile', + 'vaultPassphrase', + 'vaultPassphraseFile', +]); + +const LOCAL_INPUT_ERROR_CODES = new Set(['ENOENT', 'EISDIR', 'ENOTDIR', 'EACCES', 'EPERM']); + +/** + * @param {string | undefined} requestSource + * @returns {'inline' | 'file' | 'stdin' | undefined} + */ +function normalizeRequestSourceKind(requestSource) { + if (!requestSource) { + return undefined; + } + if (requestSource === '-') { + return 'stdin'; + } + if (requestSource.startsWith('@')) { + return 'file'; + } + return 'inline'; +} + +/** + * @param {string | undefined} key + * @param {unknown} value + * @returns {{ handled: boolean, value?: unknown }} + */ +function sanitizeSpecialStartValue(key, value) { + if (!key) { + return { handled: false }; + } + + if (key.includes('-')) { + return { handled: true }; + } + + if (key === 'requestSource') { + return { + handled: true, + value: normalizeRequestSourceKind(/** @type {string | undefined} */ (value)), + }; + } + + if (key === 'manifest') { + return { + handled: true, + value: { + provided: true, + source: typeof value === 'string' ? 'file' : 'inline', + }, + }; + } + + if (START_REDACTED_FIELDS.has(key)) { + return { handled: true, value: true }; + } + + return { handled: false }; +} + +/** + * @param {Record} value + * @returns {Record} + */ +function sanitizeStartObject(value) { + /** @type {Record} */ + const sanitized = {}; + for (const [nestedKey, nestedValue] of Object.entries(value)) { + const safeValue = sanitizeStartValue(nestedKey, nestedValue); + if (safeValue !== undefined) { + sanitized[nestedKey] = safeValue; + } + } + return sanitized; +} + +/** + * @param {unknown[]} value + * @returns {unknown[]} + */ +function sanitizeStartArray(value) { + return value + .map((entry) => sanitizeStartValue(undefined, entry)) + .filter((entry) => entry !== undefined); +} + +/** + * @param {string | undefined} key + * @param {unknown} value + * @returns {unknown} + */ +function sanitizeStartValue(key, value) { + if (value === undefined) { + return undefined; + } + + const special = sanitizeSpecialStartValue(key, value); + if (special.handled) { + return special.value; + } + + if (Buffer.isBuffer(value)) { + return true; + } + + if (Array.isArray(value)) { + return sanitizeStartArray(value); + } + + if (value && typeof value === 'object') { + return sanitizeStartObject(/** @type {Record} */ (value)); + } + + return value; +} + +/** + * @param {Record} input + * @param {string[]} fields + * @returns {Record} + */ +function selectStartInput(input, fields) { + /** @type {Record} */ + const selected = {}; + for (const field of fields) { + if (input[field] !== undefined) { + selected[field] = input[field]; + } + } + return selected; +} + +/** + * @param {Record} input + * @returns {Record} + */ +function buildAgentStartData(input) { + const sanitized = sanitizeStartValue(undefined, input); + if ( + sanitized && + typeof sanitized === 'object' && + !Array.isArray(sanitized) && + Object.keys(sanitized).length > 0 + ) { + return { input: sanitized }; + } + return {}; +} + +/** + * @param {ReturnType} session + * @param {Record} input + */ +function writeAgentStart(session, input) { + session.writeStart(buildAgentStartData(input)); +} + +/** + * @param {unknown} err + * @param {string} label + * @param {string} filePath + * @returns {Error} + */ +function normalizeLocalInputError(err, label, filePath) { + const resolvedPath = path.resolve(filePath); + + if (err instanceof SyntaxError) { + return invalidInput(`Invalid ${label}: ${resolvedPath}: ${err.message}`, { + filePath: resolvedPath, + }); + } + + if (typeof err === 'object' && err && typeof err.code === 'string') { + if (LOCAL_INPUT_ERROR_CODES.has(err.code)) { + return invalidInput(`Unable to read ${label}: ${resolvedPath}`, { + filePath: resolvedPath, + errorCode: err.code, + }); + } + } + + return err instanceof Error ? err : new Error(String(err)); +} + +/** + * @param {string} filePath + * @param {string} label + * @returns {Buffer} + */ +function readBinaryInputFile(filePath, label) { + try { + return readFileSync(filePath); + } catch (err) { + throw normalizeLocalInputError(err, label, filePath); + } +} + +/** + * @param {string} filePath + * @param {string} label + * @returns {string} + */ +function readTextInputFile(filePath, label) { + try { + return readFileSync(path.resolve(filePath), 'utf8'); + } catch (err) { + throw normalizeLocalInputError(err, label, filePath); + } +} + +/** + * @param {string} cwd + * @returns {ContentAddressableStore} + */ +function createCas(cwd) { + const plumbing = createGitPlumbing({ cwd }); + return new ContentAddressableStore({ plumbing }); +} + +/** + * @param {string} message + * @param {Record} [meta] + * @returns {Error & { code: string, meta?: Record }} + */ +function invalidInput(message, meta) { + const err = /** @type {Error & { code: string, meta?: Record }} */ ( + new Error(message) + ); + err.code = 'INVALID_INPUT'; + if (meta) { + err.meta = meta; + } + return err; +} + +/** + * @param {string} message + * @param {Record} [meta] + * @returns {Error & { code: string, meta?: Record }} + */ +function needsInput(message, meta) { + const err = /** @type {Error & { code: string, meta?: Record }} */ ( + new Error(message) + ); + err.code = 'NEEDS_INPUT'; + if (meta) { + err.meta = meta; + } + return err; +} + +/** + * @param {string | undefined} request + * @param {NodeJS.ReadStream} stdin + * @returns {Promise>} + */ +async function readRequestPayload(request, stdin) { + if (!request) { + return {}; + } + + let raw; + if (request === '-') { + raw = await readStream(stdin); + } else if (request.startsWith('@')) { + raw = readTextInputFile(request.slice(1), 'request payload file'); + } else { + raw = request; + } + + if (!raw.trim()) { + throw invalidInput('Agent request payload must not be empty'); + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw invalidInput( + `Invalid JSON request payload: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw invalidInput('Agent request payload must be a JSON object'); + } + + return parsed; +} + +/** + * @param {string} key + * @param {Record} options + * @returns {{ type: 'string' | 'boolean' } | undefined} + */ +function resolveRequestOptionSpec(key, options) { + if (options[key]) { + return options[key]; + } + + const alias = INPUT_ALIAS_MAP[key]; + if (alias && options[alias]) { + return options[alias]; + } + + return undefined; +} + +/** + * @param {string} key + * @param {unknown} value + * @returns {boolean} + */ +function allowsStructuredRequestValue(key, value) { + return key === 'manifest' && Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * @param {string} key + * @param {unknown} value + * @param {{ type: 'string' | 'boolean' } | undefined} spec + */ +function validateRequestFieldType(key, value, spec) { + if (spec?.type === 'boolean' && typeof value !== 'boolean') { + throw invalidInput(`Request field "${key}" must be a boolean`); + } + if (spec?.type === 'string' && typeof value !== 'string' && !allowsStructuredRequestValue(key, value)) { + throw invalidInput(`Request field "${key}" must be a string`); + } +} + +/** + * @param {Record} request + * @param {Record} options + * @returns {Record} + */ +function normalizeRequestValues(request, options) { + /** @type {Record} */ + const normalized = {}; + + for (const [key, value] of Object.entries(request)) { + const spec = resolveRequestOptionSpec(key, options); + validateRequestFieldType(key, value, spec); + normalized[key] = value; + } + + return normalized; +} + +/** + * @param {NodeJS.ReadStream} stream + * @returns {Promise} + */ +async function readStream(stream) { + if (typeof stream.setEncoding === 'function') { + stream.setEncoding('utf8'); + } + + let raw = ''; + for await (const chunk of stream) { + raw += String(chunk); + } + return raw; +} + +/** + * @param {string[]} args + * @param {Record} options + * @param {NodeJS.ReadStream} stdin + * @returns {Promise<{ values: Record, positionals: string[], requestSource?: string }>} + */ +async function parseAgentInput(args, options, stdin) { + const optionSpec = { + ...options, + ...REQUEST_OPTION, + }; + + let parsed; + try { + parsed = parseArgs({ + args, + allowPositionals: true, + strict: true, + options: optionSpec, + }); + } catch (err) { + throw invalidInput(err instanceof Error ? err.message : String(err)); + } + + const request = normalizeRequestValues( + await readRequestPayload(parsed.values.request, stdin), + optionSpec + ); + const values = { ...request, ...parsed.values }; + delete values.request; + + return { + values, + positionals: parsed.positionals, + requestSource: parsed.values.request, + }; +} + +/** + * @param {string[]} positionals + * @param {string[]} names + * @returns {Record} + */ +function assignPositionals(positionals, names) { + if (positionals.length > names.length) { + throw invalidInput( + `Unexpected positional arguments: ${positionals.slice(names.length).join(' ')}` + ); + } + + /** @type {Record} */ + const assigned = {}; + names.forEach((name, index) => { + if (positionals[index] !== undefined) { + assigned[name] = positionals[index]; + } + }); + return assigned; +} + +/** + * @param {Record} input + * @returns {Record} + */ +function normalizeInputAliases(input) { + const normalized = { ...input }; + for (const [key, alias] of Object.entries(INPUT_ALIAS_MAP)) { + normalized[key] = input[key] ?? input[alias]; + } + return normalized; +} + +/** + * @param {Record} input + * @returns {{ cwd: string, slug?: string, oid?: string }} + */ +function resolveTarget(input) { + if (input.slug && input.oid) { + throw invalidInput('Provide --slug or --oid, not both'); + } + if (!input.slug && !input.oid) { + throw invalidInput('Provide --slug or --oid '); + } + return { + cwd: input.cwd || '.', + ...(input.slug ? { slug: input.slug } : {}), + ...(input.oid ? { oid: input.oid } : {}), + }; +} + +/** + * @param {Record} input + * @returns {{ cwd: string, slug: string }} + */ +function resolveSlugTarget(input) { + if (!input.slug) { + throw invalidInput('Provide --slug '); + } + + return { + cwd: input.cwd || '.', + slug: input.slug, + }; +} + +/** + * @param {unknown} value + * @returns {number | undefined} + */ +function parsePositiveInteger(value) { + if (value === undefined) { + return undefined; + } + + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) { + return value; + } + + if (typeof value === 'string' && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed) && parsed > 0) { + return parsed; + } + } + + throw invalidInput('Expected a positive integer'); +} + +/** + * @param {{ cwd: string, slug?: string, oid?: string }} input + * @returns {Promise<{ cas: ContentAddressableStore, treeOid: string }>} + */ +async function resolveTree(input) { + const cas = createCas(input.cwd); + const treeOid = input.oid || (await cas.resolveVaultEntry({ slug: input.slug })); + return { cas, treeOid }; +} + +/** + * @param {string} keyFilePath + * @returns {Buffer} + */ +function readKeyFile(keyFilePath) { + const key = readBinaryInputFile(keyFilePath, 'key file'); + if (key.length !== 32) { + throw invalidInput(`Invalid key length: expected 32 bytes, got ${key.length} (${keyFilePath})`); + } + return key; +} + +/** + * @param {string} filePath + * @param {{ stdin?: NodeJS.ReadStream, onWarning?: (warning: Record) => void }} [options] + * @returns {Promise} + */ +async function readAgentPassphraseFile(filePath, { stdin, onWarning } = {}) { + if (filePath === '-') { + const raw = await readStream(stdin || process.stdin); + const trimmed = raw.replace(/\r?\n$/, ''); + if (!trimmed) { + throw invalidInput('Passphrase must not be empty'); + } + return trimmed; + } + + const resolvedPath = path.resolve(filePath); + try { + const stats = statSync(resolvedPath); + if (stats.mode & 0o077) { + onWarning?.({ + code: 'INSECURE_FILE_PERMISSIONS', + message: `${resolvedPath} has insecure permissions`, + filePath: resolvedPath, + recommendation: 'chmod 600', + }); + } + } catch { + // Let the file read raise the real error. + } + + const trimmed = readTextInputFile(resolvedPath, 'passphrase file').replace(/\r?\n$/, ''); + if (!trimmed) { + throw invalidInput('Passphrase must not be empty'); + } + + return trimmed; +} + +/** + * @param {Record} input + * @returns {boolean} + */ +function hasVaultPassphraseSource(input) { + return input.vaultPassphraseFile !== undefined || input.vaultPassphrase !== undefined; +} + +/** + * @param {Record} input + */ +function validateCredentialSources(input) { + if (input.vaultPassphrase !== undefined && input.vaultPassphraseFile) { + throw invalidInput('Provide --vault-passphrase or --vault-passphrase-file, not both'); + } + if (input.keyFile && hasVaultPassphraseSource(input)) { + throw invalidInput('Provide --key-file or a vault passphrase source, not both'); + } +} + +/** + * @param {Record} input + * @param {string | undefined} requestSource + * @returns {Promise} + */ +async function resolveVaultPassphrase(input, requestSource, options = {}) { + if (input.vaultPassphraseFile === '-' && requestSource === '-') { + throw invalidInput('Cannot read both request payload and vault passphrase from stdin'); + } + if (input.vaultPassphraseFile) { + return await readAgentPassphraseFile(input.vaultPassphraseFile, options); + } + if (input.vaultPassphrase !== undefined) { + if (!String(input.vaultPassphrase).trim()) { + throw invalidInput('Passphrase must not be empty'); + } + return input.vaultPassphrase; + } + return undefined; +} + +/** + * @param {ContentAddressableStore} cas + * @param {NonNullable>>} metadata + * @param {string} passphrase + * @returns {Promise} + */ +async function deriveVaultKey(cas, metadata, passphrase) { + const { kdf } = metadata.encryption; + const { key } = await cas.deriveKey({ + passphrase, + salt: Buffer.from(kdf.salt, 'base64'), + algorithm: kdf.algorithm, + iterations: kdf.iterations, + cost: kdf.cost, + blockSize: kdf.blockSize, + parallelization: kdf.parallelization, + keyLength: kdf.keyLength, + }); + return key; +} + +/** + * @param {import('../../index.js').default} cas + * @param {Record} input + * @returns {Promise} + */ +async function resolveStoreEncryptionKey(cas, input, options = {}) { + validateCredentialSources(input); + if (input.keyFile) { + return readKeyFile(input.keyFile); + } + const passphrase = await resolveVaultPassphrase(input, input.requestSource, options); + if (!passphrase) { + return undefined; + } + const metadata = await cas.getVaultMetadata(); + if (!metadata?.encryption?.kdf) { + throw invalidInput('Vault passphrase source is only valid for encrypted vaults'); + } + return await deriveVaultKey(cas, metadata, passphrase); +} + +/** + * @param {import('../../src/domain/value-objects/Manifest.js').default} manifest + * @returns {boolean} + */ +function hasEnvelopeRecipients(manifest) { + return ( + Array.isArray(manifest.encryption?.recipients) && manifest.encryption.recipients.length > 0 + ); +} + +/** + * @param {import('../../src/domain/value-objects/Manifest.js').default} manifest + * @param {Awaited>} metadata + * @returns {string[]} + */ +function getRestoreRequiredInputs(manifest, metadata) { + if (hasEnvelopeRecipients(manifest)) { + return ['keyFile']; + } + if (metadata?.encryption?.kdf) { + return ['keyFile', 'vaultPassphrase', 'vaultPassphraseFile']; + } + return ['keyFile']; +} + +/** + * @param {{ + * cas: ContentAddressableStore, + * manifest: import('../../src/domain/value-objects/Manifest.js').default, + * input: Record, + * requestSource?: string, + * treeOid: string, + * }} options + * @returns {Promise} + */ +async function resolveRestoreEncryptionKey({ cas, manifest, input, requestSource, treeOid }) { + validateCredentialSources(input); + if (input.keyFile) { + return readKeyFile(input.keyFile); + } + + const metadata = await cas.getVaultMetadata(); + const passphrase = await resolveVaultPassphrase(input, requestSource, { + stdin: input.stdin, + onWarning: input.onWarning, + }); + + if (passphrase) { + if (hasEnvelopeRecipients(manifest)) { + throw invalidInput( + 'Vault passphrase source cannot decrypt recipient-encrypted assets; provide --key-file' + ); + } + if (!metadata?.encryption?.kdf) { + throw invalidInput('Vault passphrase source is only valid for encrypted vaults'); + } + return await deriveVaultKey(cas, metadata, passphrase); + } + + if (!manifest.encryption?.encrypted) { + return undefined; + } + + throw needsInput('Encrypted restore requires --key-file or a vault passphrase source', { + requiredInputs: getRestoreRequiredInputs(manifest, metadata), + slug: input.slug || manifest.slug, + treeOid, + }); +} + +/** + * @param {string[]} args + * @param {NodeJS.ReadStream} stdin + * @returns {Promise>} + */ +async function parseStoreInput(args, stdin) { + const { values, positionals, requestSource } = await parseAgentInput( + args, + { + slug: { type: 'string' }, + tree: { type: 'boolean' }, + force: { type: 'boolean' }, + gzip: { type: 'boolean' }, + cwd: { type: 'string' }, + 'key-file': { type: 'string' }, + 'vault-passphrase': { type: 'string' }, + 'vault-passphrase-file': { type: 'string' }, + }, + stdin + ); + return normalizeInputAliases({ + ...values, + ...assignPositionals(positionals, ['file']), + requestSource, + }); +} + +/** + * @param {Record} input + */ +function validateStoreInput(input) { + if (input.file !== undefined && typeof input.file !== 'string') { + throw invalidInput('Request field "file" must be a string'); + } + if (!input.file) { + throw invalidInput('Provide a file path'); + } + if (!input.slug) { + throw invalidInput('Provide --slug '); + } + if (input.force && !input.tree) { + throw invalidInput('--force requires --tree'); + } +} + +/** + * @param {{ + * input: Record, + * manifest: import('../../src/domain/value-objects/Manifest.js').default, + * treeOid?: string, + * commitOid?: string, + * }} options + * @returns {{ data: Record }} + */ +function buildStoreOutcome({ input, manifest, treeOid, commitOid }) { + return { + data: { + slug: input.slug, + manifest: manifest.toJSON(), + ...(treeOid ? { treeOid } : {}), + ...(commitOid ? { commitOid } : {}), + addedToVault: Boolean(commitOid), + chunkCount: manifest.chunks.length, + encrypted: Boolean(manifest.encryption?.encrypted), + compressed: Boolean(manifest.compression), + }, + }; +} + +/** + * @param {string[]} args + * @param {NodeJS.ReadStream} stdin + * @returns {Promise>} + */ +async function parseVaultInitInput(args, stdin) { + const { values, positionals, requestSource } = await parseAgentInput( + args, + { + cwd: { type: 'string' }, + algorithm: { type: 'string' }, + passphrase: { type: 'string' }, + 'passphrase-file': { type: 'string' }, + }, + stdin + ); + assignPositionals(positionals, []); + return normalizeInputAliases({ + ...values, + requestSource, + }); +} + +/** + * @param {Record} input + */ +function validateVaultInitInput(input) { + if (input.passphrase !== undefined && input.passphraseFile !== undefined) { + throw invalidInput('Provide --passphrase or --passphrase-file, not both'); + } + + const algorithm = parseKdfAlgorithm(input.algorithm); + if (algorithm && input.passphrase === undefined && input.passphraseFile === undefined) { + throw invalidInput( + 'Provide --passphrase or --passphrase-file when using --algorithm' + ); + } +} + +/** + * @param {Record} input + * @param {string | undefined} requestSource + * @returns {Promise} + */ +async function resolveVaultInitPassphrase(input, requestSource, options = {}) { + if (input.passphraseFile === '-' && requestSource === '-') { + throw invalidInput('Cannot read both request payload and vault init passphrase from stdin'); + } + if (input.passphraseFile) { + return await readAgentPassphraseFile(input.passphraseFile, options); + } + if (input.passphrase !== undefined) { + return resolveInlinePassphrase('Passphrase', input.passphrase); + } + return undefined; +} + +/** + * @param {string[]} args + * @param {NodeJS.ReadStream} stdin + * @returns {Promise>} + */ +async function parseRotateInput(args, stdin) { + const { values, positionals } = await parseAgentInput( + args, + { + slug: { type: 'string' }, + oid: { type: 'string' }, + label: { type: 'string' }, + cwd: { type: 'string' }, + 'old-key-file': { type: 'string' }, + 'new-key-file': { type: 'string' }, + }, + stdin + ); + assignPositionals(positionals, []); + return normalizeInputAliases(values); +} + +/** + * @param {Record} input + */ +function validateRotateInput(input) { + if (!input.oldKeyFile) { + throw invalidInput('Provide --old-key-file '); + } + if (!input.newKeyFile) { + throw invalidInput('Provide --new-key-file '); + } +} + +/** + * @param {unknown} value + * @returns {'pbkdf2' | 'scrypt' | undefined} + */ +function parseKdfAlgorithm(value) { + if (value === undefined) { + return undefined; + } + if (value === 'pbkdf2' || value === 'scrypt') { + return value; + } + throw invalidInput('Provide --algorithm '); +} + +/** + * @param {string} label + * @param {unknown} value + * @returns {string | undefined} + */ +function resolveInlinePassphrase(label, value) { + if (value === undefined) { + return undefined; + } + + const passphrase = String(value); + if (!passphrase.trim()) { + throw invalidInput(`${label} must not be empty`); + } + + return passphrase; +} + +/** + * @param {string[]} args + * @param {NodeJS.ReadStream} stdin + * @returns {Promise>} + */ +async function parseVaultRotateInput(args, stdin) { + const { values, positionals, requestSource } = await parseAgentInput( + args, + { + cwd: { type: 'string' }, + algorithm: { type: 'string' }, + 'old-passphrase': { type: 'string' }, + 'new-passphrase': { type: 'string' }, + 'old-passphrase-file': { type: 'string' }, + 'new-passphrase-file': { type: 'string' }, + }, + stdin + ); + assignPositionals(positionals, []); + return normalizeInputAliases({ + ...values, + requestSource, + }); +} + +/** + * @param {Record} input + */ +function validateVaultRotateInput(input) { + if (input.oldPassphrase !== undefined && input.oldPassphraseFile !== undefined) { + throw invalidInput('Provide --old-passphrase or --old-passphrase-file, not both'); + } + if (input.newPassphrase !== undefined && input.newPassphraseFile !== undefined) { + throw invalidInput('Provide --new-passphrase or --new-passphrase-file, not both'); + } + if (input.oldPassphrase === undefined && input.oldPassphraseFile === undefined) { + throw invalidInput('Provide --old-passphrase or --old-passphrase-file '); + } + if (input.newPassphrase === undefined && input.newPassphraseFile === undefined) { + throw invalidInput('Provide --new-passphrase or --new-passphrase-file '); + } + + parseKdfAlgorithm(input.algorithm); +} + +/** + * @param {Record} input + * @param {string | undefined} requestSource + * @returns {Promise<{ oldPassphrase: string, newPassphrase: string }>} + */ +async function resolveVaultRotatePassphrases(input, requestSource, options = {}) { + validateVaultRotateStdinSources(input, requestSource); + + return { + oldPassphrase: await readVaultRotatePassphrase({ + label: 'Old passphrase', + inlineValue: input.oldPassphrase, + fileValue: input.oldPassphraseFile, + ...options, + }), + newPassphrase: await readVaultRotatePassphrase({ + label: 'New passphrase', + inlineValue: input.newPassphrase, + fileValue: input.newPassphraseFile, + ...options, + }), + }; +} + +/** + * @param {Record} input + * @param {string | undefined} requestSource + */ +function validateVaultRotateStdinSources(input, requestSource) { + if (input.oldPassphraseFile === '-' && input.newPassphraseFile === '-') { + throw invalidInput('Cannot read both old and new passphrase from stdin'); + } + if ( + requestSource === '-' && + (input.oldPassphraseFile === '-' || input.newPassphraseFile === '-') + ) { + throw invalidInput('Cannot read both request payload and vault rotation passphrase from stdin'); + } +} + +/** + * @param {{ + * label: string, + * inlineValue: unknown, + * fileValue?: string, + * stdin?: NodeJS.ReadStream, + * onWarning?: (warning: Record) => void, + * }} options + * @returns {Promise} + */ +async function readVaultRotatePassphrase({ label, inlineValue, fileValue, ...options }) { + const passphrase = fileValue + ? await readAgentPassphraseFile(fileValue, options) + : resolveInlinePassphrase(label, inlineValue); + + if (!passphrase?.trim()) { + throw invalidInput(`${label} must not be empty`); + } + + return passphrase; +} + +/** + * @param {string[]} args + * @param {NodeJS.ReadStream} stdin + * @returns {Promise>} + */ +async function parseRecipientAddInput(args, stdin) { + const { values, positionals } = await parseAgentInput( + args, + { + slug: { type: 'string' }, + label: { type: 'string' }, + cwd: { type: 'string' }, + 'key-file': { type: 'string' }, + 'existing-key-file': { type: 'string' }, + }, + stdin + ); + assignPositionals(positionals, []); + return normalizeInputAliases(values); +} + +/** + * @param {Record} input + */ +function validateRecipientAddInput(input) { + if (!input.slug) { + throw invalidInput('Provide --slug '); + } + if (!input.label) { + throw invalidInput('Provide --label