@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 +112 -0
- package/bin/fonderie.test.mjs +59 -2
- package/package.json +1 -1
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
|
}
|
package/bin/fonderie.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|