@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/LICENSE +90 -201
- package/bin/saasicat.js +416 -16
- package/dist/index.cjs +561 -15
- package/dist/index.d.cts +377 -1
- package/dist/index.d.ts +377 -1
- package/dist/index.js +532 -14
- package/package.json +7 -6
- package/templates/init/config/saas.yaml.tpl +30 -0
- package/templates/init/src/auth/password.hasher.ts.tpl +33 -0
- package/templates/init/src/saas/admin-manifest.contribution.ts.tpl +33 -0
- package/templates/init/src/saas/admin.module.ts.tpl +24 -0
- package/templates/init/src/saas/feature-ui-registry.ts.tpl +16 -0
- package/templates/init/src/saas/persistence-without-hasher.ts.tpl +24 -0
- package/templates/init/src/saas/persistence.ts.tpl +22 -0
- package/templates/init/src/saas/quota.provider.ts.tpl +31 -0
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 {
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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(
|
|
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
|
}
|