@saasicat/cli 0.26.0 → 0.27.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
@@ -10,24 +10,80 @@
10
10
  // schema check [--prisma-schema=PATH] [--fragments=01,02,03]
11
11
  // Reports what your schema is missing relative to the canonical
12
12
  // fragments. Read-only; exits 1 on drift so CI can gate on it.
13
+ //
14
+ // schema migrate --name=X [--package-manager=pnpm|npm|yarn]
15
+ // apply --all, then `prisma migrate dev`, then append the constraints
16
+ // Prisma's DSL cannot express to the migration it just wrote.
17
+ //
18
+ // init --project-key=X --quota=key:Model [--quota=…]... [--app-name=X] [--api-base=X]
19
+ // [--skip-hasher] [--dry-run] [--dir=.]
20
+ // Writes the platform wiring — config, persistence, manifest
21
+ // contribution, admin module, one provider per quota — and adds
22
+ // `SaaSiCatModule.forRoot(...)` to an existing `src/app.module.ts`.
13
23
 
14
- import { readFile, writeFile, readdir } from 'node:fs/promises';
24
+ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
15
25
  import { existsSync } from 'node:fs';
16
26
  import { dirname, join, resolve } from 'node:path';
17
27
  import { createRequire } from 'node:module';
28
+ import { fileURLToPath } from 'node:url';
18
29
  import { spawn } from 'node:child_process';
19
30
 
20
- import { applyFragmentBlocks, checkSchema, extractModelBlocks } from '../dist/index.js';
31
+ import {
32
+ LIMIT_FILTER_IMPORTS,
33
+ LIMIT_FILTER_PROVIDER,
34
+ appendConstraints,
35
+ assertModelsExist,
36
+ enableFkPointers,
37
+ extractModelNames,
38
+ applyFragmentBlocks,
39
+ applyTokens,
40
+ constraintsFor,
41
+ checkSchema,
42
+ extractModelBlocks,
43
+ findFkPointers,
44
+ hasConstraints,
45
+ assertValidProjectKey,
46
+ migrationCreatedBy,
47
+ reportConstraints,
48
+ patchAppModule,
49
+ patchOptionsFor,
50
+ planInit,
51
+ } from '../dist/index.js';
21
52
 
22
53
  const require_ = createRequire(import.meta.url);
23
54
 
55
+ // Flags are `--key=value`; a bare `--key` is a switch. Deliberately not
56
+ // `--key value`: this CLI has taken `--key=value` since the beginning, and
57
+ // accepting both would make `--dry-run --all` ambiguous.
58
+ //
59
+ // What is NOT acceptable is the way a value flag written with a space used to
60
+ // fail. `--app-name "My App"` set `appName` to `true`, which reached
61
+ // `pascalCase` and came back as `value.replace is not a function` with exit 99
62
+ // — an internal error for what is an ordinary typo.
63
+ const VALUE_FLAGS = new Set([
64
+ 'project-key',
65
+ 'app-name',
66
+ 'api-base',
67
+ 'quota',
68
+ 'name',
69
+ 'fragments',
70
+ 'prisma-schema',
71
+ 'package-manager',
72
+ 'tenant-model',
73
+ 'user-model',
74
+ 'dir',
75
+ ]);
76
+
24
77
  function parseArgs(argv) {
25
78
  const flags = {};
26
79
  for (const arg of argv) {
27
- if (arg.startsWith('--')) {
28
- const [key, value] = arg.slice(2).split('=');
29
- flags[key] = value === undefined ? true : value;
80
+ if (!arg.startsWith('--')) continue;
81
+ const [key, value] = arg.slice(2).split('=');
82
+ if (value === undefined && VALUE_FLAGS.has(key)) {
83
+ console.error(`✗ --${key} needs a value, written as --${key}=<value>.`);
84
+ process.exit(1);
30
85
  }
86
+ flags[key] = value === undefined ? true : value;
31
87
  }
32
88
  return flags;
33
89
  }
@@ -117,29 +173,54 @@ async function cmdSchemaApply(args) {
117
173
  fragmentLabel: files.join(', '),
118
174
  });
119
175
 
120
- if (result.added.length === 0) {
176
+ // The FK pointers, on whatever the schema ends up being — including a
177
+ // schema that already had every model, which is the upgrade case and the
178
+ // one where the manual step is most likely to have been forgotten.
179
+ const fk = resolveFkPointers(result.schema, args);
180
+
181
+ if (result.added.length === 0 && fk.enabled.length === 0) {
121
182
  console.log(`→ Nothing to do. Models already present: ${result.skipped.join(', ')}`);
183
+ reportFkPointers(fk, args);
122
184
  return;
123
185
  }
124
186
 
125
187
  if (args['dry-run']) {
126
- console.log(`(--dry-run) Would append: ${result.added.join(', ')}`);
188
+ if (result.added.length) {
189
+ console.log(`(--dry-run) Would append: ${result.added.join(', ')}`);
190
+ }
127
191
  if (result.skipped.length) {
128
192
  console.log(`(--dry-run) Skipped (already present): ${result.skipped.join(', ')}`);
129
193
  }
194
+ reportFkPointers(fk, args);
130
195
  console.log('');
131
- console.log(result.schema.slice(schema.length));
196
+ // Both halves of what a real run writes, and they need different
197
+ // renderings: appended models are new text at the end, while FK
198
+ // pointers are rewritten lines INSIDE the existing schema. Printing
199
+ // the tail alone showed nothing at all for an upgrade — every model
200
+ // already present, so the tail is empty while the run still rewrites
201
+ // relation lines.
202
+ for (const { line } of fk.enabled) {
203
+ console.log(` ${line + 1}: ${fk.schema.split('\n')[line]?.trim() ?? ''}`);
204
+ }
205
+ if (fk.enabled.length > 0 && result.added.length > 0) console.log('');
206
+ if (result.added.length > 0) console.log(result.schema.slice(schema.length));
132
207
  return;
133
208
  }
134
209
 
135
- await writeFile(schemaPath, result.schema, 'utf8');
210
+ await writeFile(schemaPath, fk.schema, 'utf8');
136
211
  console.log(`✓ Appended ${result.added.length} model(s): ${result.added.join(', ')}`);
137
212
  if (result.skipped.length) {
138
213
  console.log(`→ Skipped (already present): ${result.skipped.join(', ')}`);
139
214
  }
215
+ reportFkPointers(fk, args);
140
216
  console.log('');
141
217
  console.log('Next steps:');
142
- console.log(' 1. Review schema.prisma especially the FK pointers to User/Tenant');
218
+ if (fk.enabled.length === 0) {
219
+ console.log(' 1. Review schema.prisma — especially the FK pointers to User/Tenant');
220
+ console.log(' (or re-run with --tenant-model=X --user-model=Y to enable them)');
221
+ } else {
222
+ console.log(' 1. Review schema.prisma');
223
+ }
143
224
  console.log(' 2. pnpm prisma migrate dev --name add_saasicat');
144
225
  }
145
226
 
@@ -239,6 +320,60 @@ async function cmdSchemaCheck(args) {
239
320
  process.exit(1);
240
321
  }
241
322
 
323
+ /**
324
+ * Enables the FK relations to the app's own models, when it named them.
325
+ *
326
+ * Refuses a name the schema does not declare rather than writing a relation to
327
+ * a model that does not exist — that failure would come from Prisma, about a
328
+ * line the consumer did not write.
329
+ */
330
+ function resolveFkPointers(schema, args) {
331
+ const models = { tenant: args['tenant-model'], user: args['user-model'] };
332
+ if (!models.tenant && !models.user) {
333
+ return { schema, enabled: [], skipped: findFkPointers(schema), needsBackRelation: [] };
334
+ }
335
+ try {
336
+ assertModelsExist(extractModelNames(schema), models);
337
+ } catch (err) {
338
+ console.error(`✗ ${err.message}`);
339
+ process.exit(1);
340
+ }
341
+ return enableFkPointers(schema, models);
342
+ }
343
+
344
+ /** Says what was enabled, what was left commented, and what each needs. */
345
+ function reportFkPointers(fk, args) {
346
+ if (fk.enabled.length > 0) {
347
+ // The tense matters on a dry run: nothing has been enabled yet, and a
348
+ // preview that reports past-tense edits is a preview nobody can check.
349
+ const verb = args['dry-run'] ? 'Would enable' : 'Enabled';
350
+ console.log(`✓ ${verb} ${fk.enabled.length} foreign-key relation(s) to your models.`);
351
+ }
352
+
353
+ // Two different answers, so two different reports: one needs a flag, the
354
+ // other needs one line in a model this tool must not edit on its own.
355
+ for (const { owner, suggestion } of fk.needsBackRelation) {
356
+ console.log(`→ Left commented: your \`${owner}\` has no opposite relation field.`);
357
+ console.log(` Add \`${suggestion}\` to model ${owner}, then re-run.`);
358
+ }
359
+ if (fk.needsBackRelation.length > 0) {
360
+ console.log(' A one-sided relation is a schema Prisma refuses (P1012), so it is');
361
+ console.log(' better left commented than written.');
362
+ }
363
+
364
+ if (fk.skipped.length === 0) return;
365
+
366
+ const targets = [...new Set(fk.skipped.map((p) => p.target))];
367
+ const flags = targets.map((t) => `--${t.toLowerCase()}-model=Your${t}`).join(' ');
368
+ const verb = args['dry-run'] ? 'would stay' : 'stay';
369
+ console.log(
370
+ `→ ${fk.skipped.length} foreign-key relation(s) ${verb} commented out ` +
371
+ `(to ${targets.join(' and ')}).`,
372
+ );
373
+ console.log(` Enable them with: ${flags}`);
374
+ console.log(' Without them the columns exist and nothing enforces them.');
375
+ }
376
+
242
377
  function runChild(cmd, args, opts = {}) {
243
378
  return new Promise((resolve_, reject) => {
244
379
  const proc = spawn(cmd, args, { stdio: 'inherit', ...opts });
@@ -257,23 +392,279 @@ async function cmdSchemaMigrate(args) {
257
392
  }
258
393
 
259
394
  console.log(
260
- `→ Step 1/2: saasicat schema apply ${args['fragments'] ? `--fragments=${args['fragments']}` : '--all'}`,
395
+ `→ Step 1/4: saasicat schema apply ${args['fragments'] ? `--fragments=${args['fragments']}` : '--all'}`,
261
396
  );
262
397
  await cmdSchemaApply({
263
398
  ...args,
264
399
  all: args['fragments'] ? undefined : true,
265
400
  });
266
401
 
402
+ const pmRunner = args['package-manager'] ?? 'pnpm';
403
+
404
+ // A dry run touches nothing, and that has to include Prisma. Step 1 did not
405
+ // write the schema, so `migrate dev --create-only` would diff against the
406
+ // unchanged file, reach the shadow database, and leave a migration
407
+ // directory behind — a "dry" run with three side effects.
408
+ if (args['dry-run']) {
409
+ console.log('→ Steps 2-4 skipped (--dry-run): nothing was written and Prisma was');
410
+ console.log(' not called. Re-run without --dry-run to migrate.');
411
+ return;
412
+ }
413
+
414
+ // `--create-only`, and the reason is the whole point of step 3.
415
+ //
416
+ // A plain `migrate dev` APPLIES the migration and records its checksum in
417
+ // `_prisma_migrations`. Appending to the file afterwards would then do two
418
+ // wrong things at once: the constraints would never reach the database that
419
+ // was just migrated, and the next `migrate dev` would see a migration whose
420
+ // checksum no longer matches and offer a reset. Writing the file first and
421
+ // applying it after is what makes the constraints arrive with the tables.
422
+ const migrationsBefore = await listMigrations(args);
423
+ console.log(`→ Step 2/4: ${pmRunner} prisma migrate dev --create-only --name ${args.name}`);
424
+ await runChild(pmRunner, ['prisma', 'migrate', 'dev', '--create-only', '--name', args.name]);
425
+
426
+ console.log('→ Step 3/4: appending the constraints Prisma cannot express');
427
+ const report = await writeConstraintsIntoMigration(args, migrationsBefore);
428
+ console.log(report.message);
429
+
430
+ // Applying a migration the tool could not finish is worse than stopping.
431
+ // The message tells the operator to add the SQL before applying it, and
432
+ // `migrate dev` is what applies it — so the command that printed that
433
+ // advice has to be the one that leaves the window open. Afterwards the
434
+ // file sits under a recorded checksum and editing it offers a reset.
435
+ if (!report.mayApply) {
436
+ console.error('✗ schema migrate stopped before applying. Nothing reached the database.');
437
+ process.exit(1);
438
+ }
439
+
440
+ console.log(`→ Step 4/4: ${pmRunner} prisma migrate dev (applies it)`);
441
+ await runChild(pmRunner, ['prisma', 'migrate', 'dev']);
267
442
  console.log(
268
- `→ Step 2/2: ${args['package-manager'] ?? 'pnpm'} prisma migrate dev --name ${args.name}`,
443
+ report.outcome === 'appended' || report.outcome === 'already-present'
444
+ ? '✓ schema migrate succeeded — tables and constraints are in the database.'
445
+ : '✓ schema migrate succeeded.',
269
446
  );
270
- const pmRunner = args['package-manager'] ?? 'pnpm';
271
- await runChild(pmRunner, ['prisma', 'migrate', 'dev', '--name', args.name]);
272
- console.log('✓ schema migrate succeeded.');
447
+ }
448
+
449
+ /**
450
+ * The table names a schema declares — `@@map("x")` where present, the model
451
+ * name otherwise, which is what Prisma falls back to.
452
+ */
453
+ function tableNamesOf(schema) {
454
+ const names = [];
455
+ for (const line of schema.split('\n')) {
456
+ const opening = /^model\s+(\w+)\s*\{/.exec(line);
457
+ if (opening) names.push(opening[1]);
458
+ const mapped = /^\s*@@map\("(\w+)"\)/.exec(line);
459
+ if (mapped) names.push(mapped[1]);
460
+ }
461
+ return names;
462
+ }
463
+
464
+ /** The migration directories that exist right now, or none. */
465
+ async function listMigrations(args) {
466
+ const { schemaPath } = await readSchemaOrExit(args);
467
+ return readdir(join(dirname(schemaPath), 'migrations')).catch(() => []);
468
+ }
469
+
470
+ /** `@saasicat/spec/sql/constraints.postgres.sql`, read from the installed package. */
471
+ function resolveConstraintsSql() {
472
+ const specEntry = require_.resolve('@saasicat/spec');
473
+ return join(dirname(specEntry), 'sql', 'constraints.postgres.sql');
474
+ }
475
+
476
+ /**
477
+ * Appends the non-DSL constraints to the migration `prisma migrate dev` just
478
+ * wrote.
479
+ *
480
+ * Deliberately after the Prisma run rather than into a hand-made file: the
481
+ * statements need the tables, and Prisma decides what the migration is called.
482
+ *
483
+ * Returns what happened rather than whether it worked. The caller has to tell
484
+ * "nothing to append" from "appending failed": only the second one may not be
485
+ * followed by `migrate dev`, because the advice it prints — add the SQL before
486
+ * applying — is only followable while the migration is still unapplied.
487
+ */
488
+ async function writeConstraintsIntoMigration(args, migrationsBefore) {
489
+ const { schemaPath } = await readSchemaOrExit(args);
490
+ const migrationsDir = join(dirname(schemaPath), 'migrations');
491
+ const sqlPath = resolveConstraintsSql();
492
+
493
+ try {
494
+ const directories = await readdir(migrationsDir);
495
+ // The one THIS run created, not the lexicographically last: if step 2
496
+ // produced nothing, the last one belongs to somebody else and has
497
+ // already been applied.
498
+ const newest = migrationCreatedBy(migrationsBefore, directories);
499
+ if (!newest) return reportConstraints('no-migration', { sqlPath });
500
+
501
+ const migrationFile = join(migrationsDir, newest, 'migration.sql');
502
+ const migrationSql = await readFile(migrationFile, 'utf8');
503
+ if (hasConstraints(migrationSql)) {
504
+ return reportConstraints('already-present', { sqlPath, migration: newest });
505
+ }
506
+
507
+ // Only the constraints whose tables this schema has: a run scoped with
508
+ // `--fragments` produces a migration without the others, and Prisma
509
+ // fails the whole thing against its shadow database with P1014.
510
+ const { schema: currentSchema } = await readSchemaOrExit(args);
511
+ const applicable = constraintsFor(
512
+ await readFile(sqlPath, 'utf8'),
513
+ tableNamesOf(currentSchema),
514
+ );
515
+ if (applicable.trim() === '') {
516
+ return reportConstraints('not-applicable', { sqlPath, migration: newest });
517
+ }
518
+ await writeFile(migrationFile, appendConstraints(migrationSql, applicable));
519
+ return reportConstraints('appended', { sqlPath, migration: newest });
520
+ } catch (err) {
521
+ console.error(` ! ${err?.message ?? String(err)}`);
522
+ return reportConstraints('failed', { sqlPath });
523
+ }
524
+ }
525
+
526
+ /** Reads a repeated flag (`--quota=a --quota=b`) off argv. */
527
+ function repeatedFlag(argv, name) {
528
+ return argv
529
+ .filter((arg) => arg.startsWith(`--${name}=`))
530
+ .map((arg) => arg.slice(name.length + 3));
531
+ }
532
+
533
+ async function cmdInit(args, argv) {
534
+ if (!args['project-key']) {
535
+ console.error('✗ --project-key=<key> is required.');
536
+ console.error(' It names the catalogue this app administers.');
537
+ process.exit(1);
538
+ }
539
+
540
+ // A usage error, so it exits 1 like every other one here rather than
541
+ // through the top-level handler's 99. The rule itself comes from the
542
+ // catalogue schema — see src/init/project-key.ts.
543
+ try {
544
+ assertValidProjectKey(args['project-key']);
545
+ } catch (err) {
546
+ console.error(`✗ ${err.message}`);
547
+ process.exit(1);
548
+ }
549
+
550
+ const root = resolve(args.dir ?? '.');
551
+ const plan = planInit({
552
+ projectKey: args['project-key'],
553
+ appName: args['app-name'],
554
+ apiBase: args['api-base'],
555
+ quotas: repeatedFlag(argv, 'quota'),
556
+ skipHasher: args['skip-hasher'] === true,
557
+ });
558
+
559
+ const templatesDir = resolve(
560
+ dirname(fileURLToPath(import.meta.url)),
561
+ '..',
562
+ 'templates',
563
+ 'init',
564
+ );
565
+ if (!existsSync(templatesDir)) {
566
+ console.error(`✗ Templates not found: ${templatesDir}`);
567
+ process.exit(99);
568
+ }
569
+
570
+ // Every file first, then every write: a run that stops halfway leaves an
571
+ // app that neither compiles nor can be re-generated over.
572
+ const writes = [];
573
+ for (const file of plan.files) {
574
+ const raw = await readFile(join(templatesDir, `${file.template}.tpl`), 'utf8');
575
+ writes.push({
576
+ path: file.path,
577
+ dest: join(root, file.path),
578
+ content: applyTokens(raw, { ...plan.tokens, ...file.tokens }),
579
+ });
580
+ }
581
+
582
+ const existing = writes.filter((w) => existsSync(w.dest));
583
+ if (existing.length > 0 && !args['dry-run']) {
584
+ console.error(`✗ ${existing.length} file(s) already exist — refusing to overwrite:`);
585
+ for (const w of existing) console.error(` ${w.path}`);
586
+ console.error(' Move them aside, or run with --dry-run to see what would be written.');
587
+ process.exit(1);
588
+ }
589
+
590
+ if (args['dry-run']) {
591
+ console.log(`(--dry-run) Would write ${writes.length} file(s) under ${root}:`);
592
+ for (const w of writes) {
593
+ console.log(` ${w.path}${existsSync(w.dest) ? ' (EXISTS — would refuse)' : ''}`);
594
+ }
595
+ } else {
596
+ for (const w of writes) {
597
+ await mkdir(dirname(w.dest), { recursive: true });
598
+ await writeFile(w.dest, w.content, 'utf8');
599
+ }
600
+ console.log(`✓ Wrote ${writes.length} file(s) under ${root}`);
601
+ for (const w of writes) console.log(` ${w.path}`);
602
+ }
603
+
604
+ await patchAppModuleFile(root, plan, args);
605
+
606
+ console.log('');
607
+ console.log('Next steps:');
608
+ console.log(' 1. Name your auth guard in `controller: { guards: [YourAuthGuard] }`');
609
+ console.log(' — the generated app does NOT compile until you do. An empty');
610
+ console.log(' array means "deliberately auth-free" to the platform, and');
611
+ console.log(' would publish GET /admin/discovery to anyone who asks.');
612
+ if (plan.quotaProviders.length > 0) {
613
+ console.log(' 2. Check each quota provider counts the right thing');
614
+ console.log(' 3. saasicat schema migrate --name=add_saasicat');
615
+ } else {
616
+ console.log(' 2. saasicat schema migrate --name=add_saasicat');
617
+ }
618
+ }
619
+
620
+ /** Adds the platform to `src/app.module.ts`, or says exactly what to paste. */
621
+ async function patchAppModuleFile(root, plan, args) {
622
+ const appModulePath = join(root, 'src', 'app.module.ts');
623
+ // Derived in `init/plan.ts`, where it can be tested: this file is not.
624
+ const options = patchOptionsFor(plan);
625
+
626
+ if (!existsSync(appModulePath)) {
627
+ console.log('');
628
+ console.log(`! No ${appModulePath} — add the platform to your root module:`);
629
+ console.log(patchAppModule('', options).manualBlock);
630
+ return;
631
+ }
632
+
633
+ const source = await readFile(appModulePath, 'utf8');
634
+ const result = patchAppModule(source, options);
635
+
636
+ if (result.status === 'already-wired') {
637
+ console.log('');
638
+ console.log('= src/app.module.ts already calls SaaSiCatModule.forRoot — left alone.');
639
+ return;
640
+ }
641
+ if (result.status === 'declined') {
642
+ console.log('');
643
+ console.log(`! src/app.module.ts not patched: ${result.reason}.`);
644
+ console.log(' Add this yourself:');
645
+ console.log(result.manualBlock);
646
+ return;
647
+ }
648
+
649
+ if (args['dry-run']) {
650
+ console.log('');
651
+ console.log('(--dry-run) Would add SaaSiCatModule.forRoot(...) to src/app.module.ts');
652
+ } else {
653
+ await writeFile(appModulePath, result.source, 'utf8');
654
+ console.log('');
655
+ console.log('+ src/app.module.ts now imports SaaSiCatModule.');
656
+ }
657
+ console.log(' Still yours — add to `providers`, with its two imports:');
658
+ for (const line of LIMIT_FILTER_IMPORTS.split('\n')) console.log(` ${line}`);
659
+ console.log(` ${LIMIT_FILTER_PROVIDER}`);
660
+ console.log(' (maps @EnforceQuota overruns to HTTP 402 — see docs/quickstart.md)');
273
661
  }
274
662
 
275
663
  async function main() {
276
664
  const [, , cmd, sub, ...rest] = process.argv;
665
+ if (cmd === 'init') {
666
+ return cmdInit(parseArgs([sub, ...rest].filter(Boolean)), process.argv.slice(3));
667
+ }
277
668
  if (cmd === 'schema' && sub === 'apply') {
278
669
  return cmdSchemaApply(parseArgs(rest));
279
670
  }
@@ -294,9 +685,18 @@ async function main() {
294
685
  ' schema check report drift against @saasicat/spec',
295
686
  );
296
687
  console.log(' schema check --fragments=01,02 check only these fragments');
297
- console.log(' schema migrate --name=<name> apply --all + prisma migrate dev');
688
+ console.log(
689
+ ' schema migrate --name=<name> apply --all + migrate dev + constraints',
690
+ );
691
+ console.log('');
692
+ console.log(' init --project-key=<key> --quota=<key>:<Model>');
693
+ console.log(' scaffold the platform wiring. At least one --quota:');
694
+ console.log(' every plan must declare one, or the catalogue does not load.');
695
+ console.log(' init --project-key=myapp --quota=notes:Note --quota=seats:Seat');
298
696
  console.log('');
299
697
  console.log('Optional --prisma-schema=PATH (default prisma/schema.prisma).');
698
+ console.log('Optional --tenant-model=X --user-model=Y for apply/migrate — enables');
699
+ console.log(' the foreign keys from the platform tables to your models.');
300
700
  console.log('Optional --package-manager=pnpm|npm|yarn (default pnpm).');
301
701
  return;
302
702
  }