@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 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
+ }
@@ -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.8.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",