@fonderie/cli 0.7.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 +184 -0
- package/bin/fonderie.test.mjs +60 -1
- package/package.json +1 -1
package/bin/fonderie.mjs
CHANGED
|
@@ -332,6 +332,7 @@ const ADMIN_PAGES = {
|
|
|
332
332
|
catalog: { path: '/catalog', about: 'plans as configured and as stored' },
|
|
333
333
|
subscriber:{ path: '/subscriptions', about: 'admin subscriber <user|workspace> <id> [subscription|wallet|ledger]' },
|
|
334
334
|
audit: { path: '/audit', about: 'events across every workspace (--workspace, --type, --actor, --from, --to, --limit, --cursor)' },
|
|
335
|
+
token: { path: '/access/tokens', about: 'admin token issue <name> --scopes read[,write[,secrets]] [--days <n>] · admin token revoke <id> (root token only)' },
|
|
335
336
|
};
|
|
336
337
|
async function adminCmd() {
|
|
337
338
|
const pageName = argv[1];
|
|
@@ -356,6 +357,19 @@ async function adminCmd() {
|
|
|
356
357
|
const qs = q.toString();
|
|
357
358
|
return adminFetch(sub[0], base + sub[1] + (qs ? `?${qs}` : ''));
|
|
358
359
|
}
|
|
360
|
+
if (pageName === 'token') {
|
|
361
|
+
const verb = argv[2];
|
|
362
|
+
if (verb === 'issue') {
|
|
363
|
+
const name = argv[3]; const scopes = (arg('--scopes', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const days = arg('--days', undefined);
|
|
364
|
+
if (!name || scopes.length === 0) { console.error('fonderie admin token issue <name> --scopes read[,write[,secrets]] [--days <n>]'); process.exit(2); }
|
|
365
|
+
return adminFetch('POST', `${prefix}/access/tokens`, { name, scopes, ...(days ? { expiresInDays: Number(days) } : {}) });
|
|
366
|
+
}
|
|
367
|
+
if (verb === 'revoke') {
|
|
368
|
+
const id = argv[3]; if (!id) { console.error('fonderie admin token revoke <id>'); process.exit(2); }
|
|
369
|
+
return adminFetch('DELETE', `${prefix}/access/tokens/${encodeURIComponent(id)}`);
|
|
370
|
+
}
|
|
371
|
+
console.error('fonderie admin token <issue|revoke> …'); process.exit(2);
|
|
372
|
+
}
|
|
359
373
|
if (pageName === 'subscriber') {
|
|
360
374
|
const type = argv[2]; const id = argv[3]; const verb = argv[4] ?? 'subscription';
|
|
361
375
|
if ((type !== 'user' && type !== 'workspace') || !id) { console.error('fonderie admin subscriber <user|workspace> <id> [subscription|wallet|ledger] [--currency <c>] [--limit <n>]'); process.exit(2); }
|
|
@@ -463,6 +477,7 @@ else if (cmd === 'init') doInit();
|
|
|
463
477
|
else if (cmd === 'config') resourceCmd('config', '/admin/config').catch((e) => { console.error(e.message); process.exit(1); });
|
|
464
478
|
else if (cmd === 'secret') resourceCmd('secret', '/admin/secrets').catch((e) => { console.error(e.message); process.exit(1); });
|
|
465
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); });
|
|
466
481
|
else if (cmd === 'admin') adminCmd().catch((e) => { console.error(e.message); process.exit(1); });
|
|
467
482
|
else {
|
|
468
483
|
console.log(`fonderie — the Fonderie CLI (lazy skills for coding agents)
|
|
@@ -473,6 +488,12 @@ else {
|
|
|
473
488
|
fonderie query <concept> what to install for a capability
|
|
474
489
|
fonderie query --concepts list every capability
|
|
475
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
|
+
|
|
476
497
|
fonderie config <get|set|delete|history|rollback> [key] [value] [--env <e>] [--if-version <n>] [--to-version <n>]
|
|
477
498
|
fonderie secret <get|set|delete|history|rollback|reveal> [key] [value] [--env <e>] ...
|
|
478
499
|
fonderie template <get|set|delete|history|rollback> [type] [text] [--locale <l>] [--subject <s>] [--html <h>] ...
|
|
@@ -481,8 +502,171 @@ else {
|
|
|
481
502
|
fonderie admin user <email|id> [sessions|history|revoke-sessions|suspend|unsuspend]
|
|
482
503
|
fonderie admin catalog · admin subscriber <user|workspace> <id> [subscription|wallet|ledger] [--currency <c>]
|
|
483
504
|
fonderie admin audit [--workspace <id>] [--type <t>] [--actor <id>] [--from <iso>] [--to <iso>] [--limit <n>]
|
|
505
|
+
fonderie admin token issue <name> --scopes read[,write[,secrets]] [--days <n>] · admin token revoke <id> (root token only)
|
|
484
506
|
read a deployment's operator surface (@fonderie/admin) — same env; FONDERIE_ADMIN_PREFIX if moved
|
|
485
507
|
|
|
486
508
|
Zero deps. No MCP server. A binary + markdown that runs in any agent harness.`);
|
|
487
509
|
if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(2);
|
|
488
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).
|
|
@@ -155,6 +209,11 @@ await (async () => {
|
|
|
155
209
|
if (!find('GET', '/_admin/wallet/user/u1/ledger?currency=eur&limit=7')) fail('admin subscriber ledger: flags not forwarded');
|
|
156
210
|
await cli(['admin', 'audit', '--workspace', 'w1', '--type', 'user.login', '--limit', '9']);
|
|
157
211
|
if (!find('GET', '/_admin/audit?workspaceId=w1&type=user.login&limit=9')) fail('admin audit: flags not forwarded');
|
|
212
|
+
await cli(['admin', 'token', 'issue', 'dashboard', '--scopes', 'read,write', '--days', '30']);
|
|
213
|
+
await cli(['admin', 'token', 'revoke', 't1']);
|
|
214
|
+
const iss = find('POST', '/_admin/access/tokens');
|
|
215
|
+
if (!iss || iss.body?.name !== 'dashboard' || iss.body?.scopes?.join() !== 'read,write' || iss.body?.expiresInDays !== 30) fail('admin token issue: wrong body');
|
|
216
|
+
if (!find('DELETE', '/_admin/access/tokens/t1')) fail('admin token revoke: wrong path');
|
|
158
217
|
let badType = 0;
|
|
159
218
|
try { await cli(['admin', 'subscriber', 'team', 'x']); } catch (e) { badType = e.code; }
|
|
160
219
|
if (badType !== 2) fail(`admin subscriber <bad type> should exit 2, got ${badType}`);
|
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",
|