@saasicat/cli 0.26.1 → 1.0.0-rc.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/saasicat.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // `saasicat` — bootstrap CLI for the SaaSiCat framework.
3
+ // naming-history: the codemod help below names the pre-1.0 spellings it rewrites.
3
4
  //
4
5
  // Sub-commands:
5
6
  // schema apply [--prisma-schema=PATH] [--fragments=01,02,03]
@@ -10,24 +11,86 @@
10
11
  // schema check [--prisma-schema=PATH] [--fragments=01,02,03]
11
12
  // Reports what your schema is missing relative to the canonical
12
13
  // fragments. Read-only; exits 1 on drift so CI can gate on it.
13
-
14
- import { readFile, writeFile, readdir } from 'node:fs/promises';
14
+ //
15
+ // schema migrate --name=X [--package-manager=pnpm|npm|yarn]
16
+ // apply --all, then `prisma migrate dev`, then append the constraints
17
+ // Prisma's DSL cannot express to the migration it just wrote.
18
+ //
19
+ // init --project-key=X --quota=key:Model [--quota=…]... [--app-name=X] [--api-base=X]
20
+ // codemod v1-imports [--dir=X] [--dry-run]
21
+ // codemod v1-rename [--dir=X] [--dry-run]
22
+ // codemod v1 [--dir=X] [--dry-run] — both, in that order
23
+ // [--skip-hasher] [--dry-run] [--dir=.]
24
+ // Writes the platform wiring — config, persistence, manifest
25
+ // contribution, admin module, one provider per quota — and adds
26
+ // `SaaSiCatModule.forRoot(...)` to an existing `src/app.module.ts`.
27
+
28
+ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
15
29
  import { existsSync } from 'node:fs';
16
30
  import { dirname, join, resolve } from 'node:path';
17
31
  import { createRequire } from 'node:module';
32
+ import { fileURLToPath } from 'node:url';
18
33
  import { spawn } from 'node:child_process';
19
34
 
20
- import { applyFragmentBlocks, checkSchema, extractModelBlocks } from '../dist/index.js';
35
+ import {
36
+ LIMIT_FILTER_IMPORTS,
37
+ LIMIT_FILTER_PROVIDER,
38
+ appendConstraints,
39
+ assertModelsExist,
40
+ enableFkPointers,
41
+ extractModelNames,
42
+ applyFragmentBlocks,
43
+ applyTokens,
44
+ constraintsFor,
45
+ checkSchema,
46
+ extractModelBlocks,
47
+ findFkPointers,
48
+ hasConstraints,
49
+ assertValidProjectKey,
50
+ buildImportMap,
51
+ migrationCreatedBy,
52
+ rewriteImports,
53
+ rewriteNames,
54
+ reportConstraints,
55
+ patchAppModule,
56
+ patchOptionsFor,
57
+ planInit,
58
+ } from '../dist/index.js';
21
59
 
22
60
  const require_ = createRequire(import.meta.url);
23
61
 
62
+ // Flags are `--key=value`; a bare `--key` is a switch. Deliberately not
63
+ // `--key value`: this CLI has taken `--key=value` since the beginning, and
64
+ // accepting both would make `--dry-run --all` ambiguous.
65
+ //
66
+ // What is NOT acceptable is the way a value flag written with a space used to
67
+ // fail. `--app-name "My App"` set `appName` to `true`, which reached
68
+ // `pascalCase` and came back as `value.replace is not a function` with exit 99
69
+ // — an internal error for what is an ordinary typo.
70
+ const VALUE_FLAGS = new Set([
71
+ 'project-key',
72
+ 'app-name',
73
+ 'api-base',
74
+ 'quota',
75
+ 'name',
76
+ 'fragments',
77
+ 'prisma-schema',
78
+ 'package-manager',
79
+ 'tenant-model',
80
+ 'user-model',
81
+ 'dir',
82
+ ]);
83
+
24
84
  function parseArgs(argv) {
25
85
  const flags = {};
26
86
  for (const arg of argv) {
27
- if (arg.startsWith('--')) {
28
- const [key, value] = arg.slice(2).split('=');
29
- flags[key] = value === undefined ? true : value;
87
+ if (!arg.startsWith('--')) continue;
88
+ const [key, value] = arg.slice(2).split('=');
89
+ if (value === undefined && VALUE_FLAGS.has(key)) {
90
+ console.error(`✗ --${key} needs a value, written as --${key}=<value>.`);
91
+ process.exit(1);
30
92
  }
93
+ flags[key] = value === undefined ? true : value;
31
94
  }
32
95
  return flags;
33
96
  }
@@ -117,29 +180,54 @@ async function cmdSchemaApply(args) {
117
180
  fragmentLabel: files.join(', '),
118
181
  });
119
182
 
120
- if (result.added.length === 0) {
183
+ // The FK pointers, on whatever the schema ends up being — including a
184
+ // schema that already had every model, which is the upgrade case and the
185
+ // one where the manual step is most likely to have been forgotten.
186
+ const fk = resolveFkPointers(result.schema, args);
187
+
188
+ if (result.added.length === 0 && fk.enabled.length === 0) {
121
189
  console.log(`→ Nothing to do. Models already present: ${result.skipped.join(', ')}`);
190
+ reportFkPointers(fk, args);
122
191
  return;
123
192
  }
124
193
 
125
194
  if (args['dry-run']) {
126
- console.log(`(--dry-run) Would append: ${result.added.join(', ')}`);
195
+ if (result.added.length) {
196
+ console.log(`(--dry-run) Would append: ${result.added.join(', ')}`);
197
+ }
127
198
  if (result.skipped.length) {
128
199
  console.log(`(--dry-run) Skipped (already present): ${result.skipped.join(', ')}`);
129
200
  }
201
+ reportFkPointers(fk, args);
130
202
  console.log('');
131
- console.log(result.schema.slice(schema.length));
203
+ // Both halves of what a real run writes, and they need different
204
+ // renderings: appended models are new text at the end, while FK
205
+ // pointers are rewritten lines INSIDE the existing schema. Printing
206
+ // the tail alone showed nothing at all for an upgrade — every model
207
+ // already present, so the tail is empty while the run still rewrites
208
+ // relation lines.
209
+ for (const { line } of fk.enabled) {
210
+ console.log(` ${line + 1}: ${fk.schema.split('\n')[line]?.trim() ?? ''}`);
211
+ }
212
+ if (fk.enabled.length > 0 && result.added.length > 0) console.log('');
213
+ if (result.added.length > 0) console.log(result.schema.slice(schema.length));
132
214
  return;
133
215
  }
134
216
 
135
- await writeFile(schemaPath, result.schema, 'utf8');
217
+ await writeFile(schemaPath, fk.schema, 'utf8');
136
218
  console.log(`✓ Appended ${result.added.length} model(s): ${result.added.join(', ')}`);
137
219
  if (result.skipped.length) {
138
220
  console.log(`→ Skipped (already present): ${result.skipped.join(', ')}`);
139
221
  }
222
+ reportFkPointers(fk, args);
140
223
  console.log('');
141
224
  console.log('Next steps:');
142
- console.log(' 1. Review schema.prisma especially the FK pointers to User/Tenant');
225
+ if (fk.enabled.length === 0) {
226
+ console.log(' 1. Review schema.prisma — especially the FK pointers to User/Tenant');
227
+ console.log(' (or re-run with --tenant-model=X --user-model=Y to enable them)');
228
+ } else {
229
+ console.log(' 1. Review schema.prisma');
230
+ }
143
231
  console.log(' 2. pnpm prisma migrate dev --name add_saasicat');
144
232
  }
145
233
 
@@ -239,6 +327,60 @@ async function cmdSchemaCheck(args) {
239
327
  process.exit(1);
240
328
  }
241
329
 
330
+ /**
331
+ * Enables the FK relations to the app's own models, when it named them.
332
+ *
333
+ * Refuses a name the schema does not declare rather than writing a relation to
334
+ * a model that does not exist — that failure would come from Prisma, about a
335
+ * line the consumer did not write.
336
+ */
337
+ function resolveFkPointers(schema, args) {
338
+ const models = { tenant: args['tenant-model'], user: args['user-model'] };
339
+ if (!models.tenant && !models.user) {
340
+ return { schema, enabled: [], skipped: findFkPointers(schema), needsBackRelation: [] };
341
+ }
342
+ try {
343
+ assertModelsExist(extractModelNames(schema), models);
344
+ } catch (err) {
345
+ console.error(`✗ ${err.message}`);
346
+ process.exit(1);
347
+ }
348
+ return enableFkPointers(schema, models);
349
+ }
350
+
351
+ /** Says what was enabled, what was left commented, and what each needs. */
352
+ function reportFkPointers(fk, args) {
353
+ if (fk.enabled.length > 0) {
354
+ // The tense matters on a dry run: nothing has been enabled yet, and a
355
+ // preview that reports past-tense edits is a preview nobody can check.
356
+ const verb = args['dry-run'] ? 'Would enable' : 'Enabled';
357
+ console.log(`✓ ${verb} ${fk.enabled.length} foreign-key relation(s) to your models.`);
358
+ }
359
+
360
+ // Two different answers, so two different reports: one needs a flag, the
361
+ // other needs one line in a model this tool must not edit on its own.
362
+ for (const { owner, suggestion } of fk.needsBackRelation) {
363
+ console.log(`→ Left commented: your \`${owner}\` has no opposite relation field.`);
364
+ console.log(` Add \`${suggestion}\` to model ${owner}, then re-run.`);
365
+ }
366
+ if (fk.needsBackRelation.length > 0) {
367
+ console.log(' A one-sided relation is a schema Prisma refuses (P1012), so it is');
368
+ console.log(' better left commented than written.');
369
+ }
370
+
371
+ if (fk.skipped.length === 0) return;
372
+
373
+ const targets = [...new Set(fk.skipped.map((p) => p.target))];
374
+ const flags = targets.map((t) => `--${t.toLowerCase()}-model=Your${t}`).join(' ');
375
+ const verb = args['dry-run'] ? 'would stay' : 'stay';
376
+ console.log(
377
+ `→ ${fk.skipped.length} foreign-key relation(s) ${verb} commented out ` +
378
+ `(to ${targets.join(' and ')}).`,
379
+ );
380
+ console.log(` Enable them with: ${flags}`);
381
+ console.log(' Without them the columns exist and nothing enforces them.');
382
+ }
383
+
242
384
  function runChild(cmd, args, opts = {}) {
243
385
  return new Promise((resolve_, reject) => {
244
386
  const proc = spawn(cmd, args, { stdio: 'inherit', ...opts });
@@ -257,23 +399,409 @@ async function cmdSchemaMigrate(args) {
257
399
  }
258
400
 
259
401
  console.log(
260
- `→ Step 1/2: saasicat schema apply ${args['fragments'] ? `--fragments=${args['fragments']}` : '--all'}`,
402
+ `→ Step 1/4: saasicat schema apply ${args['fragments'] ? `--fragments=${args['fragments']}` : '--all'}`,
261
403
  );
262
404
  await cmdSchemaApply({
263
405
  ...args,
264
406
  all: args['fragments'] ? undefined : true,
265
407
  });
266
408
 
409
+ const pmRunner = args['package-manager'] ?? 'pnpm';
410
+
411
+ // A dry run touches nothing, and that has to include Prisma. Step 1 did not
412
+ // write the schema, so `migrate dev --create-only` would diff against the
413
+ // unchanged file, reach the shadow database, and leave a migration
414
+ // directory behind — a "dry" run with three side effects.
415
+ if (args['dry-run']) {
416
+ console.log('→ Steps 2-4 skipped (--dry-run): nothing was written and Prisma was');
417
+ console.log(' not called. Re-run without --dry-run to migrate.');
418
+ return;
419
+ }
420
+
421
+ // `--create-only`, and the reason is the whole point of step 3.
422
+ //
423
+ // A plain `migrate dev` APPLIES the migration and records its checksum in
424
+ // `_prisma_migrations`. Appending to the file afterwards would then do two
425
+ // wrong things at once: the constraints would never reach the database that
426
+ // was just migrated, and the next `migrate dev` would see a migration whose
427
+ // checksum no longer matches and offer a reset. Writing the file first and
428
+ // applying it after is what makes the constraints arrive with the tables.
429
+ const migrationsBefore = await listMigrations(args);
430
+ console.log(`→ Step 2/4: ${pmRunner} prisma migrate dev --create-only --name ${args.name}`);
431
+ await runChild(pmRunner, ['prisma', 'migrate', 'dev', '--create-only', '--name', args.name]);
432
+
433
+ console.log('→ Step 3/4: appending the constraints Prisma cannot express');
434
+ const report = await writeConstraintsIntoMigration(args, migrationsBefore);
435
+ console.log(report.message);
436
+
437
+ // Applying a migration the tool could not finish is worse than stopping.
438
+ // The message tells the operator to add the SQL before applying it, and
439
+ // `migrate dev` is what applies it — so the command that printed that
440
+ // advice has to be the one that leaves the window open. Afterwards the
441
+ // file sits under a recorded checksum and editing it offers a reset.
442
+ if (!report.mayApply) {
443
+ console.error('✗ schema migrate stopped before applying. Nothing reached the database.');
444
+ process.exit(1);
445
+ }
446
+
447
+ console.log(`→ Step 4/4: ${pmRunner} prisma migrate dev (applies it)`);
448
+ await runChild(pmRunner, ['prisma', 'migrate', 'dev']);
267
449
  console.log(
268
- `→ Step 2/2: ${args['package-manager'] ?? 'pnpm'} prisma migrate dev --name ${args.name}`,
450
+ report.outcome === 'appended' || report.outcome === 'already-present'
451
+ ? '✓ schema migrate succeeded — tables and constraints are in the database.'
452
+ : '✓ schema migrate succeeded.',
269
453
  );
270
- const pmRunner = args['package-manager'] ?? 'pnpm';
271
- await runChild(pmRunner, ['prisma', 'migrate', 'dev', '--name', args.name]);
272
- console.log('✓ schema migrate succeeded.');
454
+ }
455
+
456
+ /**
457
+ * The table names a schema declares — `@@map("x")` where present, the model
458
+ * name otherwise, which is what Prisma falls back to.
459
+ */
460
+ function tableNamesOf(schema) {
461
+ const names = [];
462
+ for (const line of schema.split('\n')) {
463
+ const opening = /^model\s+(\w+)\s*\{/.exec(line);
464
+ if (opening) names.push(opening[1]);
465
+ const mapped = /^\s*@@map\("(\w+)"\)/.exec(line);
466
+ if (mapped) names.push(mapped[1]);
467
+ }
468
+ return names;
469
+ }
470
+
471
+ /** The migration directories that exist right now, or none. */
472
+ async function listMigrations(args) {
473
+ const { schemaPath } = await readSchemaOrExit(args);
474
+ return readdir(join(dirname(schemaPath), 'migrations')).catch(() => []);
475
+ }
476
+
477
+ /** `@saasicat/spec/sql/constraints.postgres.sql`, read from the installed package. */
478
+ function resolveConstraintsSql() {
479
+ const specEntry = require_.resolve('@saasicat/spec');
480
+ return join(dirname(specEntry), 'sql', 'constraints.postgres.sql');
481
+ }
482
+
483
+ /**
484
+ * Appends the non-DSL constraints to the migration `prisma migrate dev` just
485
+ * wrote.
486
+ *
487
+ * Deliberately after the Prisma run rather than into a hand-made file: the
488
+ * statements need the tables, and Prisma decides what the migration is called.
489
+ *
490
+ * Returns what happened rather than whether it worked. The caller has to tell
491
+ * "nothing to append" from "appending failed": only the second one may not be
492
+ * followed by `migrate dev`, because the advice it prints — add the SQL before
493
+ * applying — is only followable while the migration is still unapplied.
494
+ */
495
+ async function writeConstraintsIntoMigration(args, migrationsBefore) {
496
+ const { schemaPath } = await readSchemaOrExit(args);
497
+ const migrationsDir = join(dirname(schemaPath), 'migrations');
498
+ const sqlPath = resolveConstraintsSql();
499
+
500
+ try {
501
+ const directories = await readdir(migrationsDir);
502
+ // The one THIS run created, not the lexicographically last: if step 2
503
+ // produced nothing, the last one belongs to somebody else and has
504
+ // already been applied.
505
+ const newest = migrationCreatedBy(migrationsBefore, directories);
506
+ if (!newest) return reportConstraints('no-migration', { sqlPath });
507
+
508
+ const migrationFile = join(migrationsDir, newest, 'migration.sql');
509
+ const migrationSql = await readFile(migrationFile, 'utf8');
510
+ if (hasConstraints(migrationSql)) {
511
+ return reportConstraints('already-present', { sqlPath, migration: newest });
512
+ }
513
+
514
+ // Only the constraints whose tables this schema has: a run scoped with
515
+ // `--fragments` produces a migration without the others, and Prisma
516
+ // fails the whole thing against its shadow database with P1014.
517
+ const { schema: currentSchema } = await readSchemaOrExit(args);
518
+ const applicable = constraintsFor(
519
+ await readFile(sqlPath, 'utf8'),
520
+ tableNamesOf(currentSchema),
521
+ );
522
+ if (applicable.trim() === '') {
523
+ return reportConstraints('not-applicable', { sqlPath, migration: newest });
524
+ }
525
+ await writeFile(migrationFile, appendConstraints(migrationSql, applicable));
526
+ return reportConstraints('appended', { sqlPath, migration: newest });
527
+ } catch (err) {
528
+ console.error(` ! ${err?.message ?? String(err)}`);
529
+ return reportConstraints('failed', { sqlPath });
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Rewrites `@saasicat/ui-vue` imports to the 1.0 export map.
535
+ *
536
+ * The rules come from the same table the platform's own move ran on, shipped
537
+ * with this package — so what a consumer's imports become cannot disagree with
538
+ * where the files actually went.
539
+ */
540
+ async function cmdCodemodV1Imports(args) {
541
+ const root = resolve(args.dir ?? '.');
542
+ const dryRun = args['dry-run'] === true;
543
+
544
+ const table = JSON.parse(await readFile(codemodTable('v1-imports.map.json'), 'utf8'));
545
+ const map = buildImportMap(table);
546
+
547
+ const unmapped = new Map();
548
+ let rewritten = 0;
549
+ let touched = 0;
550
+ await walkSources(root, async (full, source) => {
551
+ const result = rewriteImports(source, map);
552
+ for (const [subpath, n] of result.unmapped) {
553
+ unmapped.set(subpath, (unmapped.get(subpath) ?? 0) + n);
554
+ }
555
+ if (result.rewritten === 0) return;
556
+ if (!dryRun) await writeFile(full, result.text);
557
+ rewritten += result.rewritten;
558
+ touched += 1;
559
+ });
560
+
561
+ console.log(
562
+ `${dryRun ? 'Would rewrite' : 'Rewrote'} ${rewritten} import(s) in ${touched} file(s).`,
563
+ );
564
+ if (unmapped.size === 0) return;
565
+
566
+ console.log('');
567
+ console.log('These have no new home — they need a decision, not a rewrite:');
568
+ for (const [subpath, n] of [...unmapped].sort()) {
569
+ console.log(` ${String(n).padStart(3)}× @saasicat/ui-vue/${subpath}`);
570
+ }
571
+ console.log('');
572
+ console.log(' They moved into `features/` or `internal/`, which the 1.0 surface does');
573
+ console.log(' not publish: they were domain or page-private components, and importing');
574
+ console.log(' them tied your app to our internal structure. Copy what you need into');
575
+ console.log(' your own repository.');
576
+ }
577
+
578
+ /**
579
+ * Rewrites the names 1.0 changed: identifier stems, registry keys, the one
580
+ * token that had two meanings, and the e2e helper's subpath.
581
+ *
582
+ * Same shape as `v1-imports`, same table discipline: the rules are read from
583
+ * `codemods/v1-rename.map.json`, shipped with this package, so what a
584
+ * consumer's code becomes is what the platform's own rename was checked
585
+ * against.
586
+ */
587
+ async function cmdCodemodV1Rename(args) {
588
+ const root = resolve(args.dir ?? '.');
589
+ const dryRun = args['dry-run'] === true;
590
+ const table = JSON.parse(await readFile(codemodTable('v1-rename.map.json'), 'utf8'));
591
+
592
+ const ambiguous = new Map();
593
+ let rewritten = 0;
594
+ let touched = 0;
595
+ await walkSources(root, async (full, source) => {
596
+ const result = rewriteNames(source, table);
597
+ for (const name of result.ambiguous) {
598
+ ambiguous.set(name, (ambiguous.get(name) ?? 0) + 1);
599
+ }
600
+ if (result.rewritten === 0) return;
601
+ if (!dryRun) await writeFile(full, result.text);
602
+ rewritten += result.rewritten;
603
+ touched += 1;
604
+ });
605
+
606
+ console.log(
607
+ `${dryRun ? 'Would rewrite' : 'Rewrote'} ${rewritten} name(s) in ${touched} file(s).`,
608
+ );
609
+ if (ambiguous.size === 0) return;
610
+
611
+ console.log('');
612
+ console.log('These need a decision, not a rewrite:');
613
+ for (const [name, n] of [...ambiguous].sort()) {
614
+ console.log(` ${String(n).padStart(3)}× ${name}`);
615
+ }
616
+ console.log('');
617
+ console.log(' FEATURE_UI_REGISTRY_TOKEN meant one registry in `@saasicat/nest/billing`');
618
+ console.log(' and another in `@saasicat/nest/catalog`. Import it from the entry you');
619
+ console.log(' mean — BILLING_FEATURE_UI_REGISTRY_TOKEN or CATALOG_FEATURE_UI_REGISTRY_TOKEN.');
620
+ }
621
+
622
+ /** Where a shipped codemod table lives, resolved through the package itself. */
623
+ function codemodTable(name) {
624
+ return join(dirname(require_.resolve('@saasicat/cli')), '..', 'codemods', name);
625
+ }
626
+
627
+ // Anything a build wrote is skipped, whatever it is called: `dist`, `dist-app`,
628
+ // `dist-dev` — a consumer's declaration output carries the old names too, and
629
+ // rewriting it would only make the next build disagree with it.
630
+ const CODEMOD_SKIP = new Set(['node_modules', '.git', '.output', 'coverage']);
631
+ const isBuildOutput = (name) => name === 'dist' || name.startsWith('dist-');
632
+ const CODEMOD_EXTENSIONS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue|md)$/;
633
+
634
+ /** Every source file under `root` a codemod may touch, with its text. */
635
+ async function walkSources(root, visit) {
636
+ const walk = async (dir) => {
637
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
638
+ if (CODEMOD_SKIP.has(entry.name) || isBuildOutput(entry.name)) continue;
639
+ const full = join(dir, entry.name);
640
+ if (entry.isDirectory()) {
641
+ await walk(full);
642
+ continue;
643
+ }
644
+ if (!CODEMOD_EXTENSIONS.test(entry.name)) continue;
645
+ await visit(full, await readFile(full, 'utf8'));
646
+ }
647
+ };
648
+ await walk(root);
649
+ }
650
+
651
+ /** Reads a repeated flag (`--quota=a --quota=b`) off argv. */
652
+ function repeatedFlag(argv, name) {
653
+ return argv
654
+ .filter((arg) => arg.startsWith(`--${name}=`))
655
+ .map((arg) => arg.slice(name.length + 3));
656
+ }
657
+
658
+ async function cmdInit(args, argv) {
659
+ if (!args['project-key']) {
660
+ console.error('✗ --project-key=<key> is required.');
661
+ console.error(' It names the catalogue this app administers.');
662
+ process.exit(1);
663
+ }
664
+
665
+ // A usage error, so it exits 1 like every other one here rather than
666
+ // through the top-level handler's 99. The rule itself comes from the
667
+ // catalogue schema — see src/init/project-key.ts.
668
+ try {
669
+ assertValidProjectKey(args['project-key']);
670
+ } catch (err) {
671
+ console.error(`✗ ${err.message}`);
672
+ process.exit(1);
673
+ }
674
+
675
+ const root = resolve(args.dir ?? '.');
676
+ const plan = planInit({
677
+ projectKey: args['project-key'],
678
+ appName: args['app-name'],
679
+ apiBase: args['api-base'],
680
+ quotas: repeatedFlag(argv, 'quota'),
681
+ skipHasher: args['skip-hasher'] === true,
682
+ });
683
+
684
+ const templatesDir = resolve(
685
+ dirname(fileURLToPath(import.meta.url)),
686
+ '..',
687
+ 'templates',
688
+ 'init',
689
+ );
690
+ if (!existsSync(templatesDir)) {
691
+ console.error(`✗ Templates not found: ${templatesDir}`);
692
+ process.exit(99);
693
+ }
694
+
695
+ // Every file first, then every write: a run that stops halfway leaves an
696
+ // app that neither compiles nor can be re-generated over.
697
+ const writes = [];
698
+ for (const file of plan.files) {
699
+ const raw = await readFile(join(templatesDir, `${file.template}.tpl`), 'utf8');
700
+ writes.push({
701
+ path: file.path,
702
+ dest: join(root, file.path),
703
+ content: applyTokens(raw, { ...plan.tokens, ...file.tokens }),
704
+ });
705
+ }
706
+
707
+ const existing = writes.filter((w) => existsSync(w.dest));
708
+ if (existing.length > 0 && !args['dry-run']) {
709
+ console.error(`✗ ${existing.length} file(s) already exist — refusing to overwrite:`);
710
+ for (const w of existing) console.error(` ${w.path}`);
711
+ console.error(' Move them aside, or run with --dry-run to see what would be written.');
712
+ process.exit(1);
713
+ }
714
+
715
+ if (args['dry-run']) {
716
+ console.log(`(--dry-run) Would write ${writes.length} file(s) under ${root}:`);
717
+ for (const w of writes) {
718
+ console.log(` ${w.path}${existsSync(w.dest) ? ' (EXISTS — would refuse)' : ''}`);
719
+ }
720
+ } else {
721
+ for (const w of writes) {
722
+ await mkdir(dirname(w.dest), { recursive: true });
723
+ await writeFile(w.dest, w.content, 'utf8');
724
+ }
725
+ console.log(`✓ Wrote ${writes.length} file(s) under ${root}`);
726
+ for (const w of writes) console.log(` ${w.path}`);
727
+ }
728
+
729
+ await patchAppModuleFile(root, plan, args);
730
+
731
+ console.log('');
732
+ console.log('Next steps:');
733
+ console.log(' 1. Name your auth guard in `controller: { guards: [YourAuthGuard] }`');
734
+ console.log(' — the generated app does NOT compile until you do. An empty');
735
+ console.log(' array means "deliberately auth-free" to the platform, and');
736
+ console.log(' would publish GET /admin/discovery to anyone who asks.');
737
+ if (plan.quotaProviders.length > 0) {
738
+ console.log(' 2. Check each quota provider counts the right thing');
739
+ console.log(' 3. saasicat schema migrate --name=add_saasicat');
740
+ } else {
741
+ console.log(' 2. saasicat schema migrate --name=add_saasicat');
742
+ }
743
+ }
744
+
745
+ /** Adds the platform to `src/app.module.ts`, or says exactly what to paste. */
746
+ async function patchAppModuleFile(root, plan, args) {
747
+ const appModulePath = join(root, 'src', 'app.module.ts');
748
+ // Derived in `init/plan.ts`, where it can be tested: this file is not.
749
+ const options = patchOptionsFor(plan);
750
+
751
+ if (!existsSync(appModulePath)) {
752
+ console.log('');
753
+ console.log(`! No ${appModulePath} — add the platform to your root module:`);
754
+ console.log(patchAppModule('', options).manualBlock);
755
+ return;
756
+ }
757
+
758
+ const source = await readFile(appModulePath, 'utf8');
759
+ const result = patchAppModule(source, options);
760
+
761
+ if (result.status === 'already-wired') {
762
+ console.log('');
763
+ console.log('= src/app.module.ts already calls SaaSiCatModule.forRoot — left alone.');
764
+ return;
765
+ }
766
+ if (result.status === 'declined') {
767
+ console.log('');
768
+ console.log(`! src/app.module.ts not patched: ${result.reason}.`);
769
+ console.log(' Add this yourself:');
770
+ console.log(result.manualBlock);
771
+ return;
772
+ }
773
+
774
+ if (args['dry-run']) {
775
+ console.log('');
776
+ console.log('(--dry-run) Would add SaaSiCatModule.forRoot(...) to src/app.module.ts');
777
+ } else {
778
+ await writeFile(appModulePath, result.source, 'utf8');
779
+ console.log('');
780
+ console.log('+ src/app.module.ts now imports SaaSiCatModule.');
781
+ }
782
+ console.log(' Still yours — add to `providers`, with its two imports:');
783
+ for (const line of LIMIT_FILTER_IMPORTS.split('\n')) console.log(` ${line}`);
784
+ console.log(` ${LIMIT_FILTER_PROVIDER}`);
785
+ console.log(' (maps @EnforceQuota overruns to HTTP 402 — see docs/quickstart.md)');
273
786
  }
274
787
 
275
788
  async function main() {
276
789
  const [, , cmd, sub, ...rest] = process.argv;
790
+ if (cmd === 'codemod' && sub === 'v1-imports') {
791
+ return cmdCodemodV1Imports(parseArgs(rest));
792
+ }
793
+ if (cmd === 'codemod' && sub === 'v1-rename') {
794
+ return cmdCodemodV1Rename(parseArgs(rest));
795
+ }
796
+ if (cmd === 'codemod' && sub === 'v1') {
797
+ // Imports first: the rename table keys its per-entry tokens by the
798
+ // specifier they are imported from, which the import rewrite settles.
799
+ await cmdCodemodV1Imports(parseArgs(rest));
800
+ return cmdCodemodV1Rename(parseArgs(rest));
801
+ }
802
+ if (cmd === 'init') {
803
+ return cmdInit(parseArgs([sub, ...rest].filter(Boolean)), process.argv.slice(3));
804
+ }
277
805
  if (cmd === 'schema' && sub === 'apply') {
278
806
  return cmdSchemaApply(parseArgs(rest));
279
807
  }
@@ -294,9 +822,26 @@ async function main() {
294
822
  ' schema check report drift against @saasicat/spec',
295
823
  );
296
824
  console.log(' schema check --fragments=01,02 check only these fragments');
297
- console.log(' schema migrate --name=<name> apply --all + prisma migrate dev');
825
+ console.log(
826
+ ' schema migrate --name=<name> apply --all + migrate dev + constraints',
827
+ );
828
+ console.log('');
829
+ console.log(' init --project-key=<key> --quota=<key>:<Model>');
830
+ console.log(' scaffold the platform wiring. At least one --quota:');
831
+ console.log(' every plan must declare one, or the catalogue does not load.');
832
+ console.log(' init --project-key=myapp --quota=notes:Note --quota=seats:Seat');
833
+ console.log('');
834
+ console.log(' codemod v1 [--dir=.] [--dry-run]');
835
+ console.log(' the whole 1.0 migration: v1-imports, then v1-rename');
836
+ console.log(' codemod v1-imports [--dir=.] [--dry-run]');
837
+ console.log(' rewrite @saasicat/ui-vue imports to the 1.0 export map');
838
+ console.log(' codemod v1-rename [--dir=.] [--dry-run]');
839
+ console.log(' rewrite the names 1.0 changed: SaasPlatform*, Symbol.for keys,');
840
+ console.log(' FEATURE_UI_REGISTRY_TOKEN, @saasicat/ui-vue/testing-e2e/*');
298
841
  console.log('');
299
842
  console.log('Optional --prisma-schema=PATH (default prisma/schema.prisma).');
843
+ console.log('Optional --tenant-model=X --user-model=Y for apply/migrate — enables');
844
+ console.log(' the foreign keys from the platform tables to your models.');
300
845
  console.log('Optional --package-manager=pnpm|npm|yarn (default pnpm).');
301
846
  return;
302
847
  }