@saasicat/cli 1.0.0-rc.8 → 1.0.0-rc.9

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
@@ -46,6 +46,14 @@ import { PrismaUserPortAdapter } from './adapters/prisma-user-port';
46
46
  app: { name: 'MyApp' },
47
47
  currency: 'EUR',
48
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
+ },
49
57
  // The catalogue is read from the database, not from a file — the
50
58
  // CLI's `plan-catalog import` puts it there.
51
59
  sink: {
package/bin/saasicat.js CHANGED
@@ -21,7 +21,8 @@
21
21
  // codemod v1-imports [--dir=X] [--dry-run]
22
22
  // codemod v1-rename [--dir=X] [--dry-run]
23
23
  // codemod v1-project-key [--dir=X] [--dry-run]
24
- // codemod v1 [--dir=X] [--dry-run] — all three, in that order
24
+ // codemod v1-moved-settings [--dir=X]
25
+ // codemod v1 [--dir=X] [--dry-run] — all four, in that order
25
26
  // [--skip-hasher] [--dry-run] [--dir=.]
26
27
  // Writes the platform wiring — config, persistence, manifest
27
28
  // contribution, admin module, one provider per quota — and adds
@@ -61,6 +62,10 @@ import {
61
62
  patchAppModule,
62
63
  patchOptionsFor,
63
64
  planInit,
65
+ settingsWrittenTo,
66
+ findMovedSettings,
67
+ SCANNED_FOR_MOVED_SETTINGS,
68
+ WHERE_IT_GOES,
64
69
  } from '../dist/index.js';
65
70
 
66
71
  const require_ = createRequire(import.meta.url);
@@ -736,6 +741,51 @@ async function walkSources(root, visit, extra = null) {
736
741
  * TypeScript, so removing one would sometimes delete a member of the consumer's
737
742
  * own type. The migration guide's table says what each shape becomes.
738
743
  */
744
+ /**
745
+ * Names the module options that moved into `config/saas.yaml`.
746
+ *
747
+ * Read-only on purpose — see `codemods/v1-moved-settings.ts` for why removing
748
+ * them would lose the decision instead of moving it. `--dry-run` is accepted
749
+ * and does nothing, so `codemod v1 --dry-run` behaves the same throughout.
750
+ */
751
+ async function cmdCodemodV1MovedSettings(args) {
752
+ const root = resolve(args.dir ?? '.');
753
+ const found = [];
754
+
755
+ await walkSources(root, async (full, source) => {
756
+ // Code only. The walk includes Markdown, and a documentation file that
757
+ // mentions a setting is not a file that passes one — see
758
+ // `SCANNED_FOR_MOVED_SETTINGS`.
759
+ if (!SCANNED_FOR_MOVED_SETTINGS.test(full)) return;
760
+ for (const { setting, line } of findMovedSettings(source).occurrences) {
761
+ found.push({ where: `${relative(root, full)}:${line}`, setting });
762
+ }
763
+ });
764
+
765
+ if (found.length === 0) {
766
+ console.log('No module option that moved into config/saas.yaml is still passed.');
767
+ return;
768
+ }
769
+
770
+ console.log(`${found.length} occurrence(s) of a setting that moved:`);
771
+ const width = Math.max(...found.map((f) => f.where.length));
772
+ for (const { where, setting } of found) {
773
+ console.log(` ${where.padEnd(width)} ${setting}`);
774
+ }
775
+ console.log('');
776
+ console.log(' Not removed, and that is the point: the value is a term somebody agreed,');
777
+ console.log(' and deleting it here without writing it into the file would leave the');
778
+ console.log(' application running on whatever the file happens to say. Move each one:');
779
+ console.log('');
780
+ for (const setting of new Set(found.map((f) => f.setting))) {
781
+ console.log(` ${setting}`);
782
+ console.log(` ${WHERE_IT_GOES[setting]}`);
783
+ }
784
+ console.log('');
785
+ console.log(' TenantBillingModule.forRoot() refuses to boot while either is still');
786
+ console.log(' passed, so this cannot be half-done quietly.');
787
+ }
788
+
739
789
  async function cmdCodemodV1ProjectKey(args) {
740
790
  const root = resolve(args.dir ?? '.');
741
791
  const dryRun = args['dry-run'] === true;
@@ -874,6 +924,8 @@ async function cmdInit(args, argv) {
874
924
  for (const w of writes) console.log(` ${w.path}`);
875
925
  }
876
926
 
927
+ reportSettings(writes, root);
928
+
877
929
  await patchAppModuleFile(root, plan, args);
878
930
 
879
931
  console.log('');
@@ -895,6 +947,33 @@ async function cmdInit(args, argv) {
895
947
  }
896
948
  }
897
949
 
950
+ /**
951
+ * Says which settings the catalogue now carries, with their values and its path.
952
+ *
953
+ * These are required fields without defaults, and there is no second place they
954
+ * can come from. Somebody who has just run `init` should not have to find that
955
+ * out from a boot failure six weeks later — so the command names the file it
956
+ * put them in while they are still looking at the output.
957
+ *
958
+ * Derived in `init/settings-written.ts`, where it can be tested: this file is
959
+ * not.
960
+ */
961
+ function reportSettings(writes, root) {
962
+ const catalog = writes.find((w) => w.path === 'config/saas.yaml');
963
+ if (!catalog) return;
964
+ const settings = settingsWrittenTo(catalog.content, catalog.path);
965
+ if (settings.length === 0) return;
966
+
967
+ const width = Math.max(...settings.map((s) => s.key.length));
968
+ console.log('');
969
+ console.log(`Settings written to ${join(root, catalog.path)}:`);
970
+ for (const { key, value } of settings) {
971
+ console.log(` ${key.padEnd(width)} ${value}`);
972
+ }
973
+ console.log(' This file is where they live. Editing it is how they change, and the');
974
+ console.log(' change lands on the next restart — the platform reads it at boot.');
975
+ }
976
+
898
977
  /**
899
978
  * The TypeScript that will compile the project: the consumer's own, resolved
900
979
  * from the project root, so the tsconfig is read the way the build reads it.
@@ -965,12 +1044,16 @@ async function main() {
965
1044
  if (cmd === 'codemod' && sub === 'v1-project-key') {
966
1045
  return cmdCodemodV1ProjectKey(parseArgs(rest));
967
1046
  }
1047
+ if (cmd === 'codemod' && sub === 'v1-moved-settings') {
1048
+ return cmdCodemodV1MovedSettings(parseArgs(rest));
1049
+ }
968
1050
  if (cmd === 'codemod' && sub === 'v1') {
969
1051
  // Imports first: the rename table keys its per-entry tokens by the
970
1052
  // specifier they are imported from, which the import rewrite settles.
971
1053
  await cmdCodemodV1Imports(parseArgs(rest));
972
1054
  await cmdCodemodV1Rename(parseArgs(rest));
973
- return cmdCodemodV1ProjectKey(parseArgs(rest));
1055
+ await cmdCodemodV1ProjectKey(parseArgs(rest));
1056
+ return cmdCodemodV1MovedSettings(parseArgs(rest));
974
1057
  }
975
1058
  if (cmd === 'init') {
976
1059
  return cmdInit(parseArgs([sub, ...rest].filter(Boolean)), process.argv.slice(3));
@@ -1005,12 +1088,17 @@ async function main() {
1005
1088
  console.log(' init --app-key=myapp --quota=notes:Note --quota=seats:Seat');
1006
1089
  console.log('');
1007
1090
  console.log(' codemod v1 [--dir=.] [--dry-run]');
1008
- console.log(' the whole 1.0 migration: imports, names, then projectKey');
1091
+ console.log(' the whole 1.0 migration: imports, names, projectKey, then the');
1092
+ console.log(' settings that moved into config/saas.yaml');
1009
1093
  console.log(' codemod v1-imports [--dir=.] [--dry-run]');
1010
1094
  console.log(' rewrite @saasicat/ui-vue imports to the 1.0 export map');
1011
1095
  console.log(' codemod v1-rename [--dir=.] [--dry-run]');
1012
1096
  console.log(' rewrite the names 1.0 changed: SaasPlatform*, Symbol.for keys,');
1013
1097
  console.log(' FEATURE_UI_REGISTRY_TOKEN, @saasicat/ui-vue/testing-e2e/*');
1098
+ console.log(' codemod v1-moved-settings [--dir=.]');
1099
+ console.log(' name the module options that moved into config/saas.yaml.');
1100
+ console.log(' Reports only — the value is a commercial decision, and this');
1101
+ console.log(' would delete it without writing it anywhere.');
1014
1102
  console.log('');
1015
1103
  console.log('Optional --prisma-schema=PATH (default prisma/schema.prisma).');
1016
1104
  console.log('Optional --tenant-model=X --user-model=Y for apply/migrate — enables');
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 65348852469f50f5ff334a3437d33539439b1195a123430e913603385df96d9e
1
+ d41946fb8c4579edeeaf5f0199b97a2a41d75f2fcf92a77674e97622ff72f631
package/dist/index.cjs CHANGED
@@ -64,11 +64,14 @@ __export(index_exports, {
64
64
  MfaSetupFlow: () => MfaSetupFlow,
65
65
  PLATFORM_DOCTOR_CHECK_PROVIDERS: () => PLATFORM_DOCTOR_CHECK_PROVIDERS,
66
66
  PlanCatalogDoctorCheck: () => PlanCatalogDoctorCheck,
67
+ SCANNED_FOR_MOVED_SETTINGS: () => SCANNED_FOR_MOVED_SETTINGS,
68
+ SETTINGS_THAT_MOVED: () => SETTINGS_THAT_MOVED,
67
69
  UI_VUE_SPECIFIER: () => UI_VUE_SPECIFIER,
68
70
  USER_MANAGEMENT_PORT_TOKEN: () => USER_MANAGEMENT_PORT_TOKEN,
69
71
  USER_PORT_TOKEN: () => USER_PORT_TOKEN,
70
72
  UserCommands: () => UserCommands,
71
73
  UserPortDoctorCheck: () => UserPortDoctorCheck,
74
+ WHERE_IT_GOES: () => WHERE_IT_GOES,
72
75
  WhoAmIFlow: () => WhoAmIFlow,
73
76
  appKeyPattern: () => appKeyPattern,
74
77
  appendConstraints: () => appendConstraints,
@@ -92,6 +95,7 @@ __export(index_exports, {
92
95
  extractModelBlocks: () => extractModelBlocks,
93
96
  extractModelNames: () => extractModelNames,
94
97
  findFkPointers: () => findFkPointers,
98
+ findMovedSettings: () => findMovedSettings,
95
99
  foreignKeyOf: () => foreignKeyOf,
96
100
  hasBackRelation: () => hasBackRelation,
97
101
  hasConstraints: () => hasConstraints,
@@ -120,6 +124,7 @@ __export(index_exports, {
120
124
  rewriteManifest: () => rewriteManifest,
121
125
  rewriteNames: () => rewriteNames,
122
126
  rewriteSubpath: () => rewriteSubpath,
127
+ settingsWrittenTo: () => settingsWrittenTo,
123
128
  stripLineComment: () => stripLineComment,
124
129
  structuralOnly: () => structuralOnly,
125
130
  tablesAddressedBy: () => tablesAddressedBy
@@ -1908,6 +1913,31 @@ function readEffectiveModuleResolution(root, ts) {
1908
1913
  }
1909
1914
  __name(readEffectiveModuleResolution, "readEffectiveModuleResolution");
1910
1915
 
1916
+ // src/init/settings-written.ts
1917
+ var import_billing = require("@saasicat/nest/billing");
1918
+ function flatten(prefix, value, into) {
1919
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1920
+ for (const [key, child] of Object.entries(value)) {
1921
+ flatten(`${prefix}.${key}`, child, into);
1922
+ }
1923
+ return;
1924
+ }
1925
+ into.push({
1926
+ key: prefix,
1927
+ value: JSON.stringify(value)
1928
+ });
1929
+ }
1930
+ __name(flatten, "flatten");
1931
+ function settingsWrittenTo(catalogYaml, source = "config/saas.yaml") {
1932
+ const catalog = (0, import_billing.loadPlanCatalogFromString)(catalogYaml, {
1933
+ source
1934
+ });
1935
+ const settings = [];
1936
+ flatten("tenantBilling", catalog.tenantBilling, settings);
1937
+ return settings;
1938
+ }
1939
+ __name(settingsWrittenTo, "settingsWrittenTo");
1940
+
1911
1941
  // src/codemods/v1-imports.ts
1912
1942
  var PUBLIC_PREFIXES = [
1913
1943
  "ui/",
@@ -2283,6 +2313,43 @@ function removeFromYaml(text) {
2283
2313
  }
2284
2314
  __name(removeFromYaml, "removeFromYaml");
2285
2315
 
2316
+ // src/codemods/v1-moved-settings.ts
2317
+ var import_spec2 = require("@saasicat/spec");
2318
+ var SETTINGS_THAT_MOVED = Object.keys(import_spec2.planCatalogSchema.properties?.tenantBilling?.properties ?? (() => {
2319
+ throw new Error("plan-catalog.schema.json declares no tenantBilling properties \u2014 @saasicat/spec and @saasicat/cli are out of step.");
2320
+ })());
2321
+ var isIdentifierChar2 = /* @__PURE__ */ __name((ch) => ch !== void 0 && /[A-Za-z0-9_$]/.test(ch), "isIdentifierChar");
2322
+ function lineAt2(text, index) {
2323
+ let line = 1;
2324
+ for (let at = 0; at < index; at += 1) {
2325
+ if (text[at] === "\n") line += 1;
2326
+ }
2327
+ return line;
2328
+ }
2329
+ __name(lineAt2, "lineAt");
2330
+ var SCANNED_FOR_MOVED_SETTINGS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/;
2331
+ function findMovedSettings(text) {
2332
+ const occurrences = [];
2333
+ for (const setting of SETTINGS_THAT_MOVED) {
2334
+ for (let at = text.indexOf(setting); at >= 0; at = text.indexOf(setting, at + 1)) {
2335
+ if (isIdentifierChar2(text[at - 1])) continue;
2336
+ if (isIdentifierChar2(text[at + setting.length])) continue;
2337
+ occurrences.push({
2338
+ setting,
2339
+ line: lineAt2(text, at)
2340
+ });
2341
+ }
2342
+ }
2343
+ return {
2344
+ occurrences: occurrences.sort((a, b) => a.line - b.line || a.setting.localeCompare(b.setting))
2345
+ };
2346
+ }
2347
+ __name(findMovedSettings, "findMovedSettings");
2348
+ var WHERE_IT_GOES = {
2349
+ cancellationNoticeDays: "config/saas.yaml \u2192 tenantBilling.cancellationNoticeDays \u2014 both `monthly` and `yearly` are required, so write the number you are running today rather than leaving one out.",
2350
+ selfServiceBlockedPlans: 'config/saas.yaml \u2192 tenantBilling.selfServiceBlockedPlans \u2014 both `asTarget` and `asSource` are required, and `[]` is the way to say "nothing is blocked".'
2351
+ };
2352
+
2286
2353
  // src/init/patch-app-module.ts
2287
2354
  var MARKER = "SaaSiCatModule.forRoot";
2288
2355
  function patchAppModule(source, options) {
@@ -3500,11 +3567,14 @@ UserCommands = _ts_decorate14([
3500
3567
  MfaSetupFlow,
3501
3568
  PLATFORM_DOCTOR_CHECK_PROVIDERS,
3502
3569
  PlanCatalogDoctorCheck,
3570
+ SCANNED_FOR_MOVED_SETTINGS,
3571
+ SETTINGS_THAT_MOVED,
3503
3572
  UI_VUE_SPECIFIER,
3504
3573
  USER_MANAGEMENT_PORT_TOKEN,
3505
3574
  USER_PORT_TOKEN,
3506
3575
  UserCommands,
3507
3576
  UserPortDoctorCheck,
3577
+ WHERE_IT_GOES,
3508
3578
  WhoAmIFlow,
3509
3579
  appKeyPattern,
3510
3580
  appendConstraints,
@@ -3528,6 +3598,7 @@ UserCommands = _ts_decorate14([
3528
3598
  extractModelBlocks,
3529
3599
  extractModelNames,
3530
3600
  findFkPointers,
3601
+ findMovedSettings,
3531
3602
  foreignKeyOf,
3532
3603
  hasBackRelation,
3533
3604
  hasConstraints,
@@ -3556,6 +3627,7 @@ UserCommands = _ts_decorate14([
3556
3627
  rewriteManifest,
3557
3628
  rewriteNames,
3558
3629
  rewriteSubpath,
3630
+ settingsWrittenTo,
3559
3631
  stripLineComment,
3560
3632
  structuralOnly,
3561
3633
  tablesAddressedBy
package/dist/index.d.cts CHANGED
@@ -869,6 +869,21 @@ declare function assertValidAppKey(appKey: string): void;
869
869
  /** The same, for a quota key. `additionalProperties: false` makes it a hard rule. */
870
870
  declare function assertValidQuotaKey(quotaKey: string): void;
871
871
 
872
+ interface WrittenSetting {
873
+ /** Dotted path as it reads in the file, e.g. `tenantBilling.cancellationNoticeDays.monthly`. */
874
+ key: string;
875
+ /** The value as JSON — `0`, `[]`, `"EUR"` — so an empty list is visible as one. */
876
+ value: string;
877
+ }
878
+ /**
879
+ * The settings a generated `config/saas.yaml` carries.
880
+ *
881
+ * Loaded with the platform's own loader, not a YAML parse: a document `init`
882
+ * writes that the platform would refuse is a bug worth failing the generation
883
+ * for, rather than one the first boot reports after every file exists.
884
+ */
885
+ declare function settingsWrittenTo(catalogYaml: string, source?: string): WrittenSetting[];
886
+
872
887
  /** One entry of the move table: where a file was, and where it went. */
873
888
  interface MoveTable {
874
889
  readonly moves: Readonly<Record<string, string>>;
@@ -1000,6 +1015,65 @@ interface ProjectKeyResult {
1000
1015
  */
1001
1016
  declare function removeProjectKey(text: string, kind?: 'source' | 'yaml'): ProjectKeyResult;
1002
1017
 
1018
+ /**
1019
+ * The settings that belong in `config/saas.yaml#tenantBilling`, read off the
1020
+ * schema that defines them.
1021
+ *
1022
+ * Not a list here, and not a copy of the one in `@saasicat/nest`: both derive
1023
+ * from the same schema, so the day a third setting moves into that block, this
1024
+ * codemod names it and the module refuses it without either being edited.
1025
+ */
1026
+ declare const SETTINGS_THAT_MOVED: readonly string[];
1027
+ type MovedSetting = string;
1028
+ interface MovedSettingOccurrence {
1029
+ /** Which setting it is, so the report can say where it goes. */
1030
+ readonly setting: MovedSetting;
1031
+ /** 1-based line number. */
1032
+ readonly line: number;
1033
+ }
1034
+ interface MovedSettingsResult {
1035
+ readonly occurrences: readonly MovedSettingOccurrence[];
1036
+ }
1037
+ /**
1038
+ * Files a moved setting can actually be passed in.
1039
+ *
1040
+ * The codemod walk includes Markdown, and both consumers keep large
1041
+ * documentation folders — an upgrade note that mentions `cancellationNoticeDays`
1042
+ * would land in the report beside the line somebody has to change, and a report
1043
+ * that mixes the two is one nobody reads twice. Prose cannot pass a module
1044
+ * option, so prose is not scanned.
1045
+ */
1046
+ declare const SCANNED_FOR_MOVED_SETTINGS: RegExp;
1047
+ /**
1048
+ * Every occurrence of a moved setting in one source file.
1049
+ *
1050
+ * Matched on word boundaries alone: a longer name that contains it
1051
+ * (`cancellationNoticeDaysV2`) is not reported, and everything else is —
1052
+ * including a mention in a comment or a string.
1053
+ *
1054
+ * That last part is deliberate, and it is the opposite trade from the one this
1055
+ * comment used to claim. Requiring a colon would read as "only a property",
1056
+ * and it would then miss `{ cancellationNoticeDays }` and
1057
+ * `const { cancellationNoticeDays } = options` — two ordinary ways to pass the
1058
+ * same option. Telling a comment from code needs the grammar, which this does
1059
+ * not have. So it over-reports inside code, where the cost is a glance, rather
1060
+ * than under-reporting, where the cost is somebody not learning that their
1061
+ * value is about to stop being read.
1062
+ *
1063
+ * A property access (`config.cancellationNoticeDays`) is reported too: reading
1064
+ * the value back from module options is the same migration, one step further
1065
+ * along.
1066
+ */
1067
+ declare function findMovedSettings(text: string): MovedSettingsResult;
1068
+ /**
1069
+ * Where a setting goes, for the report.
1070
+ *
1071
+ * One sentence per setting rather than one for both: they end up in the same
1072
+ * block and mean different things, and "move these two to the file" is the
1073
+ * instruction people follow halfway.
1074
+ */
1075
+ declare const WHERE_IT_GOES: Record<string, string>;
1076
+
1003
1077
  interface PatchAppModuleOptions {
1004
1078
  /** Import specifier for the persistence bundle, or null when not generated. */
1005
1079
  persistenceImport: string | null;
@@ -1243,4 +1317,4 @@ declare class UserCommands extends CommandRunner {
1243
1317
  parsePassword(val: string): string;
1244
1318
  }
1245
1319
 
1246
- export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, type SchemaCheckReport, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WhoAmIFlow, type WhoAmIResult, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findFkPointers, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, stripLineComment, structuralOnly, tablesAddressedBy };
1320
+ export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, type MovedSetting, type MovedSettingOccurrence, type MovedSettingsResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, SCANNED_FOR_MOVED_SETTINGS, SETTINGS_THAT_MOVED, type SchemaCheckReport, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WHERE_IT_GOES, WhoAmIFlow, type WhoAmIResult, type WrittenSetting, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findFkPointers, findMovedSettings, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, settingsWrittenTo, stripLineComment, structuralOnly, tablesAddressedBy };
package/dist/index.d.ts CHANGED
@@ -869,6 +869,21 @@ declare function assertValidAppKey(appKey: string): void;
869
869
  /** The same, for a quota key. `additionalProperties: false` makes it a hard rule. */
870
870
  declare function assertValidQuotaKey(quotaKey: string): void;
871
871
 
872
+ interface WrittenSetting {
873
+ /** Dotted path as it reads in the file, e.g. `tenantBilling.cancellationNoticeDays.monthly`. */
874
+ key: string;
875
+ /** The value as JSON — `0`, `[]`, `"EUR"` — so an empty list is visible as one. */
876
+ value: string;
877
+ }
878
+ /**
879
+ * The settings a generated `config/saas.yaml` carries.
880
+ *
881
+ * Loaded with the platform's own loader, not a YAML parse: a document `init`
882
+ * writes that the platform would refuse is a bug worth failing the generation
883
+ * for, rather than one the first boot reports after every file exists.
884
+ */
885
+ declare function settingsWrittenTo(catalogYaml: string, source?: string): WrittenSetting[];
886
+
872
887
  /** One entry of the move table: where a file was, and where it went. */
873
888
  interface MoveTable {
874
889
  readonly moves: Readonly<Record<string, string>>;
@@ -1000,6 +1015,65 @@ interface ProjectKeyResult {
1000
1015
  */
1001
1016
  declare function removeProjectKey(text: string, kind?: 'source' | 'yaml'): ProjectKeyResult;
1002
1017
 
1018
+ /**
1019
+ * The settings that belong in `config/saas.yaml#tenantBilling`, read off the
1020
+ * schema that defines them.
1021
+ *
1022
+ * Not a list here, and not a copy of the one in `@saasicat/nest`: both derive
1023
+ * from the same schema, so the day a third setting moves into that block, this
1024
+ * codemod names it and the module refuses it without either being edited.
1025
+ */
1026
+ declare const SETTINGS_THAT_MOVED: readonly string[];
1027
+ type MovedSetting = string;
1028
+ interface MovedSettingOccurrence {
1029
+ /** Which setting it is, so the report can say where it goes. */
1030
+ readonly setting: MovedSetting;
1031
+ /** 1-based line number. */
1032
+ readonly line: number;
1033
+ }
1034
+ interface MovedSettingsResult {
1035
+ readonly occurrences: readonly MovedSettingOccurrence[];
1036
+ }
1037
+ /**
1038
+ * Files a moved setting can actually be passed in.
1039
+ *
1040
+ * The codemod walk includes Markdown, and both consumers keep large
1041
+ * documentation folders — an upgrade note that mentions `cancellationNoticeDays`
1042
+ * would land in the report beside the line somebody has to change, and a report
1043
+ * that mixes the two is one nobody reads twice. Prose cannot pass a module
1044
+ * option, so prose is not scanned.
1045
+ */
1046
+ declare const SCANNED_FOR_MOVED_SETTINGS: RegExp;
1047
+ /**
1048
+ * Every occurrence of a moved setting in one source file.
1049
+ *
1050
+ * Matched on word boundaries alone: a longer name that contains it
1051
+ * (`cancellationNoticeDaysV2`) is not reported, and everything else is —
1052
+ * including a mention in a comment or a string.
1053
+ *
1054
+ * That last part is deliberate, and it is the opposite trade from the one this
1055
+ * comment used to claim. Requiring a colon would read as "only a property",
1056
+ * and it would then miss `{ cancellationNoticeDays }` and
1057
+ * `const { cancellationNoticeDays } = options` — two ordinary ways to pass the
1058
+ * same option. Telling a comment from code needs the grammar, which this does
1059
+ * not have. So it over-reports inside code, where the cost is a glance, rather
1060
+ * than under-reporting, where the cost is somebody not learning that their
1061
+ * value is about to stop being read.
1062
+ *
1063
+ * A property access (`config.cancellationNoticeDays`) is reported too: reading
1064
+ * the value back from module options is the same migration, one step further
1065
+ * along.
1066
+ */
1067
+ declare function findMovedSettings(text: string): MovedSettingsResult;
1068
+ /**
1069
+ * Where a setting goes, for the report.
1070
+ *
1071
+ * One sentence per setting rather than one for both: they end up in the same
1072
+ * block and mean different things, and "move these two to the file" is the
1073
+ * instruction people follow halfway.
1074
+ */
1075
+ declare const WHERE_IT_GOES: Record<string, string>;
1076
+
1003
1077
  interface PatchAppModuleOptions {
1004
1078
  /** Import specifier for the persistence bundle, or null when not generated. */
1005
1079
  persistenceImport: string | null;
@@ -1243,4 +1317,4 @@ declare class UserCommands extends CommandRunner {
1243
1317
  parsePassword(val: string): string;
1244
1318
  }
1245
1319
 
1246
- export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, type SchemaCheckReport, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WhoAmIFlow, type WhoAmIResult, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findFkPointers, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, stripLineComment, structuralOnly, tablesAddressedBy };
1320
+ export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, type MovedSetting, type MovedSettingOccurrence, type MovedSettingsResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, SCANNED_FOR_MOVED_SETTINGS, SETTINGS_THAT_MOVED, type SchemaCheckReport, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WHERE_IT_GOES, WhoAmIFlow, type WhoAmIResult, type WrittenSetting, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findFkPointers, findMovedSettings, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, settingsWrittenTo, stripLineComment, structuralOnly, tablesAddressedBy };
package/dist/index.js CHANGED
@@ -1783,6 +1783,31 @@ function readEffectiveModuleResolution(root, ts) {
1783
1783
  }
1784
1784
  __name(readEffectiveModuleResolution, "readEffectiveModuleResolution");
1785
1785
 
1786
+ // src/init/settings-written.ts
1787
+ import { loadPlanCatalogFromString } from "@saasicat/nest/billing";
1788
+ function flatten(prefix, value, into) {
1789
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1790
+ for (const [key, child] of Object.entries(value)) {
1791
+ flatten(`${prefix}.${key}`, child, into);
1792
+ }
1793
+ return;
1794
+ }
1795
+ into.push({
1796
+ key: prefix,
1797
+ value: JSON.stringify(value)
1798
+ });
1799
+ }
1800
+ __name(flatten, "flatten");
1801
+ function settingsWrittenTo(catalogYaml, source = "config/saas.yaml") {
1802
+ const catalog = loadPlanCatalogFromString(catalogYaml, {
1803
+ source
1804
+ });
1805
+ const settings = [];
1806
+ flatten("tenantBilling", catalog.tenantBilling, settings);
1807
+ return settings;
1808
+ }
1809
+ __name(settingsWrittenTo, "settingsWrittenTo");
1810
+
1786
1811
  // src/codemods/v1-imports.ts
1787
1812
  var PUBLIC_PREFIXES = [
1788
1813
  "ui/",
@@ -2158,6 +2183,43 @@ function removeFromYaml(text) {
2158
2183
  }
2159
2184
  __name(removeFromYaml, "removeFromYaml");
2160
2185
 
2186
+ // src/codemods/v1-moved-settings.ts
2187
+ import { planCatalogSchema as planCatalogSchema2 } from "@saasicat/spec";
2188
+ var SETTINGS_THAT_MOVED = Object.keys(planCatalogSchema2.properties?.tenantBilling?.properties ?? (() => {
2189
+ throw new Error("plan-catalog.schema.json declares no tenantBilling properties \u2014 @saasicat/spec and @saasicat/cli are out of step.");
2190
+ })());
2191
+ var isIdentifierChar2 = /* @__PURE__ */ __name((ch) => ch !== void 0 && /[A-Za-z0-9_$]/.test(ch), "isIdentifierChar");
2192
+ function lineAt2(text, index) {
2193
+ let line = 1;
2194
+ for (let at = 0; at < index; at += 1) {
2195
+ if (text[at] === "\n") line += 1;
2196
+ }
2197
+ return line;
2198
+ }
2199
+ __name(lineAt2, "lineAt");
2200
+ var SCANNED_FOR_MOVED_SETTINGS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/;
2201
+ function findMovedSettings(text) {
2202
+ const occurrences = [];
2203
+ for (const setting of SETTINGS_THAT_MOVED) {
2204
+ for (let at = text.indexOf(setting); at >= 0; at = text.indexOf(setting, at + 1)) {
2205
+ if (isIdentifierChar2(text[at - 1])) continue;
2206
+ if (isIdentifierChar2(text[at + setting.length])) continue;
2207
+ occurrences.push({
2208
+ setting,
2209
+ line: lineAt2(text, at)
2210
+ });
2211
+ }
2212
+ }
2213
+ return {
2214
+ occurrences: occurrences.sort((a, b) => a.line - b.line || a.setting.localeCompare(b.setting))
2215
+ };
2216
+ }
2217
+ __name(findMovedSettings, "findMovedSettings");
2218
+ var WHERE_IT_GOES = {
2219
+ cancellationNoticeDays: "config/saas.yaml \u2192 tenantBilling.cancellationNoticeDays \u2014 both `monthly` and `yearly` are required, so write the number you are running today rather than leaving one out.",
2220
+ selfServiceBlockedPlans: 'config/saas.yaml \u2192 tenantBilling.selfServiceBlockedPlans \u2014 both `asTarget` and `asSource` are required, and `[]` is the way to say "nothing is blocked".'
2221
+ };
2222
+
2161
2223
  // src/init/patch-app-module.ts
2162
2224
  var MARKER = "SaaSiCatModule.forRoot";
2163
2225
  function patchAppModule(source, options) {
@@ -3374,11 +3436,14 @@ export {
3374
3436
  MfaSetupFlow,
3375
3437
  PLATFORM_DOCTOR_CHECK_PROVIDERS,
3376
3438
  PlanCatalogDoctorCheck,
3439
+ SCANNED_FOR_MOVED_SETTINGS,
3440
+ SETTINGS_THAT_MOVED,
3377
3441
  UI_VUE_SPECIFIER,
3378
3442
  USER_MANAGEMENT_PORT_TOKEN,
3379
3443
  USER_PORT_TOKEN,
3380
3444
  UserCommands,
3381
3445
  UserPortDoctorCheck,
3446
+ WHERE_IT_GOES,
3382
3447
  WhoAmIFlow,
3383
3448
  appKeyPattern,
3384
3449
  appendConstraints,
@@ -3402,6 +3467,7 @@ export {
3402
3467
  extractModelBlocks,
3403
3468
  extractModelNames,
3404
3469
  findFkPointers,
3470
+ findMovedSettings,
3405
3471
  foreignKeyOf,
3406
3472
  hasBackRelation,
3407
3473
  hasConstraints,
@@ -3430,6 +3496,7 @@ export {
3430
3496
  rewriteManifest,
3431
3497
  rewriteNames,
3432
3498
  rewriteSubpath,
3499
+ settingsWrittenTo,
3433
3500
  stripLineComment,
3434
3501
  structuralOnly,
3435
3502
  tablesAddressedBy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/cli",
3
- "version": "1.0.0-rc.8",
3
+ "version": "1.0.0-rc.9",
4
4
  "description": "CLI helpers for SaaS platform consumers. Provides CliContextService (identity, MFA, production confirm, audit tag).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "qrcode-terminal": "^0.12.0",
33
- "@saasicat/nest": "^1.0.0-rc.8",
34
- "@saasicat/spec": "^1.0.0-rc.8",
35
- "@saasicat/core": "^1.0.0-rc.8"
33
+ "@saasicat/nest": "^1.0.0-rc.9",
34
+ "@saasicat/spec": "^1.0.0-rc.9",
35
+ "@saasicat/core": "^1.0.0-rc.9"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@nestjs/common": "^11.0.0",
@@ -7,6 +7,30 @@ app:
7
7
  currency: EUR
8
8
  vatRate: 19.0
9
9
 
10
+ # What the tenant self-service routes may do, and on what terms. These live
11
+ # here and nowhere else — an operator reading this file has to be reading the
12
+ # values that are running, with no "unless somebody passed it in code"
13
+ # attached. The file is read at boot, so an edit lands on the next restart.
14
+ tenantBilling:
15
+ # How many days before the term ends a cancellation is still in time.
16
+ # Zero means there is no door to be shut out of: a cancellation on the last
17
+ # day still lands at the term end. Raise it and the cut is hard — a
18
+ # declaration made after the window lands one full period later, which on a
19
+ # yearly cycle is a year.
20
+ #
21
+ # Two rhythms, two numbers, and both are required. A month and a year
22
+ # cannot share a notice period, and a rhythm left out would read as zero:
23
+ # a commercial decision nobody made.
24
+ cancellationNoticeDays:
25
+ monthly: 0
26
+ yearly: 0
27
+ # Plans a tenant may not reach or leave on its own — typically a plan that
28
+ # only a special contract activates. Empty is a statement, not an omission:
29
+ # it says self-service reaches every plan.
30
+ selfServiceBlockedPlans:
31
+ asTarget: []
32
+ asSource: []
33
+
10
34
  marketing:
11
35
  availableLocales: [en]
12
36