@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 +9 -0
- package/bin/fonderie.mjs +245 -4
- package/bin/fonderie.test.mjs +68 -2
- package/package.json +1 -1
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',
|
|
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:
|
|
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 = [`#
|
|
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) =>
|
|
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
|
}
|
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');
|
|
@@ -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
|
-
|
|
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.
|
|
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",
|