@saasicat/cli 1.0.0-rc.0 → 1.0.0-rc.10
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/README.md +36 -1
- package/bin/saasicat.js +335 -34
- package/codemods/v1-rename.map.json +6 -0
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +564 -30
- package/dist/index.d.cts +246 -14
- package/dist/index.d.ts +246 -14
- package/dist/index.js +542 -24
- package/package.json +7 -6
- package/templates/init/config/saas.yaml.tpl +40 -2
- package/templates/init/src/auth/password.hasher.ts.tpl +1 -1
- package/templates/init/src/saas/admin-manifest.contribution.ts.tpl +2 -2
- package/templates/init/src/saas/feature-ui-registry.ts.tpl +1 -1
- package/templates/init/src/saas/persistence-without-hasher.ts.tpl +1 -1
- package/templates/init/src/saas/persistence.ts.tpl +1 -1
- package/templates/init/src/saas/quota.provider.ts.tpl +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @saasicat/cli
|
|
2
2
|
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
3
5
|
Cross-cutting helpers for consumer CLIs. Provides:
|
|
4
6
|
|
|
5
7
|
- `CliContextService` — identity / MFA / production-confirm / audit-tag
|
|
@@ -12,6 +14,16 @@ Cross-cutting helpers for consumer CLIs. Provides:
|
|
|
12
14
|
|
|
13
15
|
Spec: [`cli-conventions.md`][conventions] in `@saasicat/spec`.
|
|
14
16
|
|
|
17
|
+
## What this is not
|
|
18
|
+
|
|
19
|
+
Not a CLI. There is no binary here: these are `nest-commander` flows and
|
|
20
|
+
services you register in **your** application's CLI, so they run with your
|
|
21
|
+
DI container, your database connection and your configuration.
|
|
22
|
+
|
|
23
|
+
Not the schema tooling either — `saasicat schema apply|check|migrate` and
|
|
24
|
+
`saasicat init` ship in the `saasicat` binary of this package's `bin`, and
|
|
25
|
+
are documented in the quickstart rather than here.
|
|
26
|
+
|
|
15
27
|
## Plugin Architecture
|
|
16
28
|
|
|
17
29
|
Consumer CLIs are NestJS-Standalone applications based on
|
|
@@ -30,7 +42,25 @@ import { PrismaUserPortAdapter } from './adapters/prisma-user-port';
|
|
|
30
42
|
@Module({
|
|
31
43
|
imports: [
|
|
32
44
|
PrismaModule,
|
|
33
|
-
PlanCatalogModule.forRoot({
|
|
45
|
+
PlanCatalogModule.forRoot({
|
|
46
|
+
app: { name: 'MyApp' },
|
|
47
|
+
currency: 'EUR',
|
|
48
|
+
vatRate: 19,
|
|
49
|
+
// Spelled out here like the three above it. In an application
|
|
50
|
+
// these come from `loadPlanCatalogFromFile('config/saas.yaml')` —
|
|
51
|
+
// the settings are never in the database, so the file is the only
|
|
52
|
+
// place they can come from.
|
|
53
|
+
tenantBilling: {
|
|
54
|
+
cancellationNoticeDays: { monthly: 0, yearly: 0 },
|
|
55
|
+
selfServiceBlockedPlans: { asTarget: [], asSource: [] },
|
|
56
|
+
},
|
|
57
|
+
// The catalogue is read from the database, not from a file — the
|
|
58
|
+
// CLI's `plan-catalog import` puts it there.
|
|
59
|
+
sink: {
|
|
60
|
+
useFactory: (p) => new PrismaPlanCatalogReadSink(p),
|
|
61
|
+
inject: [PrismaService],
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
34
64
|
AdminModule.forRoot({
|
|
35
65
|
mfaPort: { useFactory: (p) => new PrismaMfaAdapter(p), inject: [PrismaService] },
|
|
36
66
|
auditPort: { useFactory: (p) => new PrismaAuditAdapter(p), inject: [PrismaService] },
|
|
@@ -141,3 +171,8 @@ Per [`cli-conventions.md`][conventions] §6:
|
|
|
141
171
|
| 99 | internal |
|
|
142
172
|
|
|
143
173
|
[conventions]: https://github.com/uelker70/saasicat/blob/main/packages/spec/cli-conventions.md
|
|
174
|
+
|
|
175
|
+
## Next
|
|
176
|
+
|
|
177
|
+
- [Extend your CLI](../../docs/guides/extend-your-cli.md) — registering these flows in your app
|
|
178
|
+
- [Quickstart](../../docs/quickstart.md) — `saasicat init`, `schema apply`, `schema check`
|
package/bin/saasicat.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `saasicat` — bootstrap CLI for the SaaSiCat framework.
|
|
3
3
|
// naming-history: the codemod help below names the pre-1.0 spellings it rewrites.
|
|
4
|
+
// project-key-history: `codemod v1-project-key` is named after what it removes.
|
|
4
5
|
//
|
|
5
6
|
// Sub-commands:
|
|
6
7
|
// schema apply [--prisma-schema=PATH] [--fragments=01,02,03]
|
|
@@ -16,10 +17,12 @@
|
|
|
16
17
|
// apply --all, then `prisma migrate dev`, then append the constraints
|
|
17
18
|
// Prisma's DSL cannot express to the migration it just wrote.
|
|
18
19
|
//
|
|
19
|
-
// init --
|
|
20
|
+
// init --app-key=X --quota=key:Model [--quota=…]... [--app-name=X] [--api-base=X]
|
|
20
21
|
// codemod v1-imports [--dir=X] [--dry-run]
|
|
21
22
|
// codemod v1-rename [--dir=X] [--dry-run]
|
|
22
|
-
// codemod v1
|
|
23
|
+
// codemod v1-project-key [--dir=X] [--dry-run]
|
|
24
|
+
// codemod v1-moved-settings [--dir=X] — and a `dbCatalog` still carrying values
|
|
25
|
+
// codemod v1 [--dir=X] [--dry-run] — all four, in that order
|
|
23
26
|
// [--skip-hasher] [--dry-run] [--dir=.]
|
|
24
27
|
// Writes the platform wiring — config, persistence, manifest
|
|
25
28
|
// contribution, admin module, one provider per quota — and adds
|
|
@@ -27,7 +30,7 @@
|
|
|
27
30
|
|
|
28
31
|
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
|
29
32
|
import { existsSync } from 'node:fs';
|
|
30
|
-
import { dirname, join, resolve } from 'node:path';
|
|
33
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
31
34
|
import { createRequire } from 'node:module';
|
|
32
35
|
import { fileURLToPath } from 'node:url';
|
|
33
36
|
import { spawn } from 'node:child_process';
|
|
@@ -43,18 +46,30 @@ import {
|
|
|
43
46
|
applyTokens,
|
|
44
47
|
constraintsFor,
|
|
45
48
|
checkSchema,
|
|
46
|
-
|
|
49
|
+
extractFragmentBlocks,
|
|
47
50
|
findFkPointers,
|
|
48
51
|
hasConstraints,
|
|
49
|
-
|
|
52
|
+
assertValidAppKey,
|
|
53
|
+
judgeModuleResolution,
|
|
54
|
+
readEffectiveModuleResolution,
|
|
50
55
|
buildImportMap,
|
|
51
56
|
migrationCreatedBy,
|
|
52
57
|
rewriteImports,
|
|
58
|
+
rewriteManifest,
|
|
53
59
|
rewriteNames,
|
|
60
|
+
removeProjectKey,
|
|
54
61
|
reportConstraints,
|
|
55
62
|
patchAppModule,
|
|
56
63
|
patchOptionsFor,
|
|
57
64
|
planInit,
|
|
65
|
+
settingsWrittenTo,
|
|
66
|
+
findMovedSettings,
|
|
67
|
+
SCANNED_FOR_MOVED_SETTINGS,
|
|
68
|
+
WHERE_IT_GOES,
|
|
69
|
+
describeDbCatalogOccurrence,
|
|
70
|
+
findDbCatalogBlocks,
|
|
71
|
+
SCANNED_FOR_DB_CATALOG,
|
|
72
|
+
WHERE_DB_CATALOG_GOES,
|
|
58
73
|
} from '../dist/index.js';
|
|
59
74
|
|
|
60
75
|
const require_ = createRequire(import.meta.url);
|
|
@@ -68,7 +83,7 @@ const require_ = createRequire(import.meta.url);
|
|
|
68
83
|
// `pascalCase` and came back as `value.replace is not a function` with exit 99
|
|
69
84
|
// — an internal error for what is an ordinary typo.
|
|
70
85
|
const VALUE_FLAGS = new Set([
|
|
71
|
-
'
|
|
86
|
+
'app-key',
|
|
72
87
|
'app-name',
|
|
73
88
|
'api-base',
|
|
74
89
|
'quota',
|
|
@@ -132,14 +147,15 @@ async function selectFragmentFiles(dir, filter) {
|
|
|
132
147
|
|
|
133
148
|
async function loadFragments(dir, filter) {
|
|
134
149
|
const selected = await selectFragmentFiles(dir, filter);
|
|
135
|
-
const blocks = new Map();
|
|
150
|
+
const blocks = { enums: new Map(), models: new Map() };
|
|
136
151
|
for (const file of selected) {
|
|
137
152
|
const content = await readFile(join(dir, file), 'utf8');
|
|
138
|
-
const fileBlocks =
|
|
139
|
-
for (const [name, body] of fileBlocks) {
|
|
140
|
-
if (!blocks.has(name))
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
const fileBlocks = extractFragmentBlocks(content);
|
|
154
|
+
for (const [name, body] of fileBlocks.enums) {
|
|
155
|
+
if (!blocks.enums.has(name)) blocks.enums.set(name, body);
|
|
156
|
+
}
|
|
157
|
+
for (const [name, body] of fileBlocks.models) {
|
|
158
|
+
if (!blocks.models.has(name)) blocks.models.set(name, body);
|
|
143
159
|
}
|
|
144
160
|
}
|
|
145
161
|
return { files: selected, blocks };
|
|
@@ -171,7 +187,7 @@ async function cmdSchemaApply(args) {
|
|
|
171
187
|
}
|
|
172
188
|
|
|
173
189
|
const { files, blocks } = await loadFragments(fragmentsDir, filter);
|
|
174
|
-
if (blocks.size === 0) {
|
|
190
|
+
if (blocks.models.size === 0) {
|
|
175
191
|
console.error('✗ No models found in the selected fragments.');
|
|
176
192
|
process.exit(1);
|
|
177
193
|
}
|
|
@@ -185,13 +201,16 @@ async function cmdSchemaApply(args) {
|
|
|
185
201
|
// one where the manual step is most likely to have been forgotten.
|
|
186
202
|
const fk = resolveFkPointers(result.schema, args);
|
|
187
203
|
|
|
188
|
-
if (result.added.length === 0 && fk.enabled.length === 0) {
|
|
204
|
+
if (result.added.length === 0 && result.addedEnums.length === 0 && fk.enabled.length === 0) {
|
|
189
205
|
console.log(`→ Nothing to do. Models already present: ${result.skipped.join(', ')}`);
|
|
190
206
|
reportFkPointers(fk, args);
|
|
191
207
|
return;
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
if (args['dry-run']) {
|
|
211
|
+
if (result.addedEnums.length) {
|
|
212
|
+
console.log(`(--dry-run) Would append enums: ${result.addedEnums.join(', ')}`);
|
|
213
|
+
}
|
|
195
214
|
if (result.added.length) {
|
|
196
215
|
console.log(`(--dry-run) Would append: ${result.added.join(', ')}`);
|
|
197
216
|
}
|
|
@@ -209,12 +228,18 @@ async function cmdSchemaApply(args) {
|
|
|
209
228
|
for (const { line } of fk.enabled) {
|
|
210
229
|
console.log(` ${line + 1}: ${fk.schema.split('\n')[line]?.trim() ?? ''}`);
|
|
211
230
|
}
|
|
212
|
-
|
|
213
|
-
if (
|
|
231
|
+
const appended = result.added.length > 0 || result.addedEnums.length > 0;
|
|
232
|
+
if (fk.enabled.length > 0 && appended) console.log('');
|
|
233
|
+
if (appended) console.log(result.schema.slice(schema.length));
|
|
214
234
|
return;
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
await writeFile(schemaPath, fk.schema, 'utf8');
|
|
238
|
+
if (result.addedEnums.length) {
|
|
239
|
+
console.log(
|
|
240
|
+
`✓ Appended ${result.addedEnums.length} enum(s): ${result.addedEnums.join(', ')}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
218
243
|
console.log(`✓ Appended ${result.added.length} model(s): ${result.added.join(', ')}`);
|
|
219
244
|
if (result.skipped.length) {
|
|
220
245
|
console.log(`→ Skipped (already present): ${result.skipped.join(', ')}`);
|
|
@@ -548,6 +573,7 @@ async function cmdCodemodV1Imports(args) {
|
|
|
548
573
|
let rewritten = 0;
|
|
549
574
|
let touched = 0;
|
|
550
575
|
await walkSources(root, async (full, source) => {
|
|
576
|
+
if (basename(full) === CODEMOD_MANIFEST) return;
|
|
551
577
|
const result = rewriteImports(source, map);
|
|
552
578
|
for (const [subpath, n] of result.unmapped) {
|
|
553
579
|
unmapped.set(subpath, (unmapped.get(subpath) ?? 0) + n);
|
|
@@ -592,8 +618,15 @@ async function cmdCodemodV1Rename(args) {
|
|
|
592
618
|
const ambiguous = new Map();
|
|
593
619
|
let rewritten = 0;
|
|
594
620
|
let touched = 0;
|
|
621
|
+
let manifestsTouched = 0;
|
|
595
622
|
await walkSources(root, async (full, source) => {
|
|
596
|
-
|
|
623
|
+
// A manifest takes the package renames in its dependency fields; a
|
|
624
|
+
// source file takes everything. Under pnpm an import a manifest does
|
|
625
|
+
// not declare fails to resolve, so the two travel together.
|
|
626
|
+
const result =
|
|
627
|
+
basename(full) === CODEMOD_MANIFEST
|
|
628
|
+
? rewriteManifest(source, table, { targetRange: `^${OWN_VERSION}` })
|
|
629
|
+
: rewriteNames(source, table);
|
|
597
630
|
for (const name of result.ambiguous) {
|
|
598
631
|
ambiguous.set(name, (ambiguous.get(name) ?? 0) + 1);
|
|
599
632
|
}
|
|
@@ -601,11 +634,26 @@ async function cmdCodemodV1Rename(args) {
|
|
|
601
634
|
if (!dryRun) await writeFile(full, result.text);
|
|
602
635
|
rewritten += result.rewritten;
|
|
603
636
|
touched += 1;
|
|
637
|
+
if (basename(full) === CODEMOD_MANIFEST) manifestsTouched += 1;
|
|
604
638
|
});
|
|
605
639
|
|
|
606
640
|
console.log(
|
|
607
641
|
`${dryRun ? 'Would rewrite' : 'Rewrote'} ${rewritten} name(s) in ${touched} file(s).`,
|
|
608
642
|
);
|
|
643
|
+
if (manifestsTouched > 0) {
|
|
644
|
+
// The lockfile is not rewritten: its shape is the package manager's,
|
|
645
|
+
// and a wrong guess at it is worse than an honest instruction. A CI
|
|
646
|
+
// that installs with a frozen lockfile refuses the migrated checkout
|
|
647
|
+
// until it is regenerated.
|
|
648
|
+
console.log('');
|
|
649
|
+
console.log(
|
|
650
|
+
`${manifestsTouched} package.json ${manifestsTouched === 1 ? 'file' : 'files'} changed — ` +
|
|
651
|
+
'regenerate the lockfile before committing:',
|
|
652
|
+
);
|
|
653
|
+
console.log(
|
|
654
|
+
' pnpm install (or npm install / yarn install, whichever owns your lockfile)',
|
|
655
|
+
);
|
|
656
|
+
}
|
|
609
657
|
if (ambiguous.size === 0) return;
|
|
610
658
|
|
|
611
659
|
console.log('');
|
|
@@ -617,8 +665,20 @@ async function cmdCodemodV1Rename(args) {
|
|
|
617
665
|
console.log(' FEATURE_UI_REGISTRY_TOKEN meant one registry in `@saasicat/nest/billing`');
|
|
618
666
|
console.log(' and another in `@saasicat/nest/catalog`. Import it from the entry you');
|
|
619
667
|
console.log(' mean — BILLING_FEATURE_UI_REGISTRY_TOKEN or CATALOG_FEATURE_UI_REGISTRY_TOKEN.');
|
|
668
|
+
console.log(' A dependency listed "in <field> (<range>)" points at a workspace or a path;');
|
|
669
|
+
console.log(' rename it by hand to @saasicat/core at the location you keep it.');
|
|
620
670
|
}
|
|
621
671
|
|
|
672
|
+
/**
|
|
673
|
+
* The version this CLI was released as — and therefore the line a consumer
|
|
674
|
+
* running its codemod is migrating to. The manifest rewrite sets the renamed
|
|
675
|
+
* dependency to `^<this>`, because the old range (`^0.27.0`) names a line
|
|
676
|
+
* the renamed package was never published on.
|
|
677
|
+
*/
|
|
678
|
+
const OWN_VERSION = JSON.parse(
|
|
679
|
+
await readFile(join(dirname(require_.resolve('@saasicat/cli')), '..', 'package.json'), 'utf8'),
|
|
680
|
+
).version;
|
|
681
|
+
|
|
622
682
|
/** Where a shipped codemod table lives, resolved through the package itself. */
|
|
623
683
|
function codemodTable(name) {
|
|
624
684
|
return join(dirname(require_.resolve('@saasicat/cli')), '..', 'codemods', name);
|
|
@@ -630,9 +690,32 @@ function codemodTable(name) {
|
|
|
630
690
|
const CODEMOD_SKIP = new Set(['node_modules', '.git', '.output', 'coverage']);
|
|
631
691
|
const isBuildOutput = (name) => name === 'dist' || name.startsWith('dist-');
|
|
632
692
|
const CODEMOD_EXTENSIONS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue|md)$/;
|
|
693
|
+
/**
|
|
694
|
+
* Widens the walk for `v1-project-key` alone — see `walkSources`.
|
|
695
|
+
*
|
|
696
|
+
* `.prisma` belongs here for the reason `.yaml` does: a consumer copies the
|
|
697
|
+
* platform's models into their own `schema.prisma`, which is the documented
|
|
698
|
+
* integration path, so that file carries `projectKey` fields and composite
|
|
699
|
+
* indexes of its own. Left out of the walk they were neither rewritten nor
|
|
700
|
+
* reported — and after the SQL migration drops the columns, a schema still
|
|
701
|
+
* declaring them generates a client that queries them, while the next
|
|
702
|
+
* `db push` tries to put them back.
|
|
703
|
+
*
|
|
704
|
+
* Reported rather than rewritten, like every other declaration: a `.prisma`
|
|
705
|
+
* model is a schema, and which of its fields a consumer still needs is theirs.
|
|
706
|
+
*/
|
|
707
|
+
const CODEMOD_CONFIG_EXTENSIONS = /\.(yaml|yml|prisma)$/;
|
|
708
|
+
/** Walked for the package renames alone; see `rewriteManifest`. */
|
|
709
|
+
const CODEMOD_MANIFEST = 'package.json';
|
|
633
710
|
|
|
634
|
-
/**
|
|
635
|
-
|
|
711
|
+
/**
|
|
712
|
+
* Every source file under `root` a codemod may touch, with its text.
|
|
713
|
+
*
|
|
714
|
+
* `extra` widens the set for one codemod. Only `v1-project-key` passes it, and
|
|
715
|
+
* only for `.yaml`: the other two rewrite identifiers and import specifiers,
|
|
716
|
+
* and letting them loose on a configuration file would corrupt it.
|
|
717
|
+
*/
|
|
718
|
+
async function walkSources(root, visit, extra = null) {
|
|
636
719
|
const walk = async (dir) => {
|
|
637
720
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
638
721
|
if (CODEMOD_SKIP.has(entry.name) || isBuildOutput(entry.name)) continue;
|
|
@@ -641,13 +724,151 @@ async function walkSources(root, visit) {
|
|
|
641
724
|
await walk(full);
|
|
642
725
|
continue;
|
|
643
726
|
}
|
|
644
|
-
|
|
727
|
+
const included =
|
|
728
|
+
CODEMOD_EXTENSIONS.test(entry.name) ||
|
|
729
|
+
entry.name === CODEMOD_MANIFEST ||
|
|
730
|
+
(extra !== null && extra.test(entry.name));
|
|
731
|
+
if (!included) continue;
|
|
645
732
|
await visit(full, await readFile(full, 'utf8'));
|
|
646
733
|
}
|
|
647
734
|
};
|
|
648
735
|
await walk(root);
|
|
649
736
|
}
|
|
650
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Takes `projectKey` out of a consumer's code, and names what it will not.
|
|
740
|
+
*
|
|
741
|
+
* A removal is not a rename: the word is an ordinary property name. Two forms
|
|
742
|
+
* need no grammar to decide — a `?projectKey=` on a `/catalog/` URL, and the
|
|
743
|
+
* top-level key of a `saas.yaml` — and those are rewritten. An object member is
|
|
744
|
+
* reported: an object literal and a type literal are lexically identical in
|
|
745
|
+
* TypeScript, so removing one would sometimes delete a member of the consumer's
|
|
746
|
+
* own type. The migration guide's table says what each shape becomes.
|
|
747
|
+
*/
|
|
748
|
+
/**
|
|
749
|
+
* Names the module options that moved into `config/saas.yaml`.
|
|
750
|
+
*
|
|
751
|
+
* Read-only on purpose — see `codemods/v1-moved-settings.ts` for why removing
|
|
752
|
+
* them would lose the decision instead of moving it. `--dry-run` is accepted
|
|
753
|
+
* and does nothing, so `codemod v1 --dry-run` behaves the same throughout.
|
|
754
|
+
*/
|
|
755
|
+
async function cmdCodemodV1MovedSettings(args) {
|
|
756
|
+
const root = resolve(args.dir ?? '.');
|
|
757
|
+
const found = [];
|
|
758
|
+
const blocks = [];
|
|
759
|
+
|
|
760
|
+
await walkSources(root, async (full, source) => {
|
|
761
|
+
// Code only. The walk includes Markdown, and a documentation file that
|
|
762
|
+
// mentions a setting is not a file that passes one — see
|
|
763
|
+
// `SCANNED_FOR_MOVED_SETTINGS`.
|
|
764
|
+
if (!SCANNED_FOR_MOVED_SETTINGS.test(full)) return;
|
|
765
|
+
for (const { setting, line } of findMovedSettings(source).occurrences) {
|
|
766
|
+
found.push({ where: `${relative(root, full)}:${line}`, setting });
|
|
767
|
+
}
|
|
768
|
+
if (!SCANNED_FOR_DB_CATALOG.test(full)) return;
|
|
769
|
+
for (const { shape, line, leftovers } of findDbCatalogBlocks(source).occurrences) {
|
|
770
|
+
blocks.push({ where: `${relative(root, full)}:${line}`, shape, leftovers });
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
if (found.length === 0) {
|
|
775
|
+
console.log('No module option that moved into config/saas.yaml is still passed.');
|
|
776
|
+
reportDbCatalogBlocks(blocks);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.log(`${found.length} occurrence(s) of a setting that moved:`);
|
|
781
|
+
const width = Math.max(...found.map((f) => f.where.length));
|
|
782
|
+
for (const { where, setting } of found) {
|
|
783
|
+
console.log(` ${where.padEnd(width)} ${setting}`);
|
|
784
|
+
}
|
|
785
|
+
console.log('');
|
|
786
|
+
console.log(' Not removed, and that is the point: the value is a term somebody agreed,');
|
|
787
|
+
console.log(' and deleting it here without writing it into the file would leave the');
|
|
788
|
+
console.log(' application running on whatever the file happens to say. Move each one:');
|
|
789
|
+
console.log('');
|
|
790
|
+
for (const setting of new Set(found.map((f) => f.setting))) {
|
|
791
|
+
console.log(` ${setting}`);
|
|
792
|
+
console.log(` ${WHERE_IT_GOES[setting]}`);
|
|
793
|
+
}
|
|
794
|
+
console.log('');
|
|
795
|
+
console.log(' TenantBillingModule.forRoot() refuses to boot while either is still');
|
|
796
|
+
console.log(' passed, so this cannot be half-done quietly.');
|
|
797
|
+
console.log('');
|
|
798
|
+
reportDbCatalogBlocks(blocks);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Names a `dbCatalog` that still carries the settings as values.
|
|
803
|
+
*
|
|
804
|
+
* The same decision as the settings above, one option up: the values were
|
|
805
|
+
* forwarded from `config/saas.yaml`, and the option names that file now. Not
|
|
806
|
+
* rewritten, because which file is a variable in another module more often
|
|
807
|
+
* than a literal here — see `codemods/v1-db-catalog.ts`.
|
|
808
|
+
*/
|
|
809
|
+
function reportDbCatalogBlocks(blocks) {
|
|
810
|
+
if (blocks.length === 0) {
|
|
811
|
+
console.log('No dbCatalog still carries the settings as values.');
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
console.log(`${blocks.length} dbCatalog block(s) to point at the file:`);
|
|
815
|
+
const width = Math.max(...blocks.map((b) => b.where.length));
|
|
816
|
+
for (const { where, shape, leftovers } of blocks) {
|
|
817
|
+
console.log(
|
|
818
|
+
` ${where.padEnd(width)} ${describeDbCatalogOccurrence({ shape, leftovers })}`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
console.log('');
|
|
822
|
+
console.log(` ${WHERE_DB_CATALOG_GOES}`);
|
|
823
|
+
console.log('');
|
|
824
|
+
console.log(' SaaSiCatModule.forRoot() refuses to boot while the values are still passed,');
|
|
825
|
+
console.log(' so this cannot be half-done quietly.');
|
|
826
|
+
console.log('');
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function cmdCodemodV1ProjectKey(args) {
|
|
830
|
+
const root = resolve(args.dir ?? '.');
|
|
831
|
+
const dryRun = args['dry-run'] === true;
|
|
832
|
+
|
|
833
|
+
const undecided = [];
|
|
834
|
+
let rewritten = 0;
|
|
835
|
+
let touched = 0;
|
|
836
|
+
await walkSources(
|
|
837
|
+
root,
|
|
838
|
+
async (full, source) => {
|
|
839
|
+
if (basename(full) === CODEMOD_MANIFEST) return;
|
|
840
|
+
const result = removeProjectKey(source, isCatalogConfig(full) ? 'yaml' : 'source');
|
|
841
|
+
for (const line of result.undecided) undecided.push(`${relative(root, full)}:${line}`);
|
|
842
|
+
if (result.rewritten === 0) return;
|
|
843
|
+
if (!dryRun) await writeFile(full, result.text);
|
|
844
|
+
rewritten += result.rewritten;
|
|
845
|
+
touched += 1;
|
|
846
|
+
},
|
|
847
|
+
CODEMOD_CONFIG_EXTENSIONS,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
console.log(
|
|
851
|
+
`${dryRun ? 'Would remove' : 'Removed'} ${rewritten} occurrence(s) in ${touched} file(s).`,
|
|
852
|
+
);
|
|
853
|
+
if (undecided.length === 0) return;
|
|
854
|
+
|
|
855
|
+
console.log('');
|
|
856
|
+
console.log(`${undecided.length} occurrence(s) are yours to look at:`);
|
|
857
|
+
for (const where of undecided) console.log(` ${where}`);
|
|
858
|
+
console.log('');
|
|
859
|
+
console.log(' Two kinds of file end up here. In TypeScript an object literal and a type');
|
|
860
|
+
console.log(' literal are the same tokens, so this cannot tell a payload member from one');
|
|
861
|
+
console.log(' of your own declarations without parsing. And a `.prisma` model is a schema:');
|
|
862
|
+
console.log(" which of its fields you still need is yours to say, not this tool's.");
|
|
863
|
+
console.log(' It reports rather than guesses — docs/guides/upgrade-to-1.0.md has a table');
|
|
864
|
+
console.log(' of what each shape becomes.');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** A `saas.yaml`, whose top-level `projectKey:` needs no anchor. */
|
|
868
|
+
function isCatalogConfig(full) {
|
|
869
|
+
return basename(full) === 'saas.yaml' || basename(full) === 'saas.yml';
|
|
870
|
+
}
|
|
871
|
+
|
|
651
872
|
/** Reads a repeated flag (`--quota=a --quota=b`) off argv. */
|
|
652
873
|
function repeatedFlag(argv, name) {
|
|
653
874
|
return argv
|
|
@@ -656,25 +877,42 @@ function repeatedFlag(argv, name) {
|
|
|
656
877
|
}
|
|
657
878
|
|
|
658
879
|
async function cmdInit(args, argv) {
|
|
659
|
-
if (!args['
|
|
660
|
-
console.error('✗ --
|
|
661
|
-
console.error(' It
|
|
880
|
+
if (!args['app-key']) {
|
|
881
|
+
console.error('✗ --app-key=<key> is required.');
|
|
882
|
+
console.error(' It is the slug of this application: npm package name, storage prefix.');
|
|
662
883
|
process.exit(1);
|
|
663
884
|
}
|
|
664
885
|
|
|
665
886
|
// 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
|
|
667
|
-
//
|
|
887
|
+
// through the top-level handler's 99. The rule itself is in
|
|
888
|
+
// src/init/catalog-keys.ts.
|
|
668
889
|
try {
|
|
669
|
-
|
|
890
|
+
assertValidAppKey(args['app-key']);
|
|
670
891
|
} catch (err) {
|
|
671
892
|
console.error(`✗ ${err.message}`);
|
|
672
893
|
process.exit(1);
|
|
673
894
|
}
|
|
674
895
|
|
|
675
896
|
const root = resolve(args.dir ?? '.');
|
|
897
|
+
|
|
898
|
+
// Before anything is written: the files below import subpath exports,
|
|
899
|
+
// and under `moduleResolution: node` none of them resolves. Found by
|
|
900
|
+
// running the quickstart against an app that predates `nodenext`.
|
|
901
|
+
if (existsSync(join(root, 'tsconfig.json'))) {
|
|
902
|
+
const ts = loadTypeScript(root);
|
|
903
|
+
if (ts === null) {
|
|
904
|
+
console.log('! tsconfig.json not checked: no TypeScript resolvable from this project.');
|
|
905
|
+
} else {
|
|
906
|
+
const verdict = judgeModuleResolution(readEffectiveModuleResolution(root, ts));
|
|
907
|
+
if (!verdict.ok) {
|
|
908
|
+
console.error(`✗ ${verdict.reason}`);
|
|
909
|
+
process.exit(1);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
676
914
|
const plan = planInit({
|
|
677
|
-
|
|
915
|
+
appKey: args['app-key'],
|
|
678
916
|
appName: args['app-name'],
|
|
679
917
|
apiBase: args['api-base'],
|
|
680
918
|
quotas: repeatedFlag(argv, 'quota'),
|
|
@@ -726,6 +964,8 @@ async function cmdInit(args, argv) {
|
|
|
726
964
|
for (const w of writes) console.log(` ${w.path}`);
|
|
727
965
|
}
|
|
728
966
|
|
|
967
|
+
reportSettings(writes, root);
|
|
968
|
+
|
|
729
969
|
await patchAppModuleFile(root, plan, args);
|
|
730
970
|
|
|
731
971
|
console.log('');
|
|
@@ -734,14 +974,62 @@ async function cmdInit(args, argv) {
|
|
|
734
974
|
console.log(' — the generated app does NOT compile until you do. An empty');
|
|
735
975
|
console.log(' array means "deliberately auth-free" to the platform, and');
|
|
736
976
|
console.log(' would publish GET /admin/discovery to anyone who asks.');
|
|
977
|
+
console.log(' 2. Name the modules in `imports: [YourPrismaModule, YourAuthModule]`');
|
|
978
|
+
console.log(' — the one exporting PrismaService and the one your guard needs.');
|
|
979
|
+
console.log(' The platform module resolves its providers from that list;');
|
|
980
|
+
console.log(' without it the first boot stops at "Nest can\'t resolve');
|
|
981
|
+
console.log(' dependencies of … (PrismaService)".');
|
|
737
982
|
if (plan.quotaProviders.length > 0) {
|
|
738
|
-
console.log('
|
|
739
|
-
console.log('
|
|
983
|
+
console.log(' 3. Check each quota provider counts the right thing');
|
|
984
|
+
console.log(' 4. saasicat schema migrate --name=add_saasicat');
|
|
740
985
|
} else {
|
|
741
|
-
console.log('
|
|
986
|
+
console.log(' 3. saasicat schema migrate --name=add_saasicat');
|
|
742
987
|
}
|
|
743
988
|
}
|
|
744
989
|
|
|
990
|
+
/**
|
|
991
|
+
* Says which settings the catalogue now carries, with their values and its path.
|
|
992
|
+
*
|
|
993
|
+
* These are required fields without defaults, and there is no second place they
|
|
994
|
+
* can come from. Somebody who has just run `init` should not have to find that
|
|
995
|
+
* out from a boot failure six weeks later — so the command names the file it
|
|
996
|
+
* put them in while they are still looking at the output.
|
|
997
|
+
*
|
|
998
|
+
* Derived in `init/settings-written.ts`, where it can be tested: this file is
|
|
999
|
+
* not.
|
|
1000
|
+
*/
|
|
1001
|
+
function reportSettings(writes, root) {
|
|
1002
|
+
const catalog = writes.find((w) => w.path === 'config/saas.yaml');
|
|
1003
|
+
if (!catalog) return;
|
|
1004
|
+
const settings = settingsWrittenTo(catalog.content, catalog.path);
|
|
1005
|
+
if (settings.length === 0) return;
|
|
1006
|
+
|
|
1007
|
+
const width = Math.max(...settings.map((s) => s.key.length));
|
|
1008
|
+
console.log('');
|
|
1009
|
+
console.log(`Settings written to ${join(root, catalog.path)}:`);
|
|
1010
|
+
for (const { key, value } of settings) {
|
|
1011
|
+
console.log(` ${key.padEnd(width)} ${value}`);
|
|
1012
|
+
}
|
|
1013
|
+
console.log(' This file is where they live. Editing it is how they change, and the');
|
|
1014
|
+
console.log(' change lands on the next restart — the platform reads it at boot.');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The TypeScript that will compile the project: the consumer's own, resolved
|
|
1019
|
+
* from the project root, so the tsconfig is read the way the build reads it.
|
|
1020
|
+
* Falls back to the one this CLI was installed with, then to null.
|
|
1021
|
+
*/
|
|
1022
|
+
function loadTypeScript(root) {
|
|
1023
|
+
for (const from of [join(root, 'package.json'), import.meta.url]) {
|
|
1024
|
+
try {
|
|
1025
|
+
return createRequire(from)('typescript');
|
|
1026
|
+
} catch {
|
|
1027
|
+
// Not resolvable from here — try the next origin.
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
745
1033
|
/** Adds the platform to `src/app.module.ts`, or says exactly what to paste. */
|
|
746
1034
|
async function patchAppModuleFile(root, plan, args) {
|
|
747
1035
|
const appModulePath = join(root, 'src', 'app.module.ts');
|
|
@@ -793,11 +1081,19 @@ async function main() {
|
|
|
793
1081
|
if (cmd === 'codemod' && sub === 'v1-rename') {
|
|
794
1082
|
return cmdCodemodV1Rename(parseArgs(rest));
|
|
795
1083
|
}
|
|
1084
|
+
if (cmd === 'codemod' && sub === 'v1-project-key') {
|
|
1085
|
+
return cmdCodemodV1ProjectKey(parseArgs(rest));
|
|
1086
|
+
}
|
|
1087
|
+
if (cmd === 'codemod' && sub === 'v1-moved-settings') {
|
|
1088
|
+
return cmdCodemodV1MovedSettings(parseArgs(rest));
|
|
1089
|
+
}
|
|
796
1090
|
if (cmd === 'codemod' && sub === 'v1') {
|
|
797
1091
|
// Imports first: the rename table keys its per-entry tokens by the
|
|
798
1092
|
// specifier they are imported from, which the import rewrite settles.
|
|
799
1093
|
await cmdCodemodV1Imports(parseArgs(rest));
|
|
800
|
-
|
|
1094
|
+
await cmdCodemodV1Rename(parseArgs(rest));
|
|
1095
|
+
await cmdCodemodV1ProjectKey(parseArgs(rest));
|
|
1096
|
+
return cmdCodemodV1MovedSettings(parseArgs(rest));
|
|
801
1097
|
}
|
|
802
1098
|
if (cmd === 'init') {
|
|
803
1099
|
return cmdInit(parseArgs([sub, ...rest].filter(Boolean)), process.argv.slice(3));
|
|
@@ -826,18 +1122,23 @@ async function main() {
|
|
|
826
1122
|
' schema migrate --name=<name> apply --all + migrate dev + constraints',
|
|
827
1123
|
);
|
|
828
1124
|
console.log('');
|
|
829
|
-
console.log(' init --
|
|
1125
|
+
console.log(' init --app-key=<key> --quota=<key>:<Model>');
|
|
830
1126
|
console.log(' scaffold the platform wiring. At least one --quota:');
|
|
831
1127
|
console.log(' every plan must declare one, or the catalogue does not load.');
|
|
832
|
-
console.log(' init --
|
|
1128
|
+
console.log(' init --app-key=myapp --quota=notes:Note --quota=seats:Seat');
|
|
833
1129
|
console.log('');
|
|
834
1130
|
console.log(' codemod v1 [--dir=.] [--dry-run]');
|
|
835
|
-
console.log(' the whole 1.0 migration:
|
|
1131
|
+
console.log(' the whole 1.0 migration: imports, names, projectKey, then the');
|
|
1132
|
+
console.log(' settings that moved into config/saas.yaml');
|
|
836
1133
|
console.log(' codemod v1-imports [--dir=.] [--dry-run]');
|
|
837
1134
|
console.log(' rewrite @saasicat/ui-vue imports to the 1.0 export map');
|
|
838
1135
|
console.log(' codemod v1-rename [--dir=.] [--dry-run]');
|
|
839
1136
|
console.log(' rewrite the names 1.0 changed: SaasPlatform*, Symbol.for keys,');
|
|
840
1137
|
console.log(' FEATURE_UI_REGISTRY_TOKEN, @saasicat/ui-vue/testing-e2e/*');
|
|
1138
|
+
console.log(' codemod v1-moved-settings [--dir=.]');
|
|
1139
|
+
console.log(' name the module options that moved into config/saas.yaml.');
|
|
1140
|
+
console.log(' Reports only — the value is a commercial decision, and this');
|
|
1141
|
+
console.log(' would delete it without writing it anywhere.');
|
|
841
1142
|
console.log('');
|
|
842
1143
|
console.log('Optional --prisma-schema=PATH (default prisma/schema.prisma).');
|
|
843
1144
|
console.log('Optional --tenant-model=X --user-model=Y for apply/migrate — enables');
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"consumer that spelled a key themselves gets the same symbol the platform",
|
|
16
16
|
"now registers; without this their injection would resolve to nothing.",
|
|
17
17
|
"",
|
|
18
|
+
"`packages` renames a whole package — in specifiers and in package.json.",
|
|
19
|
+
"",
|
|
18
20
|
"`entryTokens` is keyed by the import specifier, because the old name",
|
|
19
21
|
"meant different registries in different entries. An import of it from",
|
|
20
22
|
"anywhere else is reported, not guessed."
|
|
@@ -41,5 +43,9 @@
|
|
|
41
43
|
},
|
|
42
44
|
"subpaths": {
|
|
43
45
|
"@saasicat/ui-vue/testing-e2e/": "@saasicat/ui-vue/testing/"
|
|
46
|
+
},
|
|
47
|
+
"packages": {
|
|
48
|
+
"_": "A package that was renamed. Applied to import specifiers AND to the dependency fields of every package.json the walk meets: an import rewritten without its manifest does not resolve under pnpm.",
|
|
49
|
+
"@saasicat/types": "@saasicat/core"
|
|
44
50
|
}
|
|
45
51
|
}
|
package/dist/.build-stamp
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
83558356586a974819603d3c58eb8044516515cce6507e6156db23e7a89b1aac
|