@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/LICENSE +90 -201
- package/README.md +4 -2
- package/bin/saasicat.js +562 -17
- package/codemods/v1-imports.map.json +135 -0
- package/codemods/v1-rename.map.json +45 -0
- package/dist/.build-stamp +1 -0
- package/dist/index.cjs +765 -24
- package/dist/index.d.cts +461 -1
- package/dist/index.d.ts +461 -1
- package/dist/index.js +729 -23
- package/package.json +10 -8
- 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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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(
|
|
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
|
}
|