From 5e19703fcbe50c0d5b56e022e6a2c5097d629c80 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 05:52:42 -0800 Subject: [PATCH 1/8] feat(alfred-live): add jsonl command channel --- CHANGELOG.md | 15 + COOKBOOK.md | 11 +- README.md | 2 +- alfred-live/CHANGELOG.md | 9 + alfred-live/README.md | 54 +++- alfred-live/bin/alfredctl.js | 118 +++++++ .../examples/control-plane/jsonl-channel.js | 35 ++ alfred-live/jsr.json | 2 +- alfred-live/package.json | 8 +- alfred-live/src/command-envelope.js | 301 ++++++++++++++++++ alfred-live/src/index.d.ts | 65 ++++ alfred-live/src/index.js | 9 + .../test/unit/command-envelope.test.js | 89 ++++++ alfred/CHANGELOG.md | 6 + alfred/jsr.json | 2 +- alfred/package.json | 2 +- 16 files changed, 718 insertions(+), 10 deletions(-) create mode 100755 alfred-live/bin/alfredctl.js create mode 100644 alfred-live/examples/control-plane/jsonl-channel.js create mode 100644 alfred-live/src/command-envelope.js create mode 100644 alfred-live/test/unit/command-envelope.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 39075b7..0f8d728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-02-04 (@git-stunts/alfred) + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.9.1] - 2026-02-04 (@git-stunts/alfred) ### Changed @@ -162,6 +168,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-02-04 (@git-stunts/alfred-live) + +### Added + +- Canonical JSONL command envelope with strict validation helpers. +- Result envelope helpers plus JSONL encode/decode utilities. +- `alfredctl` CLI for emitting JSONL commands. +- JSONL command channel example and tests. + ## [0.9.1] - 2026-02-04 (@git-stunts/alfred-live) ### Changed diff --git a/COOKBOOK.md b/COOKBOOK.md index 46bc6b9..3c7064b 100644 --- a/COOKBOOK.md +++ b/COOKBOOK.md @@ -104,7 +104,7 @@ router.execute({ type: 'list_config', prefix: 'bulkhead' }); --- -## Recipe: Control plane CLI (planned) +## Recipe: Control plane CLI (JSONL) **Goal** Send control plane commands from a CLI. @@ -112,5 +112,10 @@ Send control plane commands from a CLI. **Packages** - `@git-stunts/alfred-live` -**Status** -Planned for v0.10 as `alfredctl`. +**Example** + +```bash +alfredctl list retry +alfredctl read retry/count +alfredctl write retry/count 5 +``` diff --git a/README.md b/README.md index 9297ce2..bcd3c0c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Alfred is a **policy engine** for async resilience: composable, observable, test ## Packages - `@git-stunts/alfred` — Resilience policies and composition utilities for async operations. -- `@git-stunts/alfred-live` — In-memory control plane primitives plus live policy plans. +- `@git-stunts/alfred-live` — Control plane primitives, live policy plans, and the `alfredctl` CLI. ## Versioning Policy diff --git a/alfred-live/CHANGELOG.md b/alfred-live/CHANGELOG.md index 1be55a7..91eade3 100644 --- a/alfred-live/CHANGELOG.md +++ b/alfred-live/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-02-04 + +### Added + +- Canonical JSONL command envelope with strict validation helpers. +- Result envelope helpers plus JSONL encode/decode utilities. +- `alfredctl` CLI for emitting JSONL commands. +- JSONL command channel example and tests. + ## [0.9.1] - 2026-02-04 ### Changed diff --git a/alfred-live/README.md b/alfred-live/README.md index 76fa072..ceee531 100644 --- a/alfred-live/README.md +++ b/alfred-live/README.md @@ -65,6 +65,55 @@ router.execute({ type: 'write_config', path: 'retry/count', value: '5' }); router.execute({ type: 'list_config', prefix: 'retry' }); ``` +## Command Channel (JSONL) + +Alfred Live exposes a canonical JSONL envelope for sending commands over +stdin/stdout. Use the helper functions to decode, validate, and execute. + +```javascript +import { + CommandRouter, + ConfigRegistry, + executeCommandLine, + decodeCommandEnvelope, + encodeCommandEnvelope, +} from '@git-stunts/alfred-live'; + +const registry = new ConfigRegistry(); +const router = new CommandRouter(registry); + +const line = JSON.stringify({ + id: 'cmd-1', + cmd: 'list_config', + args: { prefix: 'retry' }, +}); + +const decoded = decodeCommandEnvelope(line); +if (!decoded.ok) throw new Error(decoded.error.message); + +const resultLine = executeCommandLine(router, line); +if (!resultLine.ok) throw new Error(resultLine.error.message); +console.log(resultLine.data); + +const outgoing = encodeCommandEnvelope({ + id: 'cmd-2', + cmd: 'read_config', + args: { path: 'retry/count' }, +}); +if (outgoing.ok) console.log(outgoing.data); +``` + +## CLI (`alfredctl`) + +`alfredctl` emits JSONL commands to stdout. Pipe its output into your control +plane transport (stdin/stdout, ssh, etc.). + +```bash +alfredctl list retry +alfredctl read retry/count +alfredctl write retry/count 5 +``` + ## Live Policies Live policies are described with a `LivePolicyPlan` and then bound to a registry @@ -121,13 +170,16 @@ registry.write('gateway/api/retry/retries', '5'); ## Examples - `alfred-live/examples/control-plane/basic.js` — in-process registry + command router usage. +- `alfred-live/examples/control-plane/jsonl-channel.js` — JSONL command envelope execution. - `alfred-live/examples/control-plane/live-policies.js` — live policy wrappers driven by registry state. ## Status -v0.9.0 live policies implemented: +v0.10.0 control plane primitives implemented: - `Adaptive` live values with version + updatedAt. - `ConfigRegistry` for typed config and validation. - Command router for `read_config`, `write_config`, `list_config`. - `LivePolicyPlan` + `ControlPlane.registerLivePolicy` for live policy stacks. +- Canonical JSONL command envelope + helpers. +- `alfredctl` CLI for emitting JSONL commands. diff --git a/alfred-live/bin/alfredctl.js b/alfred-live/bin/alfredctl.js new file mode 100755 index 0000000..b6e339e --- /dev/null +++ b/alfred-live/bin/alfredctl.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +import { randomUUID } from 'node:crypto'; +import { encodeCommandEnvelope } from '../src/command-envelope.js'; + +const usage = `alfredctl - emit Alfred Live control-plane commands (JSONL) + +Usage: + alfredctl list [prefix] [--id ] [--auth ] + alfredctl read [--id ] [--auth ] + alfredctl write [--id ] [--auth ] + +Options: + --id Command id (default: random UUID) + --auth Optional auth token + -h, --help Show this help +`; + +function fail(message) { + process.stderr.write(`${message}\n\n${usage.trim()}\n`); + process.exit(1); +} + +function parseArgs(argv) { + const options = { id: undefined, auth: undefined }; + const positionals = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === '--') { + positionals.push(...argv.slice(i + 1)); + break; + } + + if (arg === '-h' || arg === '--help') { + return { help: true, options, positionals }; + } + + if (arg === '--id' || arg === '--auth') { + const value = argv[i + 1]; + if (!value) { + fail(`Missing value for ${arg}.`); + } + if (arg === '--id') { + options.id = value; + } else { + options.auth = value; + } + i += 1; + continue; + } + + if (arg.startsWith('--')) { + fail(`Unknown option: ${arg}`); + } + + positionals.push(arg); + } + + return { help: false, options, positionals }; +} + +function buildEnvelope(positionals, options) { + if (positionals.length === 0) { + fail('Missing command.'); + } + + const [command, ...rest] = positionals; + const id = options.id ?? randomUUID(); + const auth = options.auth; + + switch (command) { + case 'list': { + if (rest.length > 1) { + fail('list accepts at most one prefix argument.'); + } + const args = rest[0] ? { prefix: rest[0] } : {}; + return { id, cmd: 'list_config', args, auth }; + } + case 'read': { + if (rest.length !== 1) { + fail('read requires exactly one path argument.'); + } + return { id, cmd: 'read_config', args: { path: rest[0] }, auth }; + } + case 'write': { + if (rest.length !== 2) { + fail('write requires a path and value argument.'); + } + return { id, cmd: 'write_config', args: { path: rest[0], value: rest[1] }, auth }; + } + default: + fail(`Unknown command: ${command}`); + return null; + } +} + +const parsed = parseArgs(process.argv.slice(2)); +if (parsed.help) { + process.stdout.write(`${usage.trim()}\n`); + process.exit(0); +} + +const envelope = buildEnvelope(parsed.positionals, parsed.options); +if (!envelope) { + process.exit(1); +} +const encoded = encodeCommandEnvelope(envelope); +if (!encoded.ok) { + process.stderr.write(`${encoded.error.code}: ${encoded.error.message}\n`); + if (encoded.error.details) { + process.stderr.write(`${JSON.stringify(encoded.error.details, null, 2)}\n`); + } + process.exit(1); +} + +process.stdout.write(`${encoded.data}\n`); diff --git a/alfred-live/examples/control-plane/jsonl-channel.js b/alfred-live/examples/control-plane/jsonl-channel.js new file mode 100644 index 0000000..6135ff5 --- /dev/null +++ b/alfred-live/examples/control-plane/jsonl-channel.js @@ -0,0 +1,35 @@ +import { + Adaptive, + CommandRouter, + ConfigRegistry, + executeCommandLine, +} from '@git-stunts/alfred-live'; + +const registry = new ConfigRegistry(); +const retryCount = new Adaptive(3); + +registry.register('retry/count', retryCount, { + parse: (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error('retry/count must be a number'); + } + return parsed; + }, + format: (value) => value.toString(), +}); + +const router = new CommandRouter(registry); + +const line = JSON.stringify({ + id: 'cmd-1', + cmd: 'write_config', + args: { path: 'retry/count', value: '5' }, +}); + +const resultLine = executeCommandLine(router, line); +if (!resultLine.ok) { + throw new Error(resultLine.error.message); +} + +console.log(resultLine.data); diff --git a/alfred-live/jsr.json b/alfred-live/jsr.json index d316aee..5833f38 100644 --- a/alfred-live/jsr.json +++ b/alfred-live/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.9.1", + "version": "0.10.0", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "license": "Apache-2.0", "exports": { diff --git a/alfred-live/package.json b/alfred-live/package.json index 10b5825..33e7d57 100644 --- a/alfred-live/package.json +++ b/alfred-live/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.9.1", + "version": "0.10.0", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "type": "module", "sideEffects": false, @@ -12,8 +12,11 @@ "default": "./src/index.js" } }, + "bin": { + "alfredctl": "./bin/alfredctl.js" + }, "dependencies": { - "@git-stunts/alfred": "0.9.1" + "@git-stunts/alfred": "0.10.0" }, "engines": { "node": ">=20.0.0" @@ -31,6 +34,7 @@ "url": "git+https://github.com/git-stunts/alfred.git" }, "files": [ + "bin", "src", "README.md", "LICENSE", diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js new file mode 100644 index 0000000..0ae315a --- /dev/null +++ b/alfred-live/src/command-envelope.js @@ -0,0 +1,301 @@ +import { + ErrorCode, + InvalidCommandError, + ValidationError, + errorResult, + okResult, +} from './errors.js'; + +const COMMANDS = Object.freeze({ + read_config: { + required: ['path'], + optional: [], + }, + write_config: { + required: ['path', 'value'], + optional: [], + }, + list_config: { + required: [], + optional: ['prefix'], + }, +}); + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value, allowed) { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function validateString(value, label) { + if (typeof value !== 'string' || value.trim().length === 0) { + return new ValidationError(`${label} must be a non-empty string.`, { value }); + } + return null; +} + +function validateCommandArgs(cmd, args) { + const spec = COMMANDS[cmd]; + if (!spec) { + return new InvalidCommandError('Unknown command.', { cmd }); + } + + if (!isPlainObject(args)) { + return new ValidationError('args must be an object.', { args }); + } + + const allowed = [...spec.required, ...spec.optional]; + if (!hasOnlyKeys(args, allowed)) { + return new InvalidCommandError('Unknown args provided.', { cmd, args }); + } + + for (const key of spec.required) { + const error = validateString(args[key], `args.${key}`); + if (error) { + return error; + } + } + + for (const key of spec.optional) { + if (args[key] !== undefined) { + const error = validateString(args[key], `args.${key}`); + if (error) { + return error; + } + } + } + + return null; +} + +function normalizeEnvelope(envelope) { + return { + id: envelope.id, + cmd: envelope.cmd, + args: envelope.args ?? {}, + auth: envelope.auth, + }; +} + +/** + * Validate a command envelope. + * @param {unknown} envelope + * @returns {{ ok: true, data: import('./index.d.ts').CommandEnvelope } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +export function validateCommandEnvelope(envelope) { + if (!isPlainObject(envelope)) { + return errorResult(new InvalidCommandError('Envelope must be an object.')); + } + + const allowedKeys = ['id', 'cmd', 'args', 'auth']; + if (!hasOnlyKeys(envelope, allowedKeys)) { + return errorResult(new InvalidCommandError('Unknown envelope fields.', { envelope })); + } + + const idError = validateString(envelope.id, 'id'); + if (idError) { + return errorResult(idError); + } + + const cmdError = validateString(envelope.cmd, 'cmd'); + if (cmdError) { + return errorResult(cmdError); + } + + if (!COMMANDS[envelope.cmd]) { + return errorResult(new InvalidCommandError('Unknown command.', { cmd: envelope.cmd })); + } + + const args = envelope.args ?? {}; + const argsError = validateCommandArgs(envelope.cmd, args); + if (argsError) { + return errorResult(argsError); + } + + if (envelope.auth !== undefined) { + const authError = validateString(envelope.auth, 'auth'); + if (authError) { + return errorResult(authError); + } + } + + return okResult( + normalizeEnvelope({ + id: envelope.id, + cmd: envelope.cmd, + args, + auth: envelope.auth, + }) + ); +} + +/** + * Decode a JSONL command envelope. + * @param {string} line + * @returns {{ ok: true, data: import('./index.d.ts').CommandEnvelope } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +export function decodeCommandEnvelope(line) { + if (typeof line !== 'string' || line.trim().length === 0) { + return errorResult(new InvalidCommandError('Command line must be a JSON object.')); + } + + let payload; + try { + payload = JSON.parse(line); + } catch (error) { + return errorResult( + new InvalidCommandError('Command line is not valid JSON.', { error: String(error) }) + ); + } + + return validateCommandEnvelope(payload); +} + +/** + * Encode a command envelope as JSONL. + * @param {import('./index.d.ts').CommandEnvelope} envelope + * @returns {{ ok: true, data: string } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +export function encodeCommandEnvelope(envelope) { + const validation = validateCommandEnvelope(envelope); + if (!validation.ok) { + return validation; + } + + return okResult(JSON.stringify(validation.data)); +} + +function commandFromEnvelope(envelope) { + switch (envelope.cmd) { + case 'read_config': + return { type: 'read_config', path: envelope.args.path }; + case 'write_config': + return { type: 'write_config', path: envelope.args.path, value: envelope.args.value }; + case 'list_config': + return { type: 'list_config', prefix: envelope.args.prefix }; + default: + return null; + } +} + +function validateResultEnvelope(envelope) { + if (!isPlainObject(envelope)) { + return new InvalidCommandError('Result envelope must be an object.'); + } + + const allowedKeys = ['id', 'ok', 'data', 'error']; + if (!hasOnlyKeys(envelope, allowedKeys)) { + return new InvalidCommandError('Unknown result envelope fields.', { envelope }); + } + + const idError = validateString(envelope.id, 'id'); + if (idError) { + return idError; + } + + if (typeof envelope.ok !== 'boolean') { + return new ValidationError('ok must be a boolean.', { ok: envelope.ok }); + } + + if (envelope.ok) { + if (Object.prototype.hasOwnProperty.call(envelope, 'error')) { + return new ValidationError('error must be omitted when ok is true.'); + } + } else if (Object.prototype.hasOwnProperty.call(envelope, 'data')) { + return new ValidationError('data must be omitted when ok is false.'); + } else if (!isPlainObject(envelope.error)) { + return new ValidationError('error must be an object when ok is false.'); + } else { + const codeError = validateString(envelope.error.code, 'error.code'); + if (codeError) { + return codeError; + } + const messageError = validateString(envelope.error.message, 'error.message'); + if (messageError) { + return messageError; + } + } + + return null; +} + +/** + * Build a result envelope for JSONL output. + * @param {string} id + * @param {{ ok: true, data: unknown } | { ok: false, error: { code: string, message: string, details?: unknown } }} result + * @returns {{ id: string, ok: true, data: unknown } | { id: string, ok: false, error: { code: string, message: string, details?: unknown } }} + */ +export function buildResultEnvelope(id, result) { + if (result.ok) { + return { id, ok: true, data: result.data }; + } + return { + id, + ok: false, + error: result.error ?? { code: ErrorCode.INTERNAL_ERROR, message: 'Unexpected error.' }, + }; +} + +/** + * Encode a result envelope as JSONL. + * @param {{ id: string, ok: true, data: unknown } | { id: string, ok: false, error: { code: string, message: string, details?: unknown } }} envelope + * @returns {{ ok: true, data: string } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +export function encodeResultEnvelope(envelope) { + const error = validateResultEnvelope(envelope); + if (error) { + return errorResult(error); + } + return okResult(JSON.stringify(envelope)); +} + +/** + * Execute a validated command envelope using a router. + * @param {import('./router.js').CommandRouter} router + * @param {import('./index.d.ts').CommandEnvelope} envelope + * @returns {import('./index.d.ts').ResultEnvelope} + */ +export function executeCommandEnvelope(router, envelope) { + let command; + try { + command = commandFromEnvelope(envelope); + } catch (error) { + return buildResultEnvelope(envelope.id, errorResult(error)); + } + + if (!command) { + return buildResultEnvelope( + envelope.id, + errorResult(new InvalidCommandError('Unknown command.', { cmd: envelope.cmd })) + ); + } + + let result; + try { + result = router.execute(command); + } catch (error) { + result = errorResult(error); + } + + return buildResultEnvelope(envelope.id, result); +} + +/** + * Decode, validate, and execute a JSONL command line. + * @param {import('./router.js').CommandRouter} router + * @param {string} line + * @param {{ fallbackId?: string }} [options] + * @returns {{ ok: true, data: string } | { ok: false, error: { code: string, message: string, details?: unknown } }} + */ +export function executeCommandLine(router, line, options = {}) { + const fallbackId = options.fallbackId ?? 'unknown'; + const decoded = decodeCommandEnvelope(line); + if (!decoded.ok) { + return encodeResultEnvelope(buildResultEnvelope(fallbackId, decoded)); + } + + const resultEnvelope = executeCommandEnvelope(router, decoded.data); + return encodeResultEnvelope(resultEnvelope); +} diff --git a/alfred-live/src/index.d.ts b/alfred-live/src/index.d.ts index d69924d..d0f837c 100644 --- a/alfred-live/src/index.d.ts +++ b/alfred-live/src/index.d.ts @@ -254,6 +254,71 @@ export type Command = ReadConfigCommand | WriteConfigCommand | ListConfigCommand */ export type CommandResult = Result | Result; +/** + * JSONL command envelope. + */ +export type CommandEnvelope = + | { id: string; cmd: 'read_config'; args: { path: string }; auth?: string } + | { + id: string; + cmd: 'write_config'; + args: { path: string; value: string }; + auth?: string; + } + | { id: string; cmd: 'list_config'; args: { prefix?: string }; auth?: string }; + +/** + * JSONL result envelope. + */ +export type ResultEnvelope = + | { id: string; ok: true; data: ConfigSnapshot | string[] } + | { id: string; ok: false; error: ErrorShape }; + +/** + * Validate a command envelope. + */ +export function validateCommandEnvelope(envelope: unknown): Result; + +/** + * Decode a JSONL command envelope. + */ +export function decodeCommandEnvelope(line: string): Result; + +/** + * Encode a command envelope as JSONL. + */ +export function encodeCommandEnvelope(envelope: CommandEnvelope): Result; + +/** + * Build a result envelope. + */ +export function buildResultEnvelope( + id: string, + result: Result +): ResultEnvelope; + +/** + * Encode a result envelope as JSONL. + */ +export function encodeResultEnvelope(envelope: ResultEnvelope): Result; + +/** + * Execute a command envelope using a router. + */ +export function executeCommandEnvelope( + router: CommandRouter, + envelope: CommandEnvelope +): ResultEnvelope; + +/** + * Decode and execute a JSONL command line. + */ +export function executeCommandLine( + router: CommandRouter, + line: string, + options?: { fallbackId?: string } +): Result; + /** * Executes control-plane commands against a ConfigRegistry. */ diff --git a/alfred-live/src/index.js b/alfred-live/src/index.js index 3098c8d..c29b10d 100644 --- a/alfred-live/src/index.js +++ b/alfred-live/src/index.js @@ -12,6 +12,15 @@ export { Adaptive } from './adaptive.js'; export { ConfigRegistry } from './registry.js'; export { CommandRouter } from './router.js'; +export { + buildResultEnvelope, + decodeCommandEnvelope, + encodeCommandEnvelope, + encodeResultEnvelope, + executeCommandEnvelope, + executeCommandLine, + validateCommandEnvelope, +} from './command-envelope.js'; export { LivePolicyPlan, ControlPlane } from './policy.js'; export { ErrorCode, diff --git a/alfred-live/test/unit/command-envelope.test.js b/alfred-live/test/unit/command-envelope.test.js new file mode 100644 index 0000000..53adce4 --- /dev/null +++ b/alfred-live/test/unit/command-envelope.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; + +import { Adaptive } from '../../src/adaptive.js'; +import { CommandRouter } from '../../src/router.js'; +import { ConfigRegistry } from '../../src/registry.js'; +import { + decodeCommandEnvelope, + encodeCommandEnvelope, + executeCommandLine, +} from '../../src/command-envelope.js'; + +function parseJsonLine(line) { + return JSON.parse(line); +} + +describe('command envelope', () => { + it('round-trips encode/decode', () => { + const envelope = { + id: 'cmd-1', + cmd: 'read_config', + args: { path: 'retry/count' }, + }; + + const encoded = encodeCommandEnvelope(envelope); + expect(encoded.ok).toBe(true); + + const decoded = decodeCommandEnvelope(encoded.data); + expect(decoded.ok).toBe(true); + expect(decoded.data).toEqual(envelope); + }); + + it('rejects unknown fields', () => { + const line = JSON.stringify({ + id: 'cmd-2', + cmd: 'list_config', + args: {}, + extra: true, + }); + + const decoded = decodeCommandEnvelope(line); + expect(decoded.ok).toBe(false); + if (!decoded.ok) { + expect(decoded.error.code).toBe('INVALID_COMMAND'); + } + }); + + it('executes JSONL commands and returns a result envelope', () => { + const registry = new ConfigRegistry(); + const limit = new Adaptive(10); + registry.register('bulkhead/limit', limit, { + parse: (value) => Number(value), + format: (value) => value.toString(), + }); + + const router = new CommandRouter(registry); + + const encoded = encodeCommandEnvelope({ + id: 'cmd-3', + cmd: 'write_config', + args: { path: 'bulkhead/limit', value: '5' }, + }); + + if (!encoded.ok) throw new Error(encoded.error.message); + + const resultLine = executeCommandLine(router, encoded.data); + expect(resultLine.ok).toBe(true); + if (!resultLine.ok) return; + + const result = parseJsonLine(resultLine.data); + expect(result.id).toBe('cmd-3'); + expect(result.ok).toBe(true); + expect(result.data.path).toBe('bulkhead/limit'); + expect(result.data.formatted).toBe('5'); + }); + + it('returns error envelopes for invalid JSON lines', () => { + const registry = new ConfigRegistry(); + const router = new CommandRouter(registry); + + const resultLine = executeCommandLine(router, '{"bad":'); + expect(resultLine.ok).toBe(true); + if (!resultLine.ok) return; + + const result = parseJsonLine(resultLine.data); + expect(result.id).toBe('unknown'); + expect(result.ok).toBe(false); + expect(result.error.code).toBe('INVALID_COMMAND'); + }); +}); diff --git a/alfred/CHANGELOG.md b/alfred/CHANGELOG.md index 3a35e7b..9d8e364 100644 --- a/alfred/CHANGELOG.md +++ b/alfred/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-02-04 + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.9.1] - 2026-02-04 ### Changed diff --git a/alfred/jsr.json b/alfred/jsr.json index ebd8260..aae75e2 100644 --- a/alfred/jsr.json +++ b/alfred/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.9.1", + "version": "0.10.0", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "license": "Apache-2.0", "exports": { diff --git a/alfred/package.json b/alfred/package.json index 4a410fc..0699cd5 100644 --- a/alfred/package.json +++ b/alfred/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.9.1", + "version": "0.10.0", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "type": "module", "sideEffects": false, From c16adb5670fb85dbaa84adbed28cd6b2c527dae5 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 05:53:44 -0800 Subject: [PATCH 2/8] chore(alfred-live): simplify result validation --- alfred-live/src/command-envelope.js | 53 ++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js index 0ae315a..18746f3 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -180,7 +180,7 @@ function commandFromEnvelope(envelope) { } } -function validateResultEnvelope(envelope) { +function validateResultEnvelopeShape(envelope) { if (!isPlainObject(envelope)) { return new InvalidCommandError('Result envelope must be an object.'); } @@ -199,28 +199,47 @@ function validateResultEnvelope(envelope) { return new ValidationError('ok must be a boolean.', { ok: envelope.ok }); } - if (envelope.ok) { - if (Object.prototype.hasOwnProperty.call(envelope, 'error')) { - return new ValidationError('error must be omitted when ok is true.'); - } - } else if (Object.prototype.hasOwnProperty.call(envelope, 'data')) { + return null; +} + +function validateOkResultEnvelope(envelope) { + if (Object.prototype.hasOwnProperty.call(envelope, 'error')) { + return new ValidationError('error must be omitted when ok is true.'); + } + return null; +} + +function validateErrorResultEnvelope(envelope) { + if (Object.prototype.hasOwnProperty.call(envelope, 'data')) { return new ValidationError('data must be omitted when ok is false.'); - } else if (!isPlainObject(envelope.error)) { + } + if (!isPlainObject(envelope.error)) { return new ValidationError('error must be an object when ok is false.'); - } else { - const codeError = validateString(envelope.error.code, 'error.code'); - if (codeError) { - return codeError; - } - const messageError = validateString(envelope.error.message, 'error.message'); - if (messageError) { - return messageError; - } } - + const codeError = validateString(envelope.error.code, 'error.code'); + if (codeError) { + return codeError; + } + const messageError = validateString(envelope.error.message, 'error.message'); + if (messageError) { + return messageError; + } return null; } +function validateResultEnvelope(envelope) { + const baseError = validateResultEnvelopeShape(envelope); + if (baseError) { + return baseError; + } + + if (envelope.ok) { + return validateOkResultEnvelope(envelope); + } + + return validateErrorResultEnvelope(envelope); +} + /** * Build a result envelope for JSONL output. * @param {string} id From cca89fb290b4f53ab683d921e54568961c598921 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:12:42 -0800 Subject: [PATCH 3/8] chore(release): enforce frozen lockfile on preflight --- README.md | 1 + scripts/hooks/pre-push | 14 ++++++++++++++ scripts/release/preflight.mjs | 7 +++++++ 3 files changed, 22 insertions(+) diff --git a/README.md b/README.md index bcd3c0c..e2d056f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ The publish flow runs `release:preflight`, which verifies: - `jsr.json` versions match `package.json`. - `package.json` exports exist on disk and are included in `files`. - `jsr.json` exports match `package.json` exports. +- `pnpm install --frozen-lockfile` succeeds (lockfile matches specs). - `npm pack --dry-run` succeeds for each published package. ## Repo Layout diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 23cc653..4164993 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -1,3 +1,17 @@ #!/bin/sh +set -e + echo "Running pre-push checks..." pnpm run lint && pnpm test + +BASE_REF="@{u}" +if ! git rev-parse --abbrev-ref --symbolic-full-name "${BASE_REF}" >/dev/null 2>&1; then + BASE_REF="origin/main" +fi + +CHANGED_FILES=$(git diff --name-only "${BASE_REF}"...HEAD) + +if echo "${CHANGED_FILES}" | grep -E '(^|/)(package\.json|jsr\.json)$' >/dev/null 2>&1; then + echo "Detected package metadata changes; running release preflight..." + pnpm run release:preflight +fi diff --git a/scripts/release/preflight.mjs b/scripts/release/preflight.mjs index 6e21897..ddab2a8 100644 --- a/scripts/release/preflight.mjs +++ b/scripts/release/preflight.mjs @@ -122,6 +122,13 @@ function reportErrors(errors) { process.exit(1); } +const installResult = spawnSync('pnpm', ['install', '--frozen-lockfile'], { + stdio: 'inherit', +}); +if (installResult.status !== 0) { + process.exit(installResult.status ?? 1); +} + const packageDirs = getWorkspacePackageDirs(); const errors = []; From 0b659456b9477f24393abd8efb4509265b925b4f Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:16:58 -0800 Subject: [PATCH 4/8] fix(alfredctl): redact sensitive error details --- alfred-live/bin/alfredctl.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/alfred-live/bin/alfredctl.js b/alfred-live/bin/alfredctl.js index b6e339e..d39fcb3 100755 --- a/alfred-live/bin/alfredctl.js +++ b/alfred-live/bin/alfredctl.js @@ -21,6 +21,25 @@ function fail(message) { process.exit(1); } +function redactSensitive(value) { + if (Array.isArray(value)) { + return value.map((entry) => redactSensitive(entry)); + } + if (!value || typeof value !== 'object') { + return value; + } + + const redacted = {}; + for (const [key, entry] of Object.entries(value)) { + if (/auth|token|password/i.test(key)) { + redacted[key] = '[REDACTED]'; + } else { + redacted[key] = redactSensitive(entry); + } + } + return redacted; +} + function parseArgs(argv) { const options = { id: undefined, auth: undefined }; const positionals = []; @@ -110,7 +129,8 @@ const encoded = encodeCommandEnvelope(envelope); if (!encoded.ok) { process.stderr.write(`${encoded.error.code}: ${encoded.error.message}\n`); if (encoded.error.details) { - process.stderr.write(`${JSON.stringify(encoded.error.details, null, 2)}\n`); + const redacted = redactSensitive(encoded.error.details); + process.stderr.write(`${JSON.stringify(redacted, null, 2)}\n`); } process.exit(1); } From 77629979bd76eeb94bdf2570317c8d71cfce74c5 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:17:35 -0800 Subject: [PATCH 5/8] fix(alfred-live): require data on ok results --- alfred-live/src/command-envelope.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js index 18746f3..5d61c0d 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -206,6 +206,9 @@ function validateOkResultEnvelope(envelope) { if (Object.prototype.hasOwnProperty.call(envelope, 'error')) { return new ValidationError('error must be omitted when ok is true.'); } + if (!Object.prototype.hasOwnProperty.call(envelope, 'data') || envelope.data == null) { + return new ValidationError('data is required when ok is true.'); + } return null; } From e77444aaaf119db1176ba4980b08f36e39769a57 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:18:05 -0800 Subject: [PATCH 6/8] fix(alfred-live): satisfy ok result data check --- alfred-live/src/command-envelope.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/alfred-live/src/command-envelope.js b/alfred-live/src/command-envelope.js index 5d61c0d..096e814 100644 --- a/alfred-live/src/command-envelope.js +++ b/alfred-live/src/command-envelope.js @@ -206,7 +206,11 @@ function validateOkResultEnvelope(envelope) { if (Object.prototype.hasOwnProperty.call(envelope, 'error')) { return new ValidationError('error must be omitted when ok is true.'); } - if (!Object.prototype.hasOwnProperty.call(envelope, 'data') || envelope.data == null) { + if ( + !Object.prototype.hasOwnProperty.call(envelope, 'data') || + envelope.data === null || + envelope.data === undefined + ) { return new ValidationError('data is required when ok is true.'); } return null; From 2ec6c997f1dc2486b9acd9ecec8a3b942d32c929 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 06:19:05 -0800 Subject: [PATCH 7/8] chore(lockfile): sync workspace versions --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b0730b..c182906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: alfred-live: dependencies: '@git-stunts/alfred': - specifier: 0.9.1 + specifier: 0.10.0 version: link:../alfred devDependencies: '@eslint/js': From 46f731e97d808db71e42b6bd551ed8543710ed09 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 07:22:27 -0800 Subject: [PATCH 8/8] chore(hooks): harden pre-push ref lookup --- scripts/hooks/pre-push | 5 ++++- scripts/release/preflight.mjs | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 4164993..27a97cc 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -8,8 +8,11 @@ BASE_REF="@{u}" if ! git rev-parse --abbrev-ref --symbolic-full-name "${BASE_REF}" >/dev/null 2>&1; then BASE_REF="origin/main" fi +if ! git rev-parse --verify "${BASE_REF}" >/dev/null 2>&1; then + BASE_REF="HEAD" +fi -CHANGED_FILES=$(git diff --name-only "${BASE_REF}"...HEAD) +CHANGED_FILES=$(git diff --name-only "${BASE_REF}"...HEAD 2>/dev/null || echo "") if echo "${CHANGED_FILES}" | grep -E '(^|/)(package\.json|jsr\.json)$' >/dev/null 2>&1; then echo "Detected package metadata changes; running release preflight..." diff --git a/scripts/release/preflight.mjs b/scripts/release/preflight.mjs index ddab2a8..b9a5b00 100644 --- a/scripts/release/preflight.mjs +++ b/scripts/release/preflight.mjs @@ -125,6 +125,13 @@ function reportErrors(errors) { const installResult = spawnSync('pnpm', ['install', '--frozen-lockfile'], { stdio: 'inherit', }); +if (installResult.error) { + console.error( + '\nPreflight failed to run pnpm install:', + installResult.error?.message ?? installResult.error + ); + process.exit(installResult.status ?? 1); +} if (installResult.status !== 0) { process.exit(installResult.status ?? 1); }