@saasicat/cli 1.0.0-rc.1 → 1.0.0-rc.11

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 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({ path: './config/plans.yaml' }),
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 --project-key=X --quota=key:Model [--quota=…]... [--app-name=X] [--api-base=X]
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 [--dir=X] [--dry-run] — both, in that order
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 { basename, 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,19 +46,30 @@ import {
43
46
  applyTokens,
44
47
  constraintsFor,
45
48
  checkSchema,
46
- extractModelBlocks,
49
+ extractFragmentBlocks,
47
50
  findFkPointers,
48
51
  hasConstraints,
49
- assertValidProjectKey,
52
+ assertValidAppKey,
53
+ judgeModuleResolution,
54
+ readEffectiveModuleResolution,
50
55
  buildImportMap,
51
56
  migrationCreatedBy,
52
57
  rewriteImports,
53
58
  rewriteManifest,
54
59
  rewriteNames,
60
+ removeProjectKey,
55
61
  reportConstraints,
56
62
  patchAppModule,
57
63
  patchOptionsFor,
58
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,
59
73
  } from '../dist/index.js';
60
74
 
61
75
  const require_ = createRequire(import.meta.url);
@@ -69,7 +83,7 @@ const require_ = createRequire(import.meta.url);
69
83
  // `pascalCase` and came back as `value.replace is not a function` with exit 99
70
84
  // — an internal error for what is an ordinary typo.
71
85
  const VALUE_FLAGS = new Set([
72
- 'project-key',
86
+ 'app-key',
73
87
  'app-name',
74
88
  'api-base',
75
89
  'quota',
@@ -133,14 +147,15 @@ async function selectFragmentFiles(dir, filter) {
133
147
 
134
148
  async function loadFragments(dir, filter) {
135
149
  const selected = await selectFragmentFiles(dir, filter);
136
- const blocks = new Map();
150
+ const blocks = { enums: new Map(), models: new Map() };
137
151
  for (const file of selected) {
138
152
  const content = await readFile(join(dir, file), 'utf8');
139
- const fileBlocks = extractModelBlocks(content);
140
- for (const [name, body] of fileBlocks) {
141
- if (!blocks.has(name)) {
142
- blocks.set(name, body);
143
- }
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);
144
159
  }
145
160
  }
146
161
  return { files: selected, blocks };
@@ -172,7 +187,7 @@ async function cmdSchemaApply(args) {
172
187
  }
173
188
 
174
189
  const { files, blocks } = await loadFragments(fragmentsDir, filter);
175
- if (blocks.size === 0) {
190
+ if (blocks.models.size === 0) {
176
191
  console.error('✗ No models found in the selected fragments.');
177
192
  process.exit(1);
178
193
  }
@@ -186,13 +201,16 @@ async function cmdSchemaApply(args) {
186
201
  // one where the manual step is most likely to have been forgotten.
187
202
  const fk = resolveFkPointers(result.schema, args);
188
203
 
189
- if (result.added.length === 0 && fk.enabled.length === 0) {
204
+ if (result.added.length === 0 && result.addedEnums.length === 0 && fk.enabled.length === 0) {
190
205
  console.log(`→ Nothing to do. Models already present: ${result.skipped.join(', ')}`);
191
206
  reportFkPointers(fk, args);
192
207
  return;
193
208
  }
194
209
 
195
210
  if (args['dry-run']) {
211
+ if (result.addedEnums.length) {
212
+ console.log(`(--dry-run) Would append enums: ${result.addedEnums.join(', ')}`);
213
+ }
196
214
  if (result.added.length) {
197
215
  console.log(`(--dry-run) Would append: ${result.added.join(', ')}`);
198
216
  }
@@ -210,12 +228,18 @@ async function cmdSchemaApply(args) {
210
228
  for (const { line } of fk.enabled) {
211
229
  console.log(` ${line + 1}: ${fk.schema.split('\n')[line]?.trim() ?? ''}`);
212
230
  }
213
- if (fk.enabled.length > 0 && result.added.length > 0) console.log('');
214
- if (result.added.length > 0) console.log(result.schema.slice(schema.length));
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));
215
234
  return;
216
235
  }
217
236
 
218
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
+ }
219
243
  console.log(`✓ Appended ${result.added.length} model(s): ${result.added.join(', ')}`);
220
244
  if (result.skipped.length) {
221
245
  console.log(`→ Skipped (already present): ${result.skipped.join(', ')}`);
@@ -666,11 +690,32 @@ function codemodTable(name) {
666
690
  const CODEMOD_SKIP = new Set(['node_modules', '.git', '.output', 'coverage']);
667
691
  const isBuildOutput = (name) => name === 'dist' || name.startsWith('dist-');
668
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)$/;
669
708
  /** Walked for the package renames alone; see `rewriteManifest`. */
670
709
  const CODEMOD_MANIFEST = 'package.json';
671
710
 
672
- /** Every source file under `root` a codemod may touch, with its text. */
673
- async function walkSources(root, visit) {
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) {
674
719
  const walk = async (dir) => {
675
720
  for (const entry of await readdir(dir, { withFileTypes: true })) {
676
721
  if (CODEMOD_SKIP.has(entry.name) || isBuildOutput(entry.name)) continue;
@@ -679,13 +724,151 @@ async function walkSources(root, visit) {
679
724
  await walk(full);
680
725
  continue;
681
726
  }
682
- if (!CODEMOD_EXTENSIONS.test(entry.name) && entry.name !== CODEMOD_MANIFEST) continue;
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;
683
732
  await visit(full, await readFile(full, 'utf8'));
684
733
  }
685
734
  };
686
735
  await walk(root);
687
736
  }
688
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
+
689
872
  /** Reads a repeated flag (`--quota=a --quota=b`) off argv. */
690
873
  function repeatedFlag(argv, name) {
691
874
  return argv
@@ -694,25 +877,42 @@ function repeatedFlag(argv, name) {
694
877
  }
695
878
 
696
879
  async function cmdInit(args, argv) {
697
- if (!args['project-key']) {
698
- console.error('✗ --project-key=<key> is required.');
699
- console.error(' It names the catalogue this app administers.');
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.');
700
883
  process.exit(1);
701
884
  }
702
885
 
703
886
  // A usage error, so it exits 1 like every other one here rather than
704
- // through the top-level handler's 99. The rule itself comes from the
705
- // catalogue schema — see src/init/project-key.ts.
887
+ // through the top-level handler's 99. The rule itself is in
888
+ // src/init/catalog-keys.ts.
706
889
  try {
707
- assertValidProjectKey(args['project-key']);
890
+ assertValidAppKey(args['app-key']);
708
891
  } catch (err) {
709
892
  console.error(`✗ ${err.message}`);
710
893
  process.exit(1);
711
894
  }
712
895
 
713
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
+
714
914
  const plan = planInit({
715
- projectKey: args['project-key'],
915
+ appKey: args['app-key'],
716
916
  appName: args['app-name'],
717
917
  apiBase: args['api-base'],
718
918
  quotas: repeatedFlag(argv, 'quota'),
@@ -764,6 +964,8 @@ async function cmdInit(args, argv) {
764
964
  for (const w of writes) console.log(` ${w.path}`);
765
965
  }
766
966
 
967
+ reportSettings(writes, root);
968
+
767
969
  await patchAppModuleFile(root, plan, args);
768
970
 
769
971
  console.log('');
@@ -772,14 +974,62 @@ async function cmdInit(args, argv) {
772
974
  console.log(' — the generated app does NOT compile until you do. An empty');
773
975
  console.log(' array means "deliberately auth-free" to the platform, and');
774
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)".');
775
982
  if (plan.quotaProviders.length > 0) {
776
- console.log(' 2. Check each quota provider counts the right thing');
777
- console.log(' 3. saasicat schema migrate --name=add_saasicat');
983
+ console.log(' 3. Check each quota provider counts the right thing');
984
+ console.log(' 4. saasicat schema migrate --name=add_saasicat');
778
985
  } else {
779
- console.log(' 2. saasicat schema migrate --name=add_saasicat');
986
+ console.log(' 3. saasicat schema migrate --name=add_saasicat');
780
987
  }
781
988
  }
782
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
+
783
1033
  /** Adds the platform to `src/app.module.ts`, or says exactly what to paste. */
784
1034
  async function patchAppModuleFile(root, plan, args) {
785
1035
  const appModulePath = join(root, 'src', 'app.module.ts');
@@ -831,11 +1081,19 @@ async function main() {
831
1081
  if (cmd === 'codemod' && sub === 'v1-rename') {
832
1082
  return cmdCodemodV1Rename(parseArgs(rest));
833
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
+ }
834
1090
  if (cmd === 'codemod' && sub === 'v1') {
835
1091
  // Imports first: the rename table keys its per-entry tokens by the
836
1092
  // specifier they are imported from, which the import rewrite settles.
837
1093
  await cmdCodemodV1Imports(parseArgs(rest));
838
- return cmdCodemodV1Rename(parseArgs(rest));
1094
+ await cmdCodemodV1Rename(parseArgs(rest));
1095
+ await cmdCodemodV1ProjectKey(parseArgs(rest));
1096
+ return cmdCodemodV1MovedSettings(parseArgs(rest));
839
1097
  }
840
1098
  if (cmd === 'init') {
841
1099
  return cmdInit(parseArgs([sub, ...rest].filter(Boolean)), process.argv.slice(3));
@@ -864,18 +1122,23 @@ async function main() {
864
1122
  ' schema migrate --name=<name> apply --all + migrate dev + constraints',
865
1123
  );
866
1124
  console.log('');
867
- console.log(' init --project-key=<key> --quota=<key>:<Model>');
1125
+ console.log(' init --app-key=<key> --quota=<key>:<Model>');
868
1126
  console.log(' scaffold the platform wiring. At least one --quota:');
869
1127
  console.log(' every plan must declare one, or the catalogue does not load.');
870
- console.log(' init --project-key=myapp --quota=notes:Note --quota=seats:Seat');
1128
+ console.log(' init --app-key=myapp --quota=notes:Note --quota=seats:Seat');
871
1129
  console.log('');
872
1130
  console.log(' codemod v1 [--dir=.] [--dry-run]');
873
- console.log(' the whole 1.0 migration: v1-imports, then v1-rename');
1131
+ console.log(' the whole 1.0 migration: imports, names, projectKey, then the');
1132
+ console.log(' settings that moved into config/saas.yaml');
874
1133
  console.log(' codemod v1-imports [--dir=.] [--dry-run]');
875
1134
  console.log(' rewrite @saasicat/ui-vue imports to the 1.0 export map');
876
1135
  console.log(' codemod v1-rename [--dir=.] [--dry-run]');
877
1136
  console.log(' rewrite the names 1.0 changed: SaasPlatform*, Symbol.for keys,');
878
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.');
879
1142
  console.log('');
880
1143
  console.log('Optional --prisma-schema=PATH (default prisma/schema.prisma).');
881
1144
  console.log('Optional --tenant-model=X --user-model=Y for apply/migrate — enables');
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 97f66acb8ca26c7c9470c1991febbb2f68518f1b3cd364af6d3566249550482a
1
+ 56a3011db9728f553b868c72e2b208176101956a1a730e1610f168f699f7a3fa