@fonderie/cli 0.8.0 → 0.9.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 +169 -0
- package/bin/fonderie.test.mjs +55 -1
- package/package.json +1 -1
package/bin/fonderie.mjs
CHANGED
|
@@ -477,6 +477,7 @@ else if (cmd === 'init') doInit();
|
|
|
477
477
|
else if (cmd === 'config') resourceCmd('config', '/admin/config').catch((e) => { console.error(e.message); process.exit(1); });
|
|
478
478
|
else if (cmd === 'secret') resourceCmd('secret', '/admin/secrets').catch((e) => { console.error(e.message); process.exit(1); });
|
|
479
479
|
else if (cmd === 'template') resourceCmd('template', '/admin/templates').catch((e) => { console.error(e.message); process.exit(1); });
|
|
480
|
+
else if (cmd === 'migrate') doMigrate().catch((e) => { console.error(e.message); process.exit(1); });
|
|
480
481
|
else if (cmd === 'admin') adminCmd().catch((e) => { console.error(e.message); process.exit(1); });
|
|
481
482
|
else {
|
|
482
483
|
console.log(`fonderie — the Fonderie CLI (lazy skills for coding agents)
|
|
@@ -487,6 +488,12 @@ else {
|
|
|
487
488
|
fonderie query <concept> what to install for a capability
|
|
488
489
|
fonderie query --concepts list every capability
|
|
489
490
|
|
|
491
|
+
fonderie migrate --status [--app <dir>] what is pending, and what each one will do
|
|
492
|
+
fonderie migrate --check [--app <dir>] exit 1 if a pending migration deletes data (CI gate)
|
|
493
|
+
reports only — the app's own runner applies, because the ORDER is the app's
|
|
494
|
+
to declare. In CI: fonderie migrate --check && npm run migrate
|
|
495
|
+
DATABASE_URL must be the SESSION or DIRECT url, never the transaction pooler
|
|
496
|
+
|
|
490
497
|
fonderie config <get|set|delete|history|rollback> [key] [value] [--env <e>] [--if-version <n>] [--to-version <n>]
|
|
491
498
|
fonderie secret <get|set|delete|history|rollback|reveal> [key] [value] [--env <e>] ...
|
|
492
499
|
fonderie template <get|set|delete|history|rollback> [type] [text] [--locale <l>] [--subject <s>] [--html <h>] ...
|
|
@@ -501,3 +508,165 @@ else {
|
|
|
501
508
|
Zero deps. No MCP server. A binary + markdown that runs in any agent harness.`);
|
|
502
509
|
if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(2);
|
|
503
510
|
}
|
|
511
|
+
|
|
512
|
+
// ── migrate ─────────────────────────────────────────────────────────────────
|
|
513
|
+
//
|
|
514
|
+
// fonderie migrate --status what is pending, and what each one will do
|
|
515
|
+
// fonderie migrate --check exit 1 if any pending migration is destructive
|
|
516
|
+
//
|
|
517
|
+
// APPLYING IS NOT HERE, deliberately. The order migrations run in is the app's
|
|
518
|
+
// to declare — it interleaves brick migrations with its own (LeadEasyGen's app
|
|
519
|
+
// tables reference auth's), and a CLI guessing that order would eventually
|
|
520
|
+
// guess wrong in a way that only shows up on a fresh database. The app already
|
|
521
|
+
// has a runner that knows its order; this reports on it and gates it.
|
|
522
|
+
//
|
|
523
|
+
// The CI shape this is built for:
|
|
524
|
+
//
|
|
525
|
+
// fonderie migrate --check && npm run migrate
|
|
526
|
+
//
|
|
527
|
+
// --check fails the deploy on a destructive migration until someone approves
|
|
528
|
+
// it, because "the pipeline ran it" is no better than "the boot ran it" — it is
|
|
529
|
+
// further from a human, not closer.
|
|
530
|
+
//
|
|
531
|
+
// Zero deps preserved: @fonderie/store is resolved from the APP's node_modules
|
|
532
|
+
// (this runs inside a consuming app, where it is already installed), never
|
|
533
|
+
// added as a dependency of the CLI.
|
|
534
|
+
// classifyMigration landed in a later @fonderie/store; against an older one the
|
|
535
|
+
// command still lists migrations, it just cannot label their impact.
|
|
536
|
+
const classify = (store, sql) =>
|
|
537
|
+
typeof store.classifyMigration === 'function'
|
|
538
|
+
? store.classifyMigration(sql)
|
|
539
|
+
: { impact: 'unknown', destructive: [] };
|
|
540
|
+
|
|
541
|
+
async function doMigrate() {
|
|
542
|
+
const cwd = arg('--project', process.cwd());
|
|
543
|
+
const appDir = arg('--app', null);
|
|
544
|
+
const dry = argv.includes('--dry-run');
|
|
545
|
+
const url = process.env['DATABASE_URL'];
|
|
546
|
+
// --dry-run answers "what would these migrations do", which needs no database:
|
|
547
|
+
// it classifies every file rather than only the pending ones. Useful reviewing
|
|
548
|
+
// a PR, and it is how discovery is tested without standing a server up.
|
|
549
|
+
if (!url && !dry) {
|
|
550
|
+
console.error('migrate: set DATABASE_URL.');
|
|
551
|
+
console.error(' In CI use the SESSION or DIRECT Postgres URL, not the transaction');
|
|
552
|
+
console.error(' pooler — a pooler lends a backend per transaction.');
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const { pathToFileURL } = await import('node:url');
|
|
557
|
+
const scope = join(cwd, 'node_modules', '@fonderie');
|
|
558
|
+
|
|
559
|
+
// These packages are ESM-only: their exports maps carry "import" but no
|
|
560
|
+
// "require", so createRequire().resolve() answers ERR_PACKAGE_PATH_NOT_EXPORTED
|
|
561
|
+
// for every one of them. Read the map and join the path instead.
|
|
562
|
+
const resolveExport = (pkg, sub) => {
|
|
563
|
+
try {
|
|
564
|
+
const pj = JSON.parse(readFileSync(join(scope, pkg, 'package.json'), 'utf8'));
|
|
565
|
+
const ent = pj.exports?.[sub];
|
|
566
|
+
const rel = typeof ent === 'string' ? ent : (ent?.import ?? ent?.default);
|
|
567
|
+
return rel ? join(scope, pkg, rel) : null;
|
|
568
|
+
} catch { return null; }
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const storeEntry = resolveExport('store', '.');
|
|
572
|
+
let store;
|
|
573
|
+
try {
|
|
574
|
+
store = await import(pathToFileURL(storeEntry).href);
|
|
575
|
+
} catch {
|
|
576
|
+
console.error(`migrate: @fonderie/store is not installed in ${cwd}.`);
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Every installed brick that ships migrations, plus the app's own. Order is
|
|
581
|
+
// irrelevant here: this reports and classifies, it does not apply.
|
|
582
|
+
const dirs = [];
|
|
583
|
+
if (existsSync(scope)) {
|
|
584
|
+
for (const pkg of readdirSync(scope).sort()) {
|
|
585
|
+
const entry = resolveExport(pkg, './migrations');
|
|
586
|
+
if (!entry) continue; // no migrations subpath — most packages
|
|
587
|
+
try {
|
|
588
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
589
|
+
if (typeof mod.getMigrationsPath === 'function') dirs.push([pkg, mod.getMigrationsPath()]);
|
|
590
|
+
} catch { /* unreadable — skip rather than fail the whole report */ }
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (appDir) dirs.push(['app', join(cwd, appDir)]);
|
|
594
|
+
if (dirs.length === 0) {
|
|
595
|
+
console.error('migrate: found no migrations. Pass --app <dir> for the app’s own.');
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (dry) {
|
|
600
|
+
let flagged = 0;
|
|
601
|
+
for (const [name, dir] of dirs) {
|
|
602
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
|
603
|
+
if (files.length === 0) continue;
|
|
604
|
+
console.log(`\n${name}: ${files.length} migration(s)`);
|
|
605
|
+
for (const file of files) {
|
|
606
|
+
const { impact, destructive: stmts } = classify(store, readFileSync(join(dir, file), 'utf8'));
|
|
607
|
+
if (impact === 'destructive') {
|
|
608
|
+
flagged++;
|
|
609
|
+
console.log(` ✖ ${file} DESTRUCTIVE`);
|
|
610
|
+
for (const st of stmts) console.log(` ${st.slice(0, 100)}`);
|
|
611
|
+
} else {
|
|
612
|
+
console.log(` · ${file}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
console.log(`\n${flagged} of the migrations found delete data.`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const adapter = new store.PGAdapter(url);
|
|
621
|
+
let destructive = 0;
|
|
622
|
+
let pendingTotal = 0;
|
|
623
|
+
// A database with nothing applied yet has no data to lose: brick history
|
|
624
|
+
// legitimately drops columns earlier migrations in the same set created
|
|
625
|
+
// (billing's 004 drops the workspace_id its 001 added). Flagging those would
|
|
626
|
+
// refuse every first-time install, which is the wrong end of the trade.
|
|
627
|
+
let everApplied = false;
|
|
628
|
+
try {
|
|
629
|
+
const scanned = [];
|
|
630
|
+
for (const [name, dir] of dirs) {
|
|
631
|
+
const pending = await new store.InternalMigrationRunner(adapter, dir).pending();
|
|
632
|
+
const total = readdirSync(dir).filter((f) => f.endsWith('.sql')).length;
|
|
633
|
+
if (pending.length < total) everApplied = true;
|
|
634
|
+
scanned.push([name, dir, pending]);
|
|
635
|
+
}
|
|
636
|
+
for (const [name, dir, pending] of scanned) {
|
|
637
|
+
if (pending.length === 0) continue;
|
|
638
|
+
pendingTotal += pending.length;
|
|
639
|
+
console.log(`\n${name}: ${pending.length} pending`);
|
|
640
|
+
for (const file of pending) {
|
|
641
|
+
const sql = readFileSync(join(dir, file), 'utf8');
|
|
642
|
+
const { impact, destructive: stmts } = classify(store, sql);
|
|
643
|
+
if (impact === 'destructive' && everApplied) {
|
|
644
|
+
destructive++;
|
|
645
|
+
console.log(` ✖ ${file} DESTRUCTIVE`);
|
|
646
|
+
for (const s of stmts) console.log(` ${s.slice(0, 100)}`);
|
|
647
|
+
} else {
|
|
648
|
+
console.log(` · ${file}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
// The pool holds the event loop open; without this the CLI never exits.
|
|
654
|
+
await adapter.end();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (pendingTotal === 0) {
|
|
658
|
+
console.log('up to date — nothing pending.');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
console.log(
|
|
662
|
+
everApplied
|
|
663
|
+
? `\n${pendingTotal} pending, ${destructive} destructive.`
|
|
664
|
+
: `\n${pendingTotal} pending — first-time setup, nothing to lose.`,
|
|
665
|
+
);
|
|
666
|
+
if (destructive > 0 && argv.includes('--check')) {
|
|
667
|
+
console.error('\nRefusing: a pending migration deletes data. No down-migration');
|
|
668
|
+
console.error('brings it back — a recreated empty table is not a rollback. Take a');
|
|
669
|
+
console.error('backup, then approve this deploy explicitly.');
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
}
|
package/bin/fonderie.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Zero deps; exits non-zero on failure.
|
|
6
6
|
|
|
7
7
|
import { execFileSync, execFile } from 'node:child_process';
|
|
8
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync } 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';
|
|
@@ -93,6 +93,60 @@ if (!/basic-auth/.test(addErr)) fail('add unknown-recipe error should list avail
|
|
|
93
93
|
// help lists the add command
|
|
94
94
|
if (!/fonderie add <capability>/.test(run(['help']))) fail('help missing `fonderie add`');
|
|
95
95
|
|
|
96
|
+
// --- migrate: the guard that needs no database ---
|
|
97
|
+
// Without DATABASE_URL it must refuse AND say which connection mode to use.
|
|
98
|
+
// The pooler advice is the point: a transaction pooler lends a backend per
|
|
99
|
+
// statement, and pointing a migration at one is the mistake this text exists
|
|
100
|
+
// to prevent. Applying is NOT tested here — it needs a live database, and the
|
|
101
|
+
// ordering it would apply belongs to the app, not this CLI.
|
|
102
|
+
let migErr = '';
|
|
103
|
+
try {
|
|
104
|
+
run(['migrate', '--status'], { env: { ...process.env, DATABASE_URL: '' } });
|
|
105
|
+
fail('migrate ran without DATABASE_URL');
|
|
106
|
+
} catch (e) { migErr = String(e.stderr || e.stdout || ''); }
|
|
107
|
+
if (!/DATABASE_URL/.test(migErr)) fail('migrate should name DATABASE_URL when it is unset');
|
|
108
|
+
if (!/pooler/i.test(migErr)) fail('migrate should warn against the transaction pooler');
|
|
109
|
+
if (!/fonderie migrate/.test(run(['help']))) fail('help missing `fonderie migrate`');
|
|
110
|
+
console.log(' ✓ migrate guards (DATABASE_URL required, pooler warning, help listed)');
|
|
111
|
+
|
|
112
|
+
// --- migrate --dry-run: discovery + classification, no database ---
|
|
113
|
+
// This is the path that was broken first time round. These packages are
|
|
114
|
+
// ESM-ONLY — exports maps carry "import" but no "require" — so
|
|
115
|
+
// createRequire().resolve() answers ERR_PACKAGE_PATH_NOT_EXPORTED for every
|
|
116
|
+
// brick, inside a catch, and the command silently discovered nothing. The fake
|
|
117
|
+
// brick below is deliberately shaped that way so a regression fails here.
|
|
118
|
+
{
|
|
119
|
+
const mp = mkdtempSync(join(tmpdir(), 'fonderie-migrate-'));
|
|
120
|
+
const brick = join(mp, 'node_modules', '@fonderie', 'demo');
|
|
121
|
+
const sqlDir = join(brick, 'dist', 'migrations', 'sql');
|
|
122
|
+
mkdirSync(sqlDir, { recursive: true });
|
|
123
|
+
writeFileSync(join(mp, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }));
|
|
124
|
+
writeFileSync(
|
|
125
|
+
join(brick, 'package.json'),
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
name: '@fonderie/demo',
|
|
128
|
+
type: 'module',
|
|
129
|
+
exports: { './migrations': { types: './dist/migrations/index.d.ts', import: './dist/migrations/index.js' } },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
writeFileSync(
|
|
133
|
+
join(brick, 'dist', 'migrations', 'index.js'),
|
|
134
|
+
`import { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nexport const getMigrationsPath = () => join(dirname(fileURLToPath(import.meta.url)), 'sql');\n`,
|
|
135
|
+
);
|
|
136
|
+
writeFileSync(join(sqlDir, '001_create.sql'), 'CREATE TABLE demo (id int);');
|
|
137
|
+
writeFileSync(join(sqlDir, '002_drop.sql'), 'DROP TABLE demo CASCADE;');
|
|
138
|
+
// The real store, so classification is the shipped one and not a stub.
|
|
139
|
+
symlinkSync(join(here, '..', '..', 'store'), join(mp, 'node_modules', '@fonderie', 'store'), 'dir');
|
|
140
|
+
|
|
141
|
+
const outDry = run(['migrate', '--dry-run', '--project', mp], { env: { ...process.env, DATABASE_URL: '' } });
|
|
142
|
+
if (!/demo: 2 migration/.test(outDry)) fail('dry-run did not discover the ESM-only brick: ' + outDry);
|
|
143
|
+
if (!/001_create\.sql/.test(outDry)) fail('dry-run missed the additive migration');
|
|
144
|
+
if (!/✖ 002_drop\.sql\s+DESTRUCTIVE/.test(outDry)) fail('dry-run did not flag the drop: ' + outDry);
|
|
145
|
+
if (!/DROP TABLE demo CASCADE/.test(outDry)) fail('dry-run should print the statement that earned the label');
|
|
146
|
+
if (!/1 of the migrations found delete data/.test(outDry)) fail('dry-run summary wrong: ' + outDry);
|
|
147
|
+
console.log(' ✓ migrate --dry-run (ESM-only exports discovered, drop flagged with its statement)');
|
|
148
|
+
}
|
|
149
|
+
|
|
96
150
|
// ── config/secret management commands (thin client over the admin API) ──────
|
|
97
151
|
// Uses async execFile so the in-process http fixture can respond (execFileSync
|
|
98
152
|
// would block the event loop and deadlock the server).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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
|
"fonderiejs",
|