Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# error-classifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Translates the noisy `(exitCode, stderr)` tuples emitted by the macOS
|
||||
`security` binary into a single `KeychainError` with a stable code. Apple's
|
||||
CLI returns the same numeric exit code for several distinct failure modes,
|
||||
so the classifier looks at stderr substrings first; the exit code only
|
||||
matters when stderr is silent or generic.
|
||||
|
||||
This separation keeps the bridge implementation small — every CRUD method
|
||||
calls the classifier on a non-zero exit code and either rethrows the
|
||||
returned error or, when the classifier returns `null`, wraps the failure in
|
||||
`keychainAccessFailed` for the trace to retain the original stderr.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — deterministic on its inputs, no globals, no allocations
|
||||
beyond the returned error.
|
||||
- Stderr substring matching (case-insensitive) takes precedence over numeric
|
||||
exit codes.
|
||||
- Five exit codes are mapped: `errSecItemNotFound`, `errSecDuplicateItem`,
|
||||
`errSecAuthFailed`, `errSecInteractionNotAllowed`, `errSecInvalidKeychain`.
|
||||
- Returns `null` for unrecognised pairs so the caller can decide between a
|
||||
generic fallback and a hard fail.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { classifyError } from "@suaveplan/macos-keychain";
|
||||
|
||||
const err = classifyError(44, "could not be found", "my-app", "alice");
|
||||
console.error(err?.code); // "KEYCHAIN.NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyError,
|
||||
keychainAccessFailed,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
async function callSecurity(): Promise<void> {
|
||||
const result = await runner("/usr/bin/security", ["find-generic-password"]);
|
||||
if (result.exitCode === 0) return;
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
"my-app",
|
||||
"alice"
|
||||
);
|
||||
throw classified ?? keychainAccessFailed("my-app", "alice", result.stderr);
|
||||
}
|
||||
|
||||
declare function runner(
|
||||
cmd: string,
|
||||
args: readonly string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`classifyError(exitCode: number, stderr: string, service: string,
|
||||
account: string): KeychainError | null`** — returns the matching
|
||||
`KeychainError` or `null` for unknown combinations. Pure function.
|
||||
|
||||
Precedence:
|
||||
1. Stderr matches `EXIT_LOCKED_PATTERNS` → `KEYCHAIN.LOCKED`.
|
||||
2. Stderr matches `UNAVAILABLE_STDERR_PATTERNS` → `KEYCHAIN.UNAVAILABLE`.
|
||||
3. Stderr matches `PERMISSION_STDERR_PATTERNS` →
|
||||
`KEYCHAIN.PERMISSION_DENIED`.
|
||||
4. Stderr matches `NOT_FOUND_STDERR_PATTERNS` → `KEYCHAIN.NOT_FOUND`.
|
||||
5. Stderr matches `DUPLICATE_STDERR_PATTERNS` → `KEYCHAIN.ITEM_EXISTS`.
|
||||
6. Stderr matches `INVALID_STDERR_PATTERNS` → `KEYCHAIN.ITEM_INVALID`.
|
||||
7. Exit code `44` → `KEYCHAIN.NOT_FOUND`.
|
||||
8. Exit code `45` → `KEYCHAIN.ITEM_EXISTS`.
|
||||
9. Exit code `36` → `KEYCHAIN.ACCESS_FAILED`.
|
||||
10. Exit code `25308` → `KEYCHAIN.LOCKED`.
|
||||
11. Exit code `25295` → `KEYCHAIN.ITEM_INVALID`.
|
||||
12. Otherwise → `null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Substring matching is case-insensitive; both the haystack and the needles
|
||||
are lower-cased before `String.prototype.includes`.
|
||||
- The locked-pattern check runs first because Apple frequently surfaces a
|
||||
locked-keychain failure with exit code `1`, which the numeric switch
|
||||
cannot disambiguate.
|
||||
- The classifier never throws; on a malformed input (e.g. NaN exit code) it
|
||||
returns `null` and lets the caller's fallback layer take over.
|
||||
- Adding a new `errSec*` constant requires three edits: a new entry in
|
||||
`constants/index.ts`, a new branch in this file's `switch`, and a new
|
||||
scenario in `error-classifier.test.ts`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# default-executor
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the runtime-aware default `CommandExecutor` used by the bridge when
|
||||
the consumer does not inject one. The executor encapsulates the only place
|
||||
in the package that reaches into `Bun.spawn` or `node:child_process.spawn`;
|
||||
every other module operates on the `CommandExecutor` abstraction so tests
|
||||
can substitute a deterministic stub.
|
||||
|
||||
The factory chooses Bun when `globalThis.Bun` is present and falls back to
|
||||
Node's `spawn` otherwise. Neither path goes through a shell — the binary
|
||||
path and arguments are passed as a structured array.
|
||||
|
||||
## Features
|
||||
|
||||
- Runtime detection per call (so a host that lazily polyfills `globalThis.Bun`
|
||||
still routes correctly).
|
||||
- AbortSignal propagation — `options.signal` is forwarded to the underlying
|
||||
spawn API so caller-side cancellation reaches the child.
|
||||
- UTF-8 stdout/stderr capture — both streams are decoded as text before being
|
||||
returned.
|
||||
- Graceful failure — `error` events on the Node path reject the promise; Bun
|
||||
errors propagate via `child.exited`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createDefaultExecutor } from "@suaveplan/macos-keychain";
|
||||
|
||||
const runner = createDefaultExecutor();
|
||||
const result = await runner("/usr/bin/security", ["list-keychains"]);
|
||||
console.log(result.exitCode, result.stdout);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
createDefaultExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const runner = createDefaultExecutor();
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 5_000 }, runner);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), 1_000);
|
||||
try {
|
||||
await bridge.findItem("github", "alice", { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`createDefaultExecutor(): CommandExecutor`** — returns an async
|
||||
`(command, args, options) => Promise<CommandExecutorResult>`. The
|
||||
resolved value contains `stdout`, `stderr`, and `exitCode`. Rejects only
|
||||
when the runtime itself errors (binary missing, abort signal already
|
||||
aborted).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Bun branch reads the `stdout` / `stderr` `ReadableStream<Uint8Array>`
|
||||
fully into memory before returning. For the `security` CLI this is fine
|
||||
because the worst-case payload (a single keychain item plus surrounding
|
||||
metadata) is a few kilobytes.
|
||||
- The Node branch uses `child.stdout.setEncoding("utf8")` so the `data`
|
||||
events deliver decoded strings; the executor concatenates them rather
|
||||
than buffering raw bytes.
|
||||
- Aborting the underlying child mid-call surfaces as either an `error`
|
||||
event (Node) or a non-zero exit code (Bun). The bridge layer translates
|
||||
both into `KEYCHAIN.TIMEOUT` when the combined signal indicates the
|
||||
timeout fired.
|
||||
- No globals are mutated; the factory returns a fresh closure per call so
|
||||
tests can construct multiple bridges with independent runtime detection.
|
||||
@@ -0,0 +1,109 @@
|
||||
# keychain-bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hosts `MacOSKeychainBridge`, the single class consumers of the package
|
||||
construct. It composes the executor, parser, error classifier, and OTel
|
||||
meter into a strongly-typed CRUD surface for macOS generic-password items.
|
||||
Every method either resolves with a typed result or rejects with a
|
||||
`KeychainError` whose `code` is one of the eight registered literals.
|
||||
|
||||
The class is deliberately thin — most of the policy work (output parsing,
|
||||
error classification, runtime detection) lives in sibling modules so it can
|
||||
be tested in isolation. The bridge's job is to thread the
|
||||
configuration / abort signal / timeout / OTel concerns through every CLI
|
||||
invocation.
|
||||
|
||||
## Features
|
||||
|
||||
- CRUD methods: `addItem`, `findItem`, `updateItem`, `deleteItem`,
|
||||
`listItems`, `itemExists`.
|
||||
- Per-call `KeychainCallOptions.signal` cancellation, combined with the
|
||||
configured `timeoutMs` deadline.
|
||||
- Pluggable `CommandExecutor` constructor argument; defaults to
|
||||
`createDefaultExecutor()` for the running runtime.
|
||||
- Zod-validated config; an invalid `timeoutMs` throws a `ZodError` from the
|
||||
constructor before any CLI is invoked.
|
||||
- OTel emission on every call: `keychain.<op>` span (via the global
|
||||
provider), `keychain.ops_total` counter, `keychain.cli_duration_ms`
|
||||
histogram, and `keychain.items_returned` histogram (on `listItems`).
|
||||
- Logger pass-through — when `config.logger` is provided the bridge emits a
|
||||
`debug` record per invocation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MacOSKeychainBridge } from "@suaveplan/macos-keychain";
|
||||
|
||||
const bridge = new MacOSKeychainBridge();
|
||||
await bridge.addItem({ service: "my-app", account: "alice", password: "hunter2" });
|
||||
const item = await bridge.findItem("my-app", "alice");
|
||||
console.log(item.password);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
type CommandExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
// Deterministic stub for tests — no real keychain, no `vi.mock`.
|
||||
const stubRunner: CommandExecutor = async (cmd, args) => {
|
||||
if (args[0] === "find-generic-password") {
|
||||
return { stdout: "", stderr: 'password: "stored"', exitCode: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
};
|
||||
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 1_000 }, stubRunner);
|
||||
const controller = new AbortController();
|
||||
const item = await bridge.findItem("svc", "user", { signal: controller.signal });
|
||||
console.log(item.password); // "stored"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new MacOSKeychainBridge(config?: KeychainConfig, runner?:
|
||||
CommandExecutor)`** — validates `config` via `KeychainConfigSchema` and
|
||||
installs the runner. Calls `registerKeychainErrorCodes()` before
|
||||
returning.
|
||||
- **`addItem(item, options?): Promise<KeychainResult>`** — throws
|
||||
`KEYCHAIN.ITEM_EXISTS` on duplicate; otherwise routes through
|
||||
`classifyError` for failure mapping.
|
||||
- **`findItem(service, account, options?): Promise<KeychainItem>`** —
|
||||
parses `find-generic-password -g` output via `parseKeychainItem`. Throws
|
||||
`KEYCHAIN.NOT_FOUND` when the item is absent.
|
||||
- **`updateItem(service, account, newPassword, options?): Promise<
|
||||
KeychainResult>`** — performs delete-then-add. Tolerates a missing
|
||||
pre-existing item.
|
||||
- **`deleteItem(service, account, options?): Promise<KeychainResult>`** —
|
||||
throws `KEYCHAIN.NOT_FOUND` when the item does not exist.
|
||||
- **`listItems(service?, options?): Promise<readonly KeychainListEntry[]>`** —
|
||||
filters the dump output by service when provided. Records
|
||||
`keychain.items_returned` with the post-filter count.
|
||||
- **`itemExists(service, account, options?): Promise<boolean>`** — `true`
|
||||
on success, `false` on `KEYCHAIN.NOT_FOUND`, rethrows other errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- AbortSignal handling is centralised in `combineSignals`: a fresh
|
||||
`AbortController` is constructed per call and aborts on whichever fires
|
||||
first — the caller's signal or the configured `timeoutMs`. The `dispose`
|
||||
callback removes the listener and clears the timeout to avoid leaking
|
||||
handles.
|
||||
- Timeout detection inspects the combined signal's `aborted` flag; this
|
||||
ensures both caller-aborts and timer-aborts surface as
|
||||
`KEYCHAIN.TIMEOUT`.
|
||||
- Stderr placement of the password is a quirk of `find-generic-password
|
||||
-g`; the bridge concatenates stdout and stderr before handing the buffer
|
||||
to `parseKeychainItem` so the regex matches regardless of CLI version.
|
||||
- `listItems` post-filters in TypeScript rather than relying on the CLI's
|
||||
attribute filtering, which is unreliable across macOS versions.
|
||||
- The default runner deliberately lives in a separate file so the bridge
|
||||
itself stays under the file-size budget and so the test suite can
|
||||
construct bridges without touching child-process imports.
|
||||
@@ -0,0 +1,101 @@
|
||||
# output-parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses the macOS `security` CLI output formats consumed by the bridge into
|
||||
strongly-typed records. The CLI emits attribute-style output with quoted
|
||||
strings, hex blobs, and timestamps; this module is the only place that
|
||||
understands those wire formats so the rest of the package operates on
|
||||
`KeychainItem` instances.
|
||||
|
||||
The parser is also the package's only sensitive-data handler: it materialises
|
||||
the password bytes into a temporary `Uint8Array` long enough to decode UTF-8,
|
||||
zeroes the buffer immediately, and returns only a JS string view. Callers
|
||||
control how long the string lives.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseKeychainItem` — parses combined stdout/stderr from `find-generic-
|
||||
password -g`, supporting both quoted-string (`password: "value"`) and
|
||||
hex-blob (`password: 0xDEADBEEF`) password formats.
|
||||
- Attribute extraction — recognises `svce`, `acct`, `labl`, `icmt`, `cdat`,
|
||||
`mdat` attribute lines and decodes Apple's date-time format.
|
||||
- Buffer zeroization — the working `Uint8Array` produced by `TextEncoder`
|
||||
and the hex-decode buffer are zeroed via `@suaveplan/crypto-utils`.
|
||||
- `parseKeychainList` — walks `dump-keychain` records and returns
|
||||
`{ service, account }` pairs, skipping entries that lack a `svce` or
|
||||
`acct` attribute.
|
||||
- Throws `KEYCHAIN.ITEM_INVALID` on malformed input rather than returning
|
||||
undefined or null.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseKeychainItem } from "@suaveplan/macos-keychain";
|
||||
|
||||
const item = parseKeychainItem(
|
||||
'password: "hunter2"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"',
|
||||
"my-app",
|
||||
"alice"
|
||||
);
|
||||
console.log(item.password); // "hunter2"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseKeychainItem,
|
||||
parseKeychainList,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const dump = `keychain: "/Users/alice/Library/Keychains/login.keychain-db"
|
||||
"svce"<blob>="github"
|
||||
"acct"<blob>="alice"
|
||||
keychain: "/Users/alice/Library/Keychains/login.keychain-db"
|
||||
"svce"<blob>="my-app"
|
||||
"acct"<blob>="alice"
|
||||
`;
|
||||
|
||||
const entries = parseKeychainList(dump);
|
||||
for (const e of entries) {
|
||||
const detail = parseKeychainItem(
|
||||
`password: "${"redacted"}"\n"svce"<blob>="${e.service}"\n"acct"<blob>="${e.account}"`,
|
||||
e.service,
|
||||
e.account
|
||||
);
|
||||
void detail;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`parseKeychainItem(output: string, service: string, account: string):
|
||||
KeychainItem`** — returns a fully-typed `KeychainItem`. Throws a
|
||||
`KeychainError` with `code === "KEYCHAIN.ITEM_INVALID"` if the output is
|
||||
missing a `password:` line or contains an undecodable hex blob.
|
||||
- **`parseKeychainList(stdout: string): readonly KeychainListEntry[]`** —
|
||||
returns a frozen-shape array. Records lacking `svce` or `acct` are
|
||||
silently skipped (this matches Apple's own behaviour when listing items
|
||||
that a process cannot read).
|
||||
- **`KeychainListEntry`** — `{ readonly service: string; readonly account:
|
||||
string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The quoted-password regex tolerates backslash-escaped characters
|
||||
(`\\`, `\"`, `\n`, `\t`); `unescapeQuoted` reverses the escape sequences
|
||||
before the parsed value reaches the caller.
|
||||
- Hex decoding uses `TextDecoder("utf-8", { fatal: false })` so a
|
||||
truncated multi-byte sequence produces a `<EFBFBD>` replacement character
|
||||
rather than throwing — this matches the CLI's own lenient behaviour.
|
||||
- Dates use Apple's `YYYYMMDDHHMMSSZ` format with a trailing `\\000`
|
||||
null marker; the parser strips the marker and returns `undefined` for
|
||||
non-conformant strings rather than guessing.
|
||||
- `parseKeychainList` splits on `^keychain:` so multiple keychains in a
|
||||
single dump (login + iCloud + system) are walked independently.
|
||||
- Both parsers are pure on their inputs; instrumentation is the caller's
|
||||
responsibility.
|
||||
@@ -0,0 +1,91 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and
|
||||
the metadata table that downstream consumers (dashboards, retry policies,
|
||||
i18n catalogs) use to interpret them. Defining the registry in one file means
|
||||
factories, tests, and the bridge implementation never disagree on the literal
|
||||
values, and future codes can be added without coordinating across modules.
|
||||
|
||||
The module also exports the `KeychainError` class — a thin subclass of
|
||||
`SuaveplanError<KeychainErrorCode>` that narrows the code union and pre-sets
|
||||
`this.name`. It is the only error class the package ever throws.
|
||||
|
||||
## Features
|
||||
|
||||
- `KEYCHAIN_CODES` — frozen `Readonly<Record<reason, "KEYCHAIN.<reason>">>`
|
||||
produced by `defineCodes`.
|
||||
- `KeychainErrorCode` — discriminated union of every literal code value.
|
||||
- `KEYCHAIN_ERROR_METADATA` — frozen metadata table covering HTTP status,
|
||||
severity, retryable flag, user-message key, category, and description.
|
||||
- `registerKeychainErrorCodes()` — idempotent registration hook, called at
|
||||
module bottom.
|
||||
- `isKeychainErrorCodeRegistryRegistered()` — predicate exposed for tests.
|
||||
- `KeychainError` — concrete subclass of `SuaveplanError<KeychainErrorCode>`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KeychainError,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
throw new KeychainError(KEYCHAIN_CODES.NOT_FOUND, "keychain.error.not_found", {
|
||||
context: { service: "my-app", account: "alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KEYCHAIN_ERROR_METADATA,
|
||||
KeychainError,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof KeychainError)) return false;
|
||||
const meta = KEYCHAIN_ERROR_METADATA[err.code];
|
||||
return meta.retryable;
|
||||
}
|
||||
|
||||
if (isRetryable(thrown)) {
|
||||
await retryWithBackoff();
|
||||
}
|
||||
|
||||
declare const thrown: unknown;
|
||||
declare function retryWithBackoff(): Promise<void>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`KEYCHAIN_CODES`** — `Readonly<{ NOT_FOUND, ITEM_EXISTS, ACCESS_FAILED,
|
||||
LOCKED, UNAVAILABLE, PERMISSION_DENIED, ITEM_INVALID, TIMEOUT }>` mapping
|
||||
reason names to `"KEYCHAIN.<reason>"` literals.
|
||||
- **`KeychainErrorCode`** — derived union type.
|
||||
- **`KEYCHAIN_ERROR_METADATA`** — frozen `Record<KeychainErrorCode,
|
||||
KeychainCodeMetadata>` keyed by code value.
|
||||
- **`KeychainCodeMetadata`** — `{ httpStatus, severity, retryable,
|
||||
userMessageKey, category, description }`.
|
||||
- **`registerKeychainErrorCodes(): void`** — idempotent. The first call
|
||||
flips an internal flag; subsequent calls are no-ops.
|
||||
- **`isKeychainErrorCodeRegistryRegistered(): boolean`** — returns the flag
|
||||
state. Test-only; production code should not branch on this.
|
||||
- **`class KeychainError`** — `new KeychainError(code, messageKey, options?)`.
|
||||
`options` is `SuaveplanErrorOptions` from `@suaveplan/error` (cause,
|
||||
context, traceId, clock).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The metadata table is `Object.freeze`-d at module load to prevent runtime
|
||||
tampering.
|
||||
- `defineCodes` constructs the literal-typed code map without `as` assertions,
|
||||
so renaming a code triggers a typecheck error in every consumer.
|
||||
- `registerKeychainErrorCodes` does not currently push to a global registry —
|
||||
it exists as a stable hook so a future change can introduce a
|
||||
central code-discovery map without breaking the public surface.
|
||||
- `KeychainError` always sets `this.name = "KeychainError"` so `err.name`-based
|
||||
log filters work even after minification.
|
||||
@@ -0,0 +1,93 @@
|
||||
# keychain-errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hosts the named factory helpers that produce `KeychainError` instances for
|
||||
every documented failure code. Centralising construction means the message
|
||||
key, code literal, and context shape are uniform across the package and across
|
||||
consumers — call sites cannot accidentally drift on phrasing or omit
|
||||
`{ service, account }`. The factories are pure and side-effect-free; they can
|
||||
be used inside hot paths without instrumentation overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- One factory per code: `keychainNotFound`, `keychainItemExists`,
|
||||
`keychainAccessFailed`, `keychainLocked`, `keychainUnavailable`,
|
||||
`keychainPermissionDenied`, `keychainItemInvalid`, `keychainTimeout`.
|
||||
- Every factory returns a fully-typed `KeychainError`.
|
||||
- Structured context objects encode the primary key (`service` / `account`)
|
||||
plus any extra fields specific to the failure (`detail`, `reason`,
|
||||
`timeoutMs`).
|
||||
- All factories preserve `exactOptionalPropertyTypes` semantics — fields are
|
||||
omitted rather than set to `undefined`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { keychainNotFound } from "@suaveplan/macos-keychain";
|
||||
|
||||
const err = keychainNotFound("my-app", "alice");
|
||||
console.error(err.code); // "KEYCHAIN.NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KeychainError,
|
||||
keychainAccessFailed,
|
||||
keychainTimeout,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
async function callKeychain(): Promise<void> {
|
||||
try {
|
||||
await someBridgeCall();
|
||||
} catch (cause) {
|
||||
if (cause instanceof KeychainError && cause.code === "KEYCHAIN.TIMEOUT") {
|
||||
throw keychainTimeout("my-app", "alice", 5_000);
|
||||
}
|
||||
throw keychainAccessFailed("my-app", "alice", String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
declare function someBridgeCall(): Promise<void>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`keychainNotFound(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.NOT_FOUND`, message key `keychain.error.not_found`,
|
||||
context `{ service, account }`.
|
||||
- **`keychainItemExists(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.ITEM_EXISTS`.
|
||||
- **`keychainAccessFailed(service: string, account: string, detail?: string):
|
||||
KeychainError`** — code `KEYCHAIN.ACCESS_FAILED`. `detail` is appended to
|
||||
the context only when supplied.
|
||||
- **`keychainLocked(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.LOCKED`.
|
||||
- **`keychainUnavailable(detail?: string): KeychainError`** — code
|
||||
`KEYCHAIN.UNAVAILABLE`. No `service`/`account` context because the
|
||||
service-level failure is not specific to a single item.
|
||||
- **`keychainPermissionDenied(service: string, account: string):
|
||||
KeychainError`** — code `KEYCHAIN.PERMISSION_DENIED`.
|
||||
- **`keychainItemInvalid(service: string, account: string, reason: string):
|
||||
KeychainError`** — code `KEYCHAIN.ITEM_INVALID`. `reason` is required so
|
||||
the parser hint is preserved in the structured context.
|
||||
- **`keychainTimeout(service: string, account: string, timeoutMs: number):
|
||||
KeychainError`** — code `KEYCHAIN.TIMEOUT`. `timeoutMs` records the
|
||||
configured deadline, not the elapsed time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Factories never read or write process state; they are deterministic on
|
||||
their inputs.
|
||||
- `keychainAccessFailed` and `keychainUnavailable` accept optional `detail`
|
||||
so the error carries a hint without relying on stack inspection. Callers
|
||||
building a generic fallback (e.g. when `classifyError` returns `null`)
|
||||
pass the captured stderr.
|
||||
- The factory layer is intentionally thin — adding logging or tracing here
|
||||
would mean every test that constructs an error pays for it. Observability
|
||||
belongs in the bridge, not in the error constructor.
|
||||
@@ -0,0 +1,93 @@
|
||||
# macos-keychain.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides runtime Zod schemas that mirror the public type contracts. They are
|
||||
parsed at every consumer boundary so an invalid `timeoutMs`, missing required
|
||||
field, or wrong-shape logger surfaces as a precise `ZodError` rather than a
|
||||
downstream undefined-behaviour failure deep inside the bridge.
|
||||
|
||||
The schemas are colocated with the rest of the package surface but are also
|
||||
exposed via the dedicated `./schemas/macos-keychain.schemas` subpath so a
|
||||
calling library can re-validate config it received from elsewhere (CLI flags,
|
||||
config file, environment) before constructing the bridge.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeychainConfigSchema` — structural shape of bridge construction options.
|
||||
- `KeychainItemSchema` — full credential record (used by the parser).
|
||||
- `KeychainResultSchema` — boolean success envelope.
|
||||
- Inferred types — `KeychainConfigInput`, `KeychainItemInput`,
|
||||
`KeychainResultInput` — parallel the canonical interfaces with the parsing
|
||||
defaults applied.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { KeychainConfigSchema } from "@suaveplan/macos-keychain";
|
||||
|
||||
const config = KeychainConfigSchema.parse({
|
||||
serviceName: "my-app",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KeychainConfigSchema,
|
||||
KeychainItemSchema,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
function loadKeychainConfigFromCli(rawArgs: Record<string, unknown>) {
|
||||
const result = KeychainConfigSchema.safeParse({
|
||||
serviceName: rawArgs["service"],
|
||||
timeoutMs: Number(rawArgs["timeout"]) || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid keychain CLI flags: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const stored = KeychainItemSchema.parse({
|
||||
service: "github",
|
||||
account: "alice",
|
||||
password: "ghp_…",
|
||||
});
|
||||
void stored;
|
||||
void loadKeychainConfigFromCli;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`KeychainConfigSchema`** — Zod object schema. Every field optional;
|
||||
`timeoutMs` validated as a positive integer; `clock` validated as a
|
||||
function; `logger` validated as an object exposing the four `LoggerLike`
|
||||
methods.
|
||||
- **`KeychainItemSchema`** — Zod object schema requiring non-empty `service`
|
||||
and `account` strings, capping `password` at 1 MiB, and accepting optional
|
||||
`label`, `comment`, `createdAt`, `modifiedAt`.
|
||||
- **`KeychainResultSchema`** — Zod object schema with required `success`
|
||||
boolean and optional `error` string.
|
||||
- **Inferred types** — re-exported via `z.infer<typeof Schema>` for callers
|
||||
that want a single source of truth for both runtime and compile-time
|
||||
validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Schemas use `z.custom` for `clock` and `logger` because they are structural
|
||||
function/object shapes. Zod's `.parse` would otherwise narrow them to
|
||||
`unknown` without a type assertion.
|
||||
- The `password` upper bound exists because the macOS `security` CLI
|
||||
enforces a similar limit and a multi-megabyte payload almost certainly
|
||||
indicates a programming error or accidental binary blob.
|
||||
- Schemas do not validate the platform itself — the bridge throws
|
||||
`KEYCHAIN.UNAVAILABLE` at runtime when invoked off macOS, but constructing
|
||||
the schema and parsing inputs remains valid on any platform.
|
||||
@@ -0,0 +1,114 @@
|
||||
# macos-keychain.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines every public type contract exposed by `@suaveplan/macos-keychain`. The
|
||||
package wraps the macOS `security` command-line tool to perform generic-password
|
||||
CRUD against the system Keychain; these types describe both the values that
|
||||
flow into the bridge (configuration, item records) and the runner-neutral
|
||||
abstraction over the underlying child-process surface (`CommandExecutor`).
|
||||
|
||||
Keeping the types in one file means the public surface is immediately auditable
|
||||
and it gives test code, schemas, and the bridge implementation a single source
|
||||
of truth. The shapes are deliberately minimal — every field that is not
|
||||
strictly required is optional, allowing callers to opt in incrementally.
|
||||
|
||||
## Features
|
||||
|
||||
- `CommandExecutor` — pluggable async function: `(command, args, options) =>
|
||||
Promise<{ stdout, stderr, exitCode }>`.
|
||||
- `CommandExecutorOptions` — per-call abort signal forwarded to the child
|
||||
process.
|
||||
- `CommandExecutorResult` — captured stdout/stderr/exit-code triple.
|
||||
- `KeychainConfig` — bridge construction options, including injectable `Clock`
|
||||
and `LoggerLike` from `@suaveplan/types`.
|
||||
- `KeychainItem` — credential record returned by `findItem`/`listItems`.
|
||||
- `KeychainResult` — boolean success envelope returned by mutating methods.
|
||||
- `KeychainCallOptions` — per-call option bag carrying the `signal` parameter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CommandExecutor,
|
||||
KeychainConfig,
|
||||
KeychainItem,
|
||||
} from "@suaveplan/macos-keychain/types/macos-keychain.types";
|
||||
|
||||
const config: KeychainConfig = { timeoutMs: 5_000 };
|
||||
|
||||
const stubRunner: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: 'password: "hunter2"',
|
||||
exitCode: 0,
|
||||
});
|
||||
void stubRunner;
|
||||
void config;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CommandExecutor,
|
||||
KeychainConfig,
|
||||
KeychainCallOptions,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const controller = new AbortController();
|
||||
const callOptions: KeychainCallOptions = { signal: controller.signal };
|
||||
|
||||
const tracingRunner: CommandExecutor = async (cmd, args, options) => {
|
||||
const child = await spawnHelper(cmd, args, options);
|
||||
return { stdout: child.stdout, stderr: child.stderr, exitCode: child.code };
|
||||
};
|
||||
|
||||
const config: KeychainConfig = {
|
||||
timeoutMs: 30_000,
|
||||
clock: () => Date.now(),
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
};
|
||||
void config;
|
||||
void tracingRunner;
|
||||
void callOptions;
|
||||
|
||||
declare function spawnHelper(
|
||||
cmd: string,
|
||||
args: readonly string[],
|
||||
options: { signal?: AbortSignal } | undefined
|
||||
): Promise<{ stdout: string; stderr: string; code: number }>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`CommandExecutor`** — `(command: string, args: readonly string[],
|
||||
options?: CommandExecutorOptions) => Promise<CommandExecutorResult>`. The
|
||||
bridge's only escape hatch into the OS. Implementations MUST pass arguments
|
||||
as an array.
|
||||
- **`CommandExecutorOptions.signal`** — optional `AbortSignal` propagated to
|
||||
the child.
|
||||
- **`CommandExecutorResult`** — `{ stdout: string; stderr: string; exitCode:
|
||||
number }`.
|
||||
- **`KeychainConfig`** — `{ serviceName?, accessGroup?, timeoutMs?, clock?,
|
||||
logger? }`. All optional. `timeoutMs` defaults to `10_000`; `clock` defaults
|
||||
to `() => Date.now()`.
|
||||
- **`KeychainItem`** — `{ service, account, password, label?, comment?,
|
||||
createdAt?, modifiedAt? }`. `password` is plaintext and the caller's
|
||||
responsibility once returned.
|
||||
- **`KeychainResult`** — `{ success: boolean; error?: string }`.
|
||||
- **`KeychainCallOptions`** — `{ signal?: AbortSignal }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `CommandExecutor` (constructed inside the bridge when none is
|
||||
injected) uses `Bun.spawn` if the global `Bun` is present, otherwise
|
||||
`node:child_process` `spawnFile`-style file launch. Neither path goes through
|
||||
a shell, which is why all argument types are arrays of strings — passing one
|
||||
composite string would defeat the safety property.
|
||||
- `Clock` is just `() => number`; tests can pass the `FakeClock` from
|
||||
`@suaveplan/testing` to advance the deadline deterministically.
|
||||
- `LoggerLike` is the structural contract from `@suaveplan/types`; loggers
|
||||
produced by `@suaveplan/logger` and any conforming custom logger work
|
||||
interchangeably.
|
||||
- The interfaces are intentionally `readonly` to discourage in-place mutation
|
||||
by callers who might otherwise alter records returned from `findItem`.
|
||||
Reference in New Issue
Block a user