@fonderie/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fonderie.mjs CHANGED
@@ -286,11 +286,118 @@ function doAdd() {
286
286
  console.log(`\nMigrations run automatically on boot (InternalMigrationRunner above). Set DATABASE_URL and start the app.`);
287
287
  }
288
288
 
289
+ // ── fonderie config|secret <verb> — manage a live deployment over the admin API ─
290
+ // Thin client over @fonderie/config's /admin/* surface. Auth + endpoint come from
291
+ // the environment so the LLM never handles the token inline:
292
+ // FONDERIE_ADMIN_URL e.g. https://app.example.com
293
+ // FONDERIE_ADMIN_TOKEN the bootstrap admin token
294
+ // FONDERIE_ACTOR optional — recorded on writes (X-Actor)
295
+ async function adminFetch(method, path, body) {
296
+ const base = process.env.FONDERIE_ADMIN_URL;
297
+ const token = process.env.FONDERIE_ADMIN_TOKEN;
298
+ if (!base || !token) {
299
+ console.error('Set FONDERIE_ADMIN_URL and FONDERIE_ADMIN_TOKEN to manage a deployment.');
300
+ process.exit(1);
301
+ }
302
+ const headers = { authorization: `Bearer ${token}` };
303
+ if (body !== undefined) headers['content-type'] = 'application/json';
304
+ if (process.env.FONDERIE_ACTOR) headers['x-actor'] = process.env.FONDERIE_ACTOR;
305
+ const res = await fetch(base.replace(/\/$/, '') + path, {
306
+ method,
307
+ headers,
308
+ body: body !== undefined ? JSON.stringify(body) : undefined,
309
+ });
310
+ const json = await res.json().catch(() => ({}));
311
+ if (res.status >= 400) {
312
+ console.error(json.explanation || json.reason || `HTTP ${res.status}`);
313
+ if (json.details) console.error(JSON.stringify(json.details));
314
+ process.exit(res.status === 409 ? 2 : 1); // 409 conflict → exit 2 (reload + retry)
315
+ }
316
+ const result = json.result;
317
+ console.log(typeof result === 'string' ? result : JSON.stringify(result, null, 2));
318
+ }
319
+
320
+ // The supported management verbs (kubectl-inspired) — the single source of truth
321
+ // for the CLI's config/secret surface. Each maps to a method + optional path
322
+ // suffix + what it requires (a key, a body value, a --to-version).
323
+ const VERBS = {
324
+ get: { method: 'GET', needsKey: false },
325
+ set: { method: 'PUT', needsKey: true, needsValue: true },
326
+ delete: { method: 'DELETE', needsKey: true },
327
+ history: { method: 'GET', needsKey: true, suffix: '/revisions' },
328
+ rollback: { method: 'POST', needsKey: true, suffix: '/rollback', needsToVersion: true },
329
+ reveal: { method: 'POST', needsKey: true, suffix: '/reveal', secretOnly: true },
330
+ };
331
+
332
+ // config `value` is parsed (so `set flag true` stores a boolean); secrets stay raw strings.
333
+ function parseValue(raw) {
334
+ try { return JSON.parse(raw); } catch { return raw; }
335
+ }
336
+
337
+ // Per-resource scope/value shape. config & secret scope by environment and carry
338
+ // a `value`; templates scope by locale and carry `text` (+ optional subject/html).
339
+ const RESOURCE_SHAPE = {
340
+ config: { scopeFlag: '--env', scopeParam: 'environment', valueKey: 'value' },
341
+ secret: { scopeFlag: '--env', scopeParam: 'environment', valueKey: 'value', rawValue: true },
342
+ template: { scopeFlag: '--locale', scopeParam: 'locale', valueKey: 'text' },
343
+ };
344
+
345
+ async function resourceCmd(resource, base) {
346
+ const shape = RESOURCE_SHAPE[resource];
347
+ const verb = argv[1];
348
+ const spec = VERBS[verb];
349
+ if (!spec || (spec.secretOnly && resource !== 'secret')) return usageErr(resource);
350
+
351
+ const key = argv[2];
352
+ if (spec.needsKey && !key) return usageErr(resource);
353
+
354
+ const scope = arg(shape.scopeFlag, undefined);
355
+ const q = scope ? `?${shape.scopeParam}=${encodeURIComponent(scope)}` : '';
356
+ const enc = (k) => encodeURIComponent(k);
357
+
358
+ const path = spec.needsKey || key
359
+ ? `${base}/${enc(key)}${spec.suffix ?? ''}${q}`
360
+ : `${base}${q}`;
361
+
362
+ let body;
363
+ if (spec.needsValue) {
364
+ const raw = argv[3];
365
+ if (raw === undefined) { console.error(`usage: fonderie ${resource} set <key> <value>`); process.exit(1); }
366
+ body = { [shape.valueKey]: shape.rawValue ? String(raw) : parseValue(raw) };
367
+ if (scope) body[shape.scopeParam] = scope;
368
+ if (resource === 'template') {
369
+ const subject = arg('--subject', undefined);
370
+ const html = arg('--html', undefined);
371
+ if (subject !== undefined) body.subject = subject;
372
+ if (html !== undefined) body.html = html;
373
+ }
374
+ const ifVersion = arg('--if-version', undefined);
375
+ if (ifVersion !== undefined) body.ifVersion = Number(ifVersion);
376
+ } else if (spec.needsToVersion) {
377
+ const toVersion = Number(arg('--to-version'));
378
+ if (!Number.isInteger(toVersion)) { console.error('rollback needs --to-version <n>'); process.exit(1); }
379
+ body = { toVersion };
380
+ if (scope) body[shape.scopeParam] = scope;
381
+ }
382
+
383
+ return adminFetch(spec.method, path, body);
384
+ }
385
+
386
+ function usageErr(resource) {
387
+ const verbs = Object.keys(VERBS).filter((v) => !VERBS[v].secretOnly || resource === 'secret');
388
+ const scopeFlag = (RESOURCE_SHAPE[resource] ?? { scopeFlag: '--env' }).scopeFlag;
389
+ console.error(`usage: fonderie ${resource} <${verbs.join('|')}> [key] [value] [${scopeFlag} <s>] [--if-version <n>] [--to-version <n>]`);
390
+ process.exit(1);
391
+ }
392
+
289
393
  // ── dispatch ────────────────────────────────────────────────────────────────
290
394
  if (cmd === 'query') doQuery();
291
395
  else if (cmd === 'skill') doSkill();
292
396
  else if (cmd === 'add') doAdd();
293
397
  else if (cmd === 'init') doInit();
398
+ else if (cmd === 'config') resourceCmd('config', '/admin/config').catch((e) => { console.error(e.message); process.exit(1); });
399
+ else if (cmd === 'secret') resourceCmd('secret', '/admin/secrets').catch((e) => { console.error(e.message); process.exit(1); });
400
+ else if (cmd === 'template') resourceCmd('template', '/admin/templates').catch((e) => { console.error(e.message); process.exit(1); });
294
401
  else {
295
402
  console.log(`fonderie — the Fonderie CLI (lazy skills for coding agents)
296
403
 
@@ -300,6 +407,11 @@ else {
300
407
  fonderie query <concept> what to install for a capability
301
408
  fonderie query --concepts list every capability
302
409
 
410
+ fonderie config <get|set|delete|history|rollback> [key] [value] [--env <e>] [--if-version <n>] [--to-version <n>]
411
+ fonderie secret <get|set|delete|history|rollback|reveal> [key] [value] [--env <e>] ...
412
+ fonderie template <get|set|delete|history|rollback> [type] [text] [--locale <l>] [--subject <s>] [--html <h>] ...
413
+ manage a live deployment over its admin API — set FONDERIE_ADMIN_URL + FONDERIE_ADMIN_TOKEN
414
+
303
415
  Zero deps. No MCP server. A binary + markdown that runs in any agent harness.`);
304
416
  if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(2);
305
417
  }
@@ -4,11 +4,14 @@
4
4
  // asserts `skill` writes a router + bodies, and `query` answers correctly.
5
5
  // Zero deps; exits non-zero on failure.
6
6
 
7
- import { execFileSync } from 'node:child_process';
7
+ import { execFileSync, execFile } from 'node:child_process';
8
8
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
9
9
  import { join, dirname } from 'node:path';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { createServer } from 'node:http';
13
+ import { promisify } from 'node:util';
14
+ const execFileP = promisify(execFile);
12
15
 
13
16
  const here = dirname(fileURLToPath(import.meta.url));
14
17
  const bin = join(here, 'fonderie.mjs');
@@ -86,4 +89,58 @@ if (!/basic-auth/.test(addErr)) fail('add unknown-recipe error should list avail
86
89
  // help lists the add command
87
90
  if (!/fonderie add <capability>/.test(run(['help']))) fail('help missing `fonderie add`');
88
91
 
89
- console.log('fonderie CLI test: all assertions passed (skill, query installed/uninstalled, init wires idempotent fresh-keeping postinstall, add guards)');
92
+ // ── config/secret management commands (thin client over the admin API) ──────
93
+ // Uses async execFile so the in-process http fixture can respond (execFileSync
94
+ // would block the event loop and deadlock the server).
95
+ await (async () => {
96
+ const requests = [];
97
+ const server = createServer((req, res) => {
98
+ let raw = '';
99
+ req.on('data', (c) => { raw += c; });
100
+ req.on('end', () => {
101
+ requests.push({ method: req.method, url: req.url, auth: req.headers.authorization, actor: req.headers['x-actor'], body: raw ? JSON.parse(raw) : undefined });
102
+ if (req.url.includes('conflict')) {
103
+ res.writeHead(409, { 'content-type': 'application/json' });
104
+ res.end(JSON.stringify({ reason: 'VERSION_CONFLICT', explanation: 'stale', details: { currentVersion: 5 } }));
105
+ return;
106
+ }
107
+ res.writeHead(200, { 'content-type': 'application/json' });
108
+ res.end(JSON.stringify({ reason: 'OK', explanation: 'ok', result: { ok: true } }));
109
+ });
110
+ });
111
+ await new Promise((r) => server.listen(0, r));
112
+ const port = server.address().port;
113
+ const env = { ...process.env, FONDERIE_ADMIN_URL: `http://127.0.0.1:${port}`, FONDERIE_ADMIN_TOKEN: 'sekret', FONDERIE_ACTOR: 'ada' };
114
+ const cli = (args) => execFileP('node', [bin, ...args], { env });
115
+
116
+ await cli(['config', 'get']);
117
+ await cli(['config', 'set', 'feature.x', 'true']);
118
+ await cli(['config', 'history', 'feature.x']);
119
+ await cli(['config', 'rollback', 'feature.x', '--to-version', '2']);
120
+ await cli(['secret', 'reveal', 'stripe.key', '--env', 'prod']);
121
+
122
+ const find = (m, u) => requests.find((r) => r.method === m && r.url === u);
123
+ if (!find('GET', '/admin/config')?.auth?.includes('sekret')) fail('config get: wrong request/auth');
124
+ const set = find('PUT', '/admin/config/feature.x');
125
+ if (set?.body?.value !== true) fail('config set: value should parse to boolean true');
126
+ if (set?.actor !== 'ada') fail('config set: X-Actor not sent');
127
+ if (!find('GET', '/admin/config/feature.x/revisions')) fail('config history: wrong path');
128
+ if (find('POST', '/admin/config/feature.x/rollback')?.body?.toVersion !== 2) fail('rollback: toVersion body wrong');
129
+ if (!find('POST', '/admin/secrets/stripe.key/reveal?environment=prod')) fail('secret reveal: wrong path');
130
+
131
+ // 409 conflict → exit 2 (reload + retry)
132
+ let code = 0;
133
+ try { await cli(['config', 'set', 'conflict', 'x']); } catch (e) { code = e.code; }
134
+ if (code !== 2) fail(`409 conflict should exit 2, got ${code}`);
135
+
136
+ // missing env → exit 1 with guidance
137
+ let noEnvErr = '';
138
+ try { await execFileP('node', [bin, 'config', 'get'], { env: { ...process.env, FONDERIE_ADMIN_URL: '', FONDERIE_ADMIN_TOKEN: '' } }); }
139
+ catch (e) { noEnvErr = String(e.stderr || ''); }
140
+ if (!/FONDERIE_ADMIN_URL/.test(noEnvErr)) fail('missing-env should hint FONDERIE_ADMIN_URL');
141
+
142
+ server.close();
143
+ console.log(' ✓ config/secret management commands (get/set/history/rollback/reveal, 409→exit2, env guard)');
144
+ })();
145
+
146
+ console.log('fonderie CLI test: all assertions passed (skill, query installed/uninstalled, init wires idempotent fresh-keeping postinstall, add guards, config/secret management)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The Fonderie CLI — teaches any coding agent the SDK without loading it eagerly. `fonderie skill` writes a lazy skill (a small router + per-package bodies read on demand); `fonderie query` answers what to install for a capability. Zero deps, runs anywhere.",
5
5
  "keywords": [
6
6
  "fonderie-js",