Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions COOKBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,18 @@ 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.

**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
```
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions alfred-live/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion alfred-live/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T>` 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.
138 changes: 138 additions & 0 deletions alfred-live/bin/alfredctl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/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 <id>] [--auth <token>]
alfredctl read <path> [--id <id>] [--auth <token>]
alfredctl write <path> <value> [--id <id>] [--auth <token>]

Options:
--id <id> Command id (default: random UUID)
--auth <token> Optional auth token
-h, --help Show this help
`;

function fail(message) {
process.stderr.write(`${message}\n\n${usage.trim()}\n`);
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 = [];

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) {
const redacted = redactSensitive(encoded.error.details);
process.stderr.write(`${JSON.stringify(redacted, null, 2)}\n`);
}
process.exit(1);
}

process.stdout.write(`${encoded.data}\n`);
35 changes: 35 additions & 0 deletions alfred-live/examples/control-plane/jsonl-channel.js
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion alfred-live/jsr.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
8 changes: 6 additions & 2 deletions alfred-live/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand All @@ -31,6 +34,7 @@
"url": "git+https://github.com/git-stunts/alfred.git"
},
"files": [
"bin",
"src",
"README.md",
"LICENSE",
Expand Down
Loading