@fonderie/cli 0.1.0 → 0.2.1
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 +133 -4
- package/bin/fonderie.test.mjs +10 -1
- 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,14 +180,122 @@ 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
|
+
|
|
162
289
|
// ── dispatch ────────────────────────────────────────────────────────────────
|
|
163
290
|
if (cmd === 'query') doQuery();
|
|
164
291
|
else if (cmd === 'skill') doSkill();
|
|
292
|
+
else if (cmd === 'add') doAdd();
|
|
165
293
|
else if (cmd === 'init') doInit();
|
|
166
294
|
else {
|
|
167
295
|
console.log(`fonderie — the Fonderie CLI (lazy skills for coding agents)
|
|
168
296
|
|
|
169
297
|
fonderie init [--project <dir>] set up the lazy skill + keep it fresh (postinstall)
|
|
298
|
+
fonderie add <capability> [--project <dir>] deterministically wire a brick: install + compose + migrate + env
|
|
170
299
|
fonderie skill [--out <dir>] [--project <dir>] write the lazy skill (router + bodies)
|
|
171
300
|
fonderie query <concept> what to install for a capability
|
|
172
301
|
fonderie query --concepts list every capability
|
package/bin/fonderie.test.mjs
CHANGED
|
@@ -77,4 +77,13 @@ run(['init', '--project', proj3]);
|
|
|
77
77
|
const pj3 = JSON.parse(readFileSync(join(proj3, 'package.json'), 'utf8'));
|
|
78
78
|
if (pj3.scripts.postinstall !== 'patch-package && fonderie skill') fail(`init did not chain existing postinstall (got: ${pj3.scripts.postinstall})`);
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// --- add: offline guards (the happy path installs from npm — not unit-tested) ---
|
|
81
|
+
// unknown capability → non-zero, lists the recipes (no network touched)
|
|
82
|
+
let addErr = '';
|
|
83
|
+
try { run(['add', 'not-a-recipe', '--project', proj]); fail('add accepted an unknown recipe'); }
|
|
84
|
+
catch (e) { addErr = String(e.stderr || e.stdout || ''); }
|
|
85
|
+
if (!/basic-auth/.test(addErr)) fail('add unknown-recipe error should list available recipes');
|
|
86
|
+
// help lists the add command
|
|
87
|
+
if (!/fonderie add <capability>/.test(run(['help']))) fail('help missing `fonderie add`');
|
|
88
|
+
|
|
89
|
+
console.log('fonderie CLI test: all assertions passed (skill, query installed/uninstalled, init wires idempotent fresh-keeping postinstall, add guards)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|