@fonderie/cli 0.2.0 → 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/README.md CHANGED
@@ -27,6 +27,15 @@ at equal completion and quality** (`experiments/phase41-2026-07/`).
27
27
  for this capability": the package, the recipe, the wiring, and (if installed)
28
28
  the exact API. Zero resident schema tax — the agent runs it only when it needs
29
29
  discovery.
30
+ - **`fonderie add <recipe> [--project <dir>]`** — deterministically wire a
31
+ capability in one command: `npm install` the recipe's bricks, emit a
32
+ version-matched `src/fonderie.ts` composition (modules on `FonderieApp`,
33
+ migrations on boot) matching the maintained `example-express`, and set up
34
+ `.env.example`. Prints the one app-specific line (`mount`) it leaves to you.
35
+ A **correctness/DX** convenience — the emitted wiring is verified to typecheck
36
+ against the installed packages; it is *not* a token/turn saving (an
37
+ auth-session pilot found the wiring isn't the turn bottleneck — see
38
+ `experiments/phase41-2026-07/DISCOVERY-ADD-WIRING.md`).
30
39
 
31
40
  ## How it stays correct
32
41
 
package/bin/fonderie.mjs CHANGED
@@ -19,10 +19,14 @@
19
19
  import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
20
  import { join, dirname } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { spawnSync } from 'node:child_process';
22
23
 
23
24
  const here = dirname(fileURLToPath(import.meta.url));
24
25
  const pkgRoot = join(here, '..');
25
26
  const K = JSON.parse(readFileSync(join(pkgRoot, 'data/knowledge.json'), 'utf8'));
27
+ // Package scope — the @fonderiejs 1.0.0 launch flips this one line (see
28
+ // MIGRATION-FONDERIEJS.md); every scope reference below derives from it.
29
+ const SCOPE = '@fonderie';
26
30
  const CONCEPTS = Object.entries(K.concepts || {});
27
31
 
28
32
  const argv = process.argv.slice(2);
@@ -31,7 +35,7 @@ const arg = (f, d) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] :
31
35
 
32
36
  // installed @fonderie packages in a project (co-located fragments are the data)
33
37
  function installed(projectDir) {
34
- const dir = join(projectDir, 'node_modules', '@fonderie');
38
+ const dir = join(projectDir, 'node_modules', SCOPE);
35
39
  if (!existsSync(dir)) return [];
36
40
  return readdirSync(dir).sort()
37
41
  .filter((n) => existsSync(join(dir, n, 'package.json')))
@@ -57,7 +61,7 @@ function doQuery() {
57
61
  const projectDir = arg('--project', process.cwd());
58
62
  const inst = installed(projectDir).find((p) => p.name === c.package);
59
63
  console.log(`${id} — ${c.description}\n`);
60
- console.log(`Package: @fonderie/${c.package}${inst ? `@${inst.version} (installed)` : ' — run: npm install @fonderie/' + c.package}`);
64
+ console.log(`Package: ${SCOPE}/${c.package}${inst ? `@${inst.version} (installed)` : ` — run: npm install ${SCOPE}/${c.package}`}`);
61
65
  const recipe = c.recipe && K.recipes[c.recipe];
62
66
  if (recipe) {
63
67
  console.log(`Recipe: ${c.recipe} — ${recipe.when}`);
@@ -86,7 +90,7 @@ function doSkill() {
86
90
  for (const p of inst) {
87
91
  const fr = fragment(p);
88
92
  if (!fr.signatures && !fr.outcomes) continue;
89
- const body = [`# @fonderie/${p.name}@${p.version}`, '', fr.signatures, fr.outcomes ? '\n' + fr.outcomes : ''].join('\n').trim();
93
+ const body = [`# ${SCOPE}/${p.name}@${p.version}`, '', fr.signatures, fr.outcomes ? '\n' + fr.outcomes : ''].join('\n').trim();
90
94
  writeFileSync(join(outDir, 'fonderie', `${p.name}.md`), body + '\n');
91
95
  bodies++;
92
96
  }
@@ -104,7 +108,7 @@ function doSkill() {
104
108
  L.push('limiting, or config. Use the audited `@fonderie/*` brick. For the capability');
105
109
  L.push('your task needs, **read only that package\'s body** — do not load them all.');
106
110
  L.push('');
107
- L.push(`Installed here: ${inst.length ? inst.map((p) => `\`@fonderie/${p.name}@${p.version}\``).join(', ') : 'none yet'}.`);
111
+ L.push(`Installed here: ${inst.length ? inst.map((p) => `\`${SCOPE}/${p.name}@${p.version}\``).join(', ') : 'none yet'}.`);
108
112
  L.push('');
109
113
  L.push('## Capability → read this / or discover');
110
114
  L.push('');
@@ -120,6 +124,23 @@ function doSkill() {
120
124
  L.push('Do NOT stop to ask permission or abandon the task; adding the brick IS the task.');
121
125
  L.push('Pick sensible defaults for provider/config choices behind fail-fast env vars.');
122
126
  L.push('');
127
+ L.push('## Definition of done — you do NOT need a database to build');
128
+ L.push('');
129
+ L.push('A Fonderie app is **done** when it **typechecks** (`tsc` clean) and each');
130
+ L.push('capability is **wired per its recipe**. That is sufficient: the bricks are');
131
+ L.push('audited and own their schema — migrations ship inside the package and run');
132
+ L.push('automatically on boot, and each route is guaranteed by the package, not by');
133
+ L.push('your app. (Confirmed: a typecheck-clean wired app boots, self-migrates, and');
134
+ L.push('serves the brick routes with no hand-written glue.)');
135
+ L.push('');
136
+ L.push('While building:');
137
+ L.push('- Do NOT provision a database — no `docker run`, no `initdb`, no testcontainers.');
138
+ L.push('- Do NOT boot the app to "check it works": a clean typecheck + correct wiring');
139
+ L.push(' IS the check. Running it is a separate, optional step the operator does');
140
+ L.push(' later with a real `DATABASE_URL`.');
141
+ L.push('- The tables each brick creates are listed in its body — read them, do not');
142
+ L.push(' reverse-engineer them from a running database.');
143
+ L.push('');
123
144
  if (Object.keys(K.invariants || {}).length) {
124
145
  L.push('## Security invariants (always apply)');
125
146
  L.push('');
@@ -159,18 +180,238 @@ function doInit() {
159
180
  }
160
181
  }
161
182
 
183
+ // ── fonderie add <capability> — deterministic wiring in ONE command ─────────
184
+ // Instead of the agent reasoning through install → migrate → compose → mount
185
+ // across many turns, it runs ONE command that does the DETERMINISTIC part — npm
186
+ // install, a version-matched composition module, the env template, the migration
187
+ // step — driven by the recipe, not by LLM guessing. Emits code that matches the
188
+ // maintained example-express composition (the ground-truth wiring), verified to
189
+ // typecheck against the installed packages.
190
+ //
191
+ // Value: correctness + DX (one command → version-matched wiring that compiles),
192
+ // NOT a token/turn saving. We prototyped it as a turn-count lever and pre-
193
+ // registered a gate; the auth-session pilot (DISCOVERY-ADD-WIRING.md) found the
194
+ // wiring was ~1 command's worth of a ~62-turn session — the turns live in
195
+ // orientation + writing/testing the app's own surface, not the brick wiring —
196
+ // so it did NOT cut turns (62 vs a 61–81 baseline). Kept as a DX feature, not
197
+ // sold as an efficiency edge.
198
+ //
199
+ // Per-module wiring spec: import + construction + migrations + env. Deterministic
200
+ // and version-matched — the installed package ships the real API these lines call.
201
+ const MODULE_SPECS = {
202
+ // `order` = construction/registration priority. Lower first. auth depends on
203
+ // events.bus, so events (2) must precede auth (3) — the canonical example order.
204
+ store: { pkg: 'store', order: 0, import: `import { PGAdapter, MigrationRunner, InternalMigrationRunner } from '${SCOPE}/store';`, ctor: null, migrations: false },
205
+ core: { pkg: 'core', order: 1, import: `import { FonderieApp, defineConfig } from '${SCOPE}/core';`, ctor: null, migrations: false },
206
+ events: { pkg: 'events', order: 2, import: `import { EventsModule } from '${SCOPE}/events';\nimport { getMigrationsPath as evtMig } from '${SCOPE}/events/migrations';`,
207
+ varName: 'events', ctor: `const events = new EventsModule({ transport: { type: 'pg', connectionUrl: config.db.url } });`, migrations: 'evtMig()' },
208
+ auth: { pkg: 'auth', order: 3, import: `import { AuthModule } from '${SCOPE}/auth';\nimport { getMigrationsPath as authMig } from '${SCOPE}/auth/migrations';`,
209
+ varName: 'auth', ctor: `const auth = new AuthModule(store, {\n jwtSecret: process.env['JWT_SECRET'] ?? 'dev-secret-min-32-chars-long-here',\n appName: 'App',\n providers: ['email'],\n requireVerification: false,\n}, events.bus);`, migrations: 'authMig()',
210
+ env: { JWT_SECRET: 'dev-secret-min-32-chars-long-here' } },
211
+ };
212
+
213
+ function doAdd() {
214
+ const capability = argv[1];
215
+ const projectDir = arg('--project', process.cwd());
216
+ // resolve capability → recipe (accept a concept id, a recipe name, or a bare package)
217
+ let recipeName = capability;
218
+ if (K.concepts[capability]?.recipe) recipeName = K.concepts[capability].recipe;
219
+ const recipe = K.recipes[recipeName];
220
+ if (!recipe) {
221
+ console.error(`unknown capability "${capability ?? ''}". Try a concept (\`fonderie query --concepts\`) or a recipe: ${Object.keys(K.recipes).join(', ')}`);
222
+ process.exit(2);
223
+ }
224
+ // module set in dependency order (spec.order): store, core, then modules with
225
+ // deps before dependents (events before auth). Dedup, keep only recipe packages.
226
+ const wanted = [...new Set(['store', 'core', ...recipe.packages])];
227
+ const unknown = wanted.filter((p) => !MODULE_SPECS[p]);
228
+ const order = wanted.filter((p) => MODULE_SPECS[p]).sort((a, b) => MODULE_SPECS[a].order - MODULE_SPECS[b].order);
229
+ const specs = order.map((p) => MODULE_SPECS[p]);
230
+ if (unknown.length) {
231
+ console.error(`recipe "${recipeName}" needs packages this prototype can't wire yet: ${unknown.join(', ')}. Wired recipes: ${Object.entries(K.recipes).filter(([, r]) => r.packages.every((p) => MODULE_SPECS[p])).map(([n]) => n).join(', ') || '(none)'}`);
232
+ process.exit(3);
233
+ }
234
+ const pkgs = [...new Set(order)];
235
+ const registrable = specs.filter((s) => s.varName);
236
+
237
+ // 1) install (deterministic) — packages + the express adapter it mounts through
238
+ const installList = [...pkgs.map((p) => `${SCOPE}/${p}`), `${SCOPE}/adapter-express`];
239
+ console.log(`fonderie add ${recipeName}: installing ${installList.join(' ')} …`);
240
+ const npm = spawnSync('npm', ['install', ...installList], { cwd: projectDir, stdio: 'inherit' });
241
+ if (npm.status !== 0) { console.error('npm install failed — aborting before writing wiring.'); process.exit(1); }
242
+
243
+ // 2) emit the composition module (version-matched to what we just installed)
244
+ const migCalls = specs.filter((s) => s.migrations).map((s) => s.migrations);
245
+ const lines = [
246
+ `// GENERATED by \`fonderie add ${recipeName}\` — deterministic wiring. Safe to edit;`,
247
+ `// re-running \`fonderie add\` overwrites it. Composes the audited @fonderie bricks.`,
248
+ `import { fileURLToPath } from 'node:url';`,
249
+ `import { join } from 'node:path';`,
250
+ ...specs.map((s) => s.import),
251
+ ``,
252
+ `const __dirname = fileURLToPath(new URL('.', import.meta.url));`,
253
+ ``,
254
+ `const config = defineConfig({ db: { url: process.env['DATABASE_URL'] ?? 'postgres://localhost/app' } });`,
255
+ `export const store = new PGAdapter(config.db.url);`,
256
+ ``,
257
+ ...(migCalls.length ? [`for (const dir of [${migCalls.join(', ')}]) {`, ` await new InternalMigrationRunner(store, dir).run();`, `}`, ``] : []),
258
+ ...registrable.map((s) => s.ctor),
259
+ ``,
260
+ `export { config };`,
261
+ `export const fonderie = new FonderieApp(config)`,
262
+ ...registrable.map((s, i) => ` .register(${s.varName})${i === registrable.length - 1 ? ';' : ''}`),
263
+ ``,
264
+ `await fonderie.boot();`,
265
+ ``,
266
+ ];
267
+ mkdirSync(join(projectDir, 'src'), { recursive: true });
268
+ const compPath = join(projectDir, 'src/fonderie.ts');
269
+ writeFileSync(compPath, lines.join('\n'));
270
+ console.log(`✓ wrote src/fonderie.ts — composes ${registrable.map((s) => s.varName).join(' + ')} on FonderieApp.`);
271
+
272
+ // 3) env template (deterministic, from the spec + recipe invariants)
273
+ const envAdds = { DATABASE_URL: 'postgres://localhost/app', ...Object.assign({}, ...specs.map((s) => s.env || {})) };
274
+ const envPath = join(projectDir, '.env.example');
275
+ const existingEnv = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
276
+ let envOut = existingEnv;
277
+ for (const [k, v] of Object.entries(envAdds)) if (!new RegExp(`^${k}=`, 'm').test(envOut)) envOut += `${envOut && !envOut.endsWith('\n') ? '\n' : ''}${k}=${v}\n`;
278
+ if (envOut !== existingEnv) { writeFileSync(envPath, envOut); console.log(`✓ .env.example — added ${Object.keys(envAdds).join(', ')}`); }
279
+
280
+ // 4) the ONE app-specific line the agent still owns — printed, not guessed
281
+ console.log(`\nDone. Two lines left for your app entry (e.g. src/index.ts):`);
282
+ console.log(` import { mount } from '${SCOPE}/adapter-express';`);
283
+ console.log(` import { fonderie } from './fonderie';`);
284
+ console.log(` then wrap your express app: const app = mount(express(), fonderie);`);
285
+ for (const inv of recipe.invariants || []) if (K.invariants[inv]) console.log(` ⚠ ${K.invariants[inv]}`);
286
+ console.log(`\nMigrations run automatically on boot (InternalMigrationRunner above). Set DATABASE_URL and start the app.`);
287
+ }
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
+
162
393
  // ── dispatch ────────────────────────────────────────────────────────────────
163
394
  if (cmd === 'query') doQuery();
164
395
  else if (cmd === 'skill') doSkill();
396
+ else if (cmd === 'add') doAdd();
165
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); });
166
401
  else {
167
402
  console.log(`fonderie — the Fonderie CLI (lazy skills for coding agents)
168
403
 
169
404
  fonderie init [--project <dir>] set up the lazy skill + keep it fresh (postinstall)
405
+ fonderie add <capability> [--project <dir>] deterministically wire a brick: install + compose + migrate + env
170
406
  fonderie skill [--out <dir>] [--project <dir>] write the lazy skill (router + bodies)
171
407
  fonderie query <concept> what to install for a capability
172
408
  fonderie query --concepts list every capability
173
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
+
174
415
  Zero deps. No MCP server. A binary + markdown that runs in any agent harness.`);
175
416
  if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(2);
176
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');
@@ -77,4 +80,67 @@ run(['init', '--project', proj3]);
77
80
  const pj3 = JSON.parse(readFileSync(join(proj3, 'package.json'), 'utf8'));
78
81
  if (pj3.scripts.postinstall !== 'patch-package && fonderie skill') fail(`init did not chain existing postinstall (got: ${pj3.scripts.postinstall})`);
79
82
 
80
- console.log('fonderie CLI test: all assertions passed (skill, query installed/uninstalled, init wires idempotent fresh-keeping postinstall)');
83
+ // --- add: offline guards (the happy path installs from npm — not unit-tested) ---
84
+ // unknown capability → non-zero, lists the recipes (no network touched)
85
+ let addErr = '';
86
+ try { run(['add', 'not-a-recipe', '--project', proj]); fail('add accepted an unknown recipe'); }
87
+ catch (e) { addErr = String(e.stderr || e.stdout || ''); }
88
+ if (!/basic-auth/.test(addErr)) fail('add unknown-recipe error should list available recipes');
89
+ // help lists the add command
90
+ if (!/fonderie add <capability>/.test(run(['help']))) fail('help missing `fonderie add`');
91
+
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.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",