@saasicat/cli 1.0.0-rc.2 → 1.0.0-rc.20

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/dist/index.js CHANGED
@@ -869,7 +869,7 @@ __name(collectComponentKeys, "collectComponentKeys");
869
869
 
870
870
  // src/default-doctor-checks.ts
871
871
  import { Inject as Inject6, Injectable as Injectable7 } from "@nestjs/common";
872
- import { AdminManifestService, DISCOVERY_SNAPSHOT_TOKEN, PLAN_CATALOG_TOKEN } from "@saasicat/nest";
872
+ import { AdminManifestService, DISCOVERY_SNAPSHOT_TOKEN, IssuerIdentityInspector, PLAN_CATALOG_TOKEN } from "@saasicat/nest";
873
873
  function _ts_decorate7(decorators, target, key, desc) {
874
874
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
875
875
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -909,7 +909,7 @@ var PlanCatalogDoctorCheck = class {
909
909
  severity: "ok",
910
910
  message: `${plans.length} plan(s), ${this.catalog.features?.length ?? 0} feature(s) loaded.`,
911
911
  details: {
912
- projectKey: this.catalog.projectKey,
912
+ app: this.catalog.app.name,
913
913
  planIds: plans.map((p) => p.id)
914
914
  }
915
915
  };
@@ -1021,11 +1021,85 @@ AdminManifestDoctorCheck = _ts_decorate7([
1021
1021
  typeof AdminManifestService === "undefined" ? Object : AdminManifestService
1022
1022
  ])
1023
1023
  ], AdminManifestDoctorCheck);
1024
+ var IssuerIdentityDoctorCheck = class {
1025
+ static {
1026
+ __name(this, "IssuerIdentityDoctorCheck");
1027
+ }
1028
+ issuer;
1029
+ id = "platform.issuer-identity";
1030
+ label = "Issuer identity against the recorded one";
1031
+ constructor(issuer) {
1032
+ this.issuer = issuer;
1033
+ }
1034
+ async run() {
1035
+ const verdict = await this.issuer.inspect();
1036
+ if (verdict.kind === "refused") {
1037
+ return {
1038
+ severity: "error",
1039
+ message: verdict.refusal,
1040
+ // The number and the identifiers, not the rows: `DoctorReport`
1041
+ // is serialised as JSON, and the dates on a row would come out
1042
+ // as timestamps beside the same dates already written as days in
1043
+ // the message above.
1044
+ // One unknown answered once: `null` for both where the
1045
+ // contracts could not be read, rather than a null count beside
1046
+ // an empty list that reads as "none".
1047
+ details: verdict.running ? {
1048
+ runningContracts: verdict.running.total,
1049
+ named: verdict.running.contracts.map((row) => row.id)
1050
+ } : {
1051
+ runningContracts: null,
1052
+ named: null
1053
+ }
1054
+ };
1055
+ }
1056
+ if (verdict.kind === "not-compared") {
1057
+ return {
1058
+ severity: "warning",
1059
+ message: `The issuer in the file is compared with nothing \u2014 ${verdict.why} \u2014 so a start that names another legal entity than the last one is not refused.`
1060
+ };
1061
+ }
1062
+ const change = verdict.change;
1063
+ switch (change.kind) {
1064
+ case "none-named":
1065
+ return {
1066
+ severity: "ok",
1067
+ message: "No issuer is named, and none was recorded."
1068
+ };
1069
+ case "first-naming":
1070
+ return {
1071
+ severity: "ok",
1072
+ message: `'${change.identity.legalName}' is named for the first time; no contract can name another.`
1073
+ };
1074
+ case "corrected":
1075
+ return {
1076
+ severity: "ok",
1077
+ message: `A correction of '${change.recorded.legalName}' is declared: ${change.reason}. This is what the start found; whether it has been recorded is what \`GET /admin/settings\` shows, and \`issuer.correctionOf\` may be dropped once it does.`
1078
+ };
1079
+ case "unchanged": {
1080
+ const running = await this.issuer.runningContractCount();
1081
+ const weighedAgainst = running === null ? "" : ` ${running} contract(s) are running, and their issuer copies do not follow a change.`;
1082
+ return {
1083
+ severity: "ok",
1084
+ message: `'${change.identity.legalName}' is what the installation recorded.` + weighedAgainst + " Changing the legal name or a tax identifier needs `issuer.correctionOf` beside the values it replaces; the address and the contact details do not."
1085
+ };
1086
+ }
1087
+ }
1088
+ }
1089
+ };
1090
+ IssuerIdentityDoctorCheck = _ts_decorate7([
1091
+ Injectable7(),
1092
+ _ts_metadata7("design:type", Function),
1093
+ _ts_metadata7("design:paramtypes", [
1094
+ typeof IssuerIdentityInspector === "undefined" ? Object : IssuerIdentityInspector
1095
+ ])
1096
+ ], IssuerIdentityDoctorCheck);
1024
1097
  var PLATFORM_DOCTOR_CHECK_PROVIDERS = [
1025
1098
  PlanCatalogDoctorCheck,
1026
1099
  DiscoverySnapshotDoctorCheck,
1027
1100
  UserPortDoctorCheck,
1028
- AdminManifestDoctorCheck
1101
+ AdminManifestDoctorCheck,
1102
+ IssuerIdentityDoctorCheck
1029
1103
  ];
1030
1104
 
1031
1105
  // src/prisma-blocks.ts
@@ -1201,6 +1275,117 @@ function applyFragmentBlocks(schema2, fragmentBlocks, options = {}) {
1201
1275
  }
1202
1276
  __name(applyFragmentBlocks, "applyFragmentBlocks");
1203
1277
 
1278
+ // src/fk-pointers.ts
1279
+ var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
1280
+ function findFkPointers(schema2) {
1281
+ const found = [];
1282
+ let model = "";
1283
+ schema2.split("\n").forEach((text, line) => {
1284
+ const opening = /^model\s+(\w+)\s*\{/.exec(text);
1285
+ if (opening) model = opening[1];
1286
+ const match = POINTER.exec(text);
1287
+ if (match) found.push({
1288
+ line,
1289
+ target: match[4],
1290
+ model,
1291
+ text
1292
+ });
1293
+ });
1294
+ return found;
1295
+ }
1296
+ __name(findFkPointers, "findFkPointers");
1297
+ function relationNameOf(relationAttribute) {
1298
+ const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
1299
+ return match ? match[1] : null;
1300
+ }
1301
+ __name(relationNameOf, "relationNameOf");
1302
+ function escapeForRegExp(value) {
1303
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1304
+ }
1305
+ __name(escapeForRegExp, "escapeForRegExp");
1306
+ function modelBody(schema2, model) {
1307
+ const name = escapeForRegExp(model);
1308
+ const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
1309
+ return block ? block[2] : null;
1310
+ }
1311
+ __name(modelBody, "modelBody");
1312
+ function isOneToOne(schema2, model, foreignKey) {
1313
+ const body = modelBody(schema2, model);
1314
+ if (!body) return false;
1315
+ return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
1316
+ }
1317
+ __name(isOneToOne, "isOneToOne");
1318
+ function hasBackRelation(schema2, model, owner, relationName, singular = false) {
1319
+ const body = modelBody(schema2, owner);
1320
+ if (body === null) return false;
1321
+ const name = escapeForRegExp(model);
1322
+ const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
1323
+ const candidates = [
1324
+ ...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
1325
+ ];
1326
+ return candidates.some((line) => relationNameOf(line[0]) === relationName);
1327
+ }
1328
+ __name(hasBackRelation, "hasBackRelation");
1329
+ function enableFkPointers(schema2, models) {
1330
+ const lines = schema2.split("\n");
1331
+ const enabled = [];
1332
+ const skipped = [];
1333
+ const needsBackRelation = [];
1334
+ for (const pointer of findFkPointers(schema2)) {
1335
+ const model = pointer.target === "Tenant" ? models.tenant : models.user;
1336
+ if (!model) {
1337
+ skipped.push({
1338
+ line: pointer.line,
1339
+ target: pointer.target
1340
+ });
1341
+ continue;
1342
+ }
1343
+ const match = POINTER.exec(pointer.text);
1344
+ const relationName = relationNameOf(match[7]);
1345
+ const foreignKey = foreignKeyOf(match[7]);
1346
+ const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
1347
+ if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
1348
+ needsBackRelation.push({
1349
+ line: pointer.line,
1350
+ owner: model,
1351
+ suggestion: backRelationSuggestion(pointer.model, relationName, singular)
1352
+ });
1353
+ continue;
1354
+ }
1355
+ const [, indent, field, gap1, , optional, gap2, relation] = match;
1356
+ lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
1357
+ enabled.push({
1358
+ line: pointer.line,
1359
+ model
1360
+ });
1361
+ }
1362
+ return {
1363
+ schema: lines.join("\n"),
1364
+ enabled,
1365
+ skipped,
1366
+ needsBackRelation
1367
+ };
1368
+ }
1369
+ __name(enableFkPointers, "enableFkPointers");
1370
+ var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
1371
+ function foreignKeyOf(relationAttribute) {
1372
+ const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
1373
+ return match ? match[1] : null;
1374
+ }
1375
+ __name(foreignKeyOf, "foreignKeyOf");
1376
+ function backRelationSuggestion(model, relationName, singular) {
1377
+ const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
1378
+ const type = singular ? `${model}?` : `${model}[]`;
1379
+ return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
1380
+ }
1381
+ __name(backRelationSuggestion, "backRelationSuggestion");
1382
+ function assertModelsExist(declaredModels, models) {
1383
+ const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
1384
+ if (missing.length === 0) return;
1385
+ throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
1386
+ }
1387
+ __name(assertModelsExist, "assertModelsExist");
1388
+
1204
1389
  // src/schema-check.ts
1205
1390
  function renderType(signature) {
1206
1391
  return `${signature.type}${signature.list ? "[]" : ""}${signature.optional ? "?" : ""}`;
@@ -1272,10 +1457,11 @@ function isDocumentedTypeSubstitution(spec, app, appEnums) {
1272
1457
  return spec.type === "String" && appEnums.has(app.type);
1273
1458
  }
1274
1459
  __name(isDocumentedTypeSubstitution, "isDocumentedTypeSubstitution");
1275
- function compareFields(model, specFields, appFields, appEnums, missingFields, fieldMismatches) {
1460
+ function compareFields(model, specFields, appFields, appEnums, notAdopted, missingFields, fieldMismatches) {
1276
1461
  for (const [name, spec] of specFields) {
1277
1462
  const app = appFields.get(name);
1278
1463
  if (!app) {
1464
+ if (notAdopted.has(spec.type)) continue;
1279
1465
  missingFields.push({
1280
1466
  model,
1281
1467
  field: name,
@@ -1333,34 +1519,62 @@ function breaksContract(attribute) {
1333
1519
  return attribute.kind !== "index";
1334
1520
  }
1335
1521
  __name(breaksContract, "breaksContract");
1336
- function checkSchema(specSchema, appSchema) {
1522
+ function modelsKeptPastTheTenant(specSchema) {
1523
+ const pointed = new Set(findFkPointers(specSchema).filter((pointer) => pointer.target === "Tenant").map((pointer) => pointer.model));
1524
+ return [
1525
+ ...parseSchema(specSchema).models
1526
+ ].filter(([model, fields]) => fields.has("tenantId") && !pointed.has(model)).map(([model]) => model);
1527
+ }
1528
+ __name(modelsKeptPastTheTenant, "modelsKeptPastTheTenant");
1529
+ function cascadesFromTenant(model, block) {
1530
+ const found = [];
1531
+ for (const line of block.split("\n")) {
1532
+ const compact = structuralOnly(line).replace(/\s+/g, "");
1533
+ if (compact.includes("@relation(") && compact.includes("fields:[tenantId]") && compact.includes("onDelete:Cascade")) {
1534
+ found.push({
1535
+ model,
1536
+ field: line.trim().split(/\s/)[0]
1537
+ });
1538
+ }
1539
+ }
1540
+ return found;
1541
+ }
1542
+ __name(cascadesFromTenant, "cascadesFromTenant");
1543
+ function checkSchema(specSchema, appSchema, knownModels) {
1337
1544
  const spec = parseSchema(specSchema);
1338
1545
  const app = parseSchema(appSchema);
1339
- const absentModels = [];
1340
1546
  const missingFields = [];
1341
1547
  const fieldMismatches = [];
1342
1548
  const missingBlockAttributes = [];
1549
+ const absentModels = [
1550
+ ...spec.models.keys()
1551
+ ].filter((name) => !app.models.has(name));
1552
+ const absentEnums = [
1553
+ ...spec.enums.keys()
1554
+ ].filter((name) => !app.enums.has(name));
1555
+ const shipped = knownModels ?? new Set(spec.models.keys());
1556
+ const notAdopted = new Set([
1557
+ ...shipped
1558
+ ].filter((name) => !app.models.has(name)));
1343
1559
  for (const [model, specFields] of spec.models) {
1344
1560
  const appFields = app.models.get(model);
1345
- if (!appFields) {
1346
- absentModels.push(model);
1347
- continue;
1348
- }
1349
- compareFields(model, specFields, appFields, app.enums, missingFields, fieldMismatches);
1561
+ if (!appFields) continue;
1562
+ compareFields(model, specFields, appFields, app.enums, notAdopted, missingFields, fieldMismatches);
1350
1563
  const specAttrs = spec.modelAttributes.get(model);
1351
1564
  const appAttrs = app.modelAttributes.get(model);
1352
1565
  if (specAttrs && appAttrs) {
1353
1566
  compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
1354
1567
  }
1355
1568
  }
1356
- const absentEnums = [];
1569
+ const appBlocks = extractBlocks(appSchema, "model");
1570
+ const tenantCascades = modelsKeptPastTheTenant(specSchema).flatMap((model) => {
1571
+ const block = appBlocks.get(model);
1572
+ return block ? cascadesFromTenant(model, block) : [];
1573
+ });
1357
1574
  const missingEnumValues = [];
1358
1575
  for (const [name, specValues] of spec.enums) {
1359
1576
  const appValues = app.enums.get(name);
1360
- if (!appValues) {
1361
- absentEnums.push(name);
1362
- continue;
1363
- }
1577
+ if (!appValues) continue;
1364
1578
  for (const value of specValues) {
1365
1579
  if (!appValues.includes(value)) {
1366
1580
  missingEnumValues.push({
@@ -1377,9 +1591,10 @@ function checkSchema(specSchema, appSchema) {
1377
1591
  missingEnumValues,
1378
1592
  fieldMismatches,
1379
1593
  missingBlockAttributes,
1594
+ tenantCascades,
1380
1595
  checkedModelCount: spec.models.size - absentModels.length,
1381
1596
  checkedEnumCount: spec.enums.size - absentEnums.length,
1382
- ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
1597
+ ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && tenantCascades.length === 0 && !missingBlockAttributes.some(breaksContract)
1383
1598
  };
1384
1599
  }
1385
1600
  __name(checkSchema, "checkSchema");
@@ -1460,117 +1675,6 @@ function reportConstraints(outcome, context) {
1460
1675
  }
1461
1676
  __name(reportConstraints, "reportConstraints");
1462
1677
 
1463
- // src/fk-pointers.ts
1464
- var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
1465
- function findFkPointers(schema2) {
1466
- const found = [];
1467
- let model = "";
1468
- schema2.split("\n").forEach((text, line) => {
1469
- const opening = /^model\s+(\w+)\s*\{/.exec(text);
1470
- if (opening) model = opening[1];
1471
- const match = POINTER.exec(text);
1472
- if (match) found.push({
1473
- line,
1474
- target: match[4],
1475
- model,
1476
- text
1477
- });
1478
- });
1479
- return found;
1480
- }
1481
- __name(findFkPointers, "findFkPointers");
1482
- function relationNameOf(relationAttribute) {
1483
- const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
1484
- return match ? match[1] : null;
1485
- }
1486
- __name(relationNameOf, "relationNameOf");
1487
- function escapeForRegExp(value) {
1488
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1489
- }
1490
- __name(escapeForRegExp, "escapeForRegExp");
1491
- function modelBody(schema2, model) {
1492
- const name = escapeForRegExp(model);
1493
- const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
1494
- return block ? block[2] : null;
1495
- }
1496
- __name(modelBody, "modelBody");
1497
- function isOneToOne(schema2, model, foreignKey) {
1498
- const body = modelBody(schema2, model);
1499
- if (!body) return false;
1500
- return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
1501
- }
1502
- __name(isOneToOne, "isOneToOne");
1503
- function hasBackRelation(schema2, model, owner, relationName, singular = false) {
1504
- const body = modelBody(schema2, owner);
1505
- if (body === null) return false;
1506
- const name = escapeForRegExp(model);
1507
- const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
1508
- const candidates = [
1509
- ...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
1510
- ];
1511
- return candidates.some((line) => relationNameOf(line[0]) === relationName);
1512
- }
1513
- __name(hasBackRelation, "hasBackRelation");
1514
- function enableFkPointers(schema2, models) {
1515
- const lines = schema2.split("\n");
1516
- const enabled = [];
1517
- const skipped = [];
1518
- const needsBackRelation = [];
1519
- for (const pointer of findFkPointers(schema2)) {
1520
- const model = pointer.target === "Tenant" ? models.tenant : models.user;
1521
- if (!model) {
1522
- skipped.push({
1523
- line: pointer.line,
1524
- target: pointer.target
1525
- });
1526
- continue;
1527
- }
1528
- const match = POINTER.exec(pointer.text);
1529
- const relationName = relationNameOf(match[7]);
1530
- const foreignKey = foreignKeyOf(match[7]);
1531
- const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
1532
- if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
1533
- needsBackRelation.push({
1534
- line: pointer.line,
1535
- owner: model,
1536
- suggestion: backRelationSuggestion(pointer.model, relationName, singular)
1537
- });
1538
- continue;
1539
- }
1540
- const [, indent, field, gap1, , optional, gap2, relation] = match;
1541
- lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
1542
- enabled.push({
1543
- line: pointer.line,
1544
- model
1545
- });
1546
- }
1547
- return {
1548
- schema: lines.join("\n"),
1549
- enabled,
1550
- skipped,
1551
- needsBackRelation
1552
- };
1553
- }
1554
- __name(enableFkPointers, "enableFkPointers");
1555
- var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
1556
- function foreignKeyOf(relationAttribute) {
1557
- const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
1558
- return match ? match[1] : null;
1559
- }
1560
- __name(foreignKeyOf, "foreignKeyOf");
1561
- function backRelationSuggestion(model, relationName, singular) {
1562
- const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
1563
- const type = singular ? `${model}?` : `${model}[]`;
1564
- return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
1565
- }
1566
- __name(backRelationSuggestion, "backRelationSuggestion");
1567
- function assertModelsExist(declaredModels, models) {
1568
- const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
1569
- if (missing.length === 0) return;
1570
- throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
1571
- }
1572
- __name(assertModelsExist, "assertModelsExist");
1573
-
1574
1678
  // src/init/catalog-keys.ts
1575
1679
  import { planCatalogSchema } from "@saasicat/spec";
1576
1680
  var schema = planCatalogSchema;
@@ -1581,10 +1685,11 @@ function required(value, what) {
1581
1685
  return value;
1582
1686
  }
1583
1687
  __name(required, "required");
1584
- function projectKeyPattern() {
1585
- return new RegExp(required(schema.properties?.projectKey?.pattern, "pattern for projectKey"));
1688
+ var APP_KEY_PATTERN = /^[a-z][a-z0-9-]{1,30}$/;
1689
+ function appKeyPattern() {
1690
+ return APP_KEY_PATTERN;
1586
1691
  }
1587
- __name(projectKeyPattern, "projectKeyPattern");
1692
+ __name(appKeyPattern, "appKeyPattern");
1588
1693
  function quotaKeyPattern() {
1589
1694
  const patterns = Object.keys(required(schema.$defs?.PlanDef?.properties?.quotas?.patternProperties, "quota key pattern"));
1590
1695
  if (patterns.length !== 1) {
@@ -1597,12 +1702,12 @@ function minimumQuotasPerPlan() {
1597
1702
  return required(schema.$defs?.PlanDef?.properties?.quotas?.minProperties, "minProperties");
1598
1703
  }
1599
1704
  __name(minimumQuotasPerPlan, "minimumQuotasPerPlan");
1600
- function assertValidProjectKey(projectKey) {
1601
- const pattern = projectKeyPattern();
1602
- if (pattern.test(projectKey)) return;
1603
- throw new Error(`--project-key=${projectKey} is not a valid project key. It has to match ${pattern.source} \u2014 lower case, starting with a letter, at least two characters. The platform validates config/saas.yaml against the same pattern at boot, so this would fail after every file was written.`);
1705
+ function assertValidAppKey(appKey) {
1706
+ const pattern = appKeyPattern();
1707
+ if (pattern.test(appKey)) return;
1708
+ throw new Error(`--app-key=${appKey} is not a valid app key. It has to match ${pattern.source} \u2014 lower case, starting with a letter, at least two characters. It becomes an npm package name and a browser storage prefix, so this would fail after every file was written.`);
1604
1709
  }
1605
- __name(assertValidProjectKey, "assertValidProjectKey");
1710
+ __name(assertValidAppKey, "assertValidAppKey");
1606
1711
  function assertValidQuotaKey(quotaKey) {
1607
1712
  const pattern = quotaKeyPattern();
1608
1713
  if (pattern.test(quotaKey)) return;
@@ -1634,24 +1739,24 @@ function pascalCase(value) {
1634
1739
  __name(pascalCase, "pascalCase");
1635
1740
  var quotaFileName = /* @__PURE__ */ __name((key) => `${key.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}-quota.provider.ts`, "quotaFileName");
1636
1741
  function planInit(options) {
1637
- const projectKey = options.projectKey;
1638
- if (!projectKey) throw new Error("init needs a --project-key.");
1639
- assertValidProjectKey(projectKey);
1640
- const appLabel = options.appName ?? pascalCase(projectKey);
1742
+ const appKey = options.appKey;
1743
+ if (!appKey) throw new Error("init needs an --app-key.");
1744
+ assertValidAppKey(appKey);
1745
+ const appLabel = options.appName ?? pascalCase(appKey);
1641
1746
  const appName = pascalCase(appLabel);
1642
1747
  const apiBase = options.apiBase ?? "/api/v1/admin";
1643
1748
  const quotas = (options.quotas ?? []).map(parseQuota);
1644
1749
  assertEnoughQuotas(quotas);
1645
1750
  const hasherClass = options.skipHasher ? null : `${appName}PasswordHasher`;
1646
- const featureKey = `${projectKey.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}_CORE`;
1751
+ const featureKey = `${appKey.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}_CORE`;
1647
1752
  const shared = {
1648
- PROJECT_KEY: projectKey,
1753
+ APP_KEY: appKey,
1649
1754
  APP_NAME: appName,
1650
1755
  APP_LABEL: appLabel,
1651
1756
  API_BASE: apiBase,
1652
1757
  FEATURE_KEY: featureKey,
1653
- REGISTRY_CONST: `${constantCase(projectKey)}_FEATURE_UI_REGISTRY`,
1654
- MANIFEST_CONST: `${constantCase(projectKey)}_MANIFEST_CONTRIBUTION`,
1758
+ REGISTRY_CONST: `${constantCase(appKey)}_FEATURE_UI_REGISTRY`,
1759
+ MANIFEST_CONST: `${constantCase(appKey)}_MANIFEST_CONTRIBUTION`,
1655
1760
  ADMIN_MODULE_CLASS: `${appName}AdminModule`,
1656
1761
  HASHER_CLASS: hasherClass ?? "",
1657
1762
  HASHER_FILE: hasherClass ? `${kebabCase(appName)}-password.hasher` : "",
@@ -1782,6 +1887,31 @@ function readEffectiveModuleResolution(root, ts) {
1782
1887
  }
1783
1888
  __name(readEffectiveModuleResolution, "readEffectiveModuleResolution");
1784
1889
 
1890
+ // src/init/settings-written.ts
1891
+ import { loadPlanCatalogFromString } from "@saasicat/nest/billing";
1892
+ function flatten(prefix, value, into) {
1893
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1894
+ for (const [key, child] of Object.entries(value)) {
1895
+ flatten(`${prefix}.${key}`, child, into);
1896
+ }
1897
+ return;
1898
+ }
1899
+ into.push({
1900
+ key: prefix,
1901
+ value: JSON.stringify(value)
1902
+ });
1903
+ }
1904
+ __name(flatten, "flatten");
1905
+ function settingsWrittenTo(catalogYaml, source = "config/saas.yaml") {
1906
+ const catalog = loadPlanCatalogFromString(catalogYaml, {
1907
+ source
1908
+ });
1909
+ const settings = [];
1910
+ flatten("tenantBilling", catalog.tenantBilling, settings);
1911
+ return settings;
1912
+ }
1913
+ __name(settingsWrittenTo, "settingsWrittenTo");
1914
+
1785
1915
  // src/codemods/v1-imports.ts
1786
1916
  var PUBLIC_PREFIXES = [
1787
1917
  "ui/",
@@ -2045,6 +2175,327 @@ function rewriteManifest(text, table, options) {
2045
2175
  }
2046
2176
  __name(rewriteManifest, "rewriteManifest");
2047
2177
 
2178
+ // src/codemods/v1-project-key.ts
2179
+ function stripQueryParameter(text) {
2180
+ const NEEDLE = "projectKey=";
2181
+ let out = "";
2182
+ let index = 0;
2183
+ const taken = /* @__PURE__ */ new Set();
2184
+ for (; ; ) {
2185
+ const at = text.indexOf(NEEDLE, index);
2186
+ if (at < 0) break;
2187
+ const separator = at === 0 ? "" : text[at - 1];
2188
+ const value = separator === "?" || separator === "&" ? simpleValueEnd(text, at + NEEDLE.length) : null;
2189
+ if (value === null || !servesTheCatalogue(text, at)) {
2190
+ out += text.slice(index, at + NEEDLE.length);
2191
+ index = at + NEEDLE.length;
2192
+ continue;
2193
+ }
2194
+ out += text.slice(index, at - 1);
2195
+ if (separator === "?" && text[value] === "&") {
2196
+ out += "?";
2197
+ index = value + 1;
2198
+ } else {
2199
+ index = value;
2200
+ }
2201
+ taken.add(at);
2202
+ }
2203
+ return {
2204
+ text: out + text.slice(index),
2205
+ taken
2206
+ };
2207
+ }
2208
+ __name(stripQueryParameter, "stripQueryParameter");
2209
+ var HIDES_A_BRACE = /[`'"{}/\\]/;
2210
+ function simpleValueEnd(text, from) {
2211
+ let i = from;
2212
+ while (i < text.length) {
2213
+ const ch = text[i];
2214
+ if (ch === "&" || ch === "#" || ch === "'" || ch === '"' || ch === "`" || ch === "\n" || ch === " ") {
2215
+ return i;
2216
+ }
2217
+ if (ch !== "$" || text[i + 1] !== "{") {
2218
+ if (ch === "{" || ch === "}") return null;
2219
+ i += 1;
2220
+ continue;
2221
+ }
2222
+ const close = text.indexOf("}", i + 2);
2223
+ if (close < 0) return null;
2224
+ if (HIDES_A_BRACE.test(text.slice(i + 2, close))) return null;
2225
+ i = close + 1;
2226
+ }
2227
+ return null;
2228
+ }
2229
+ __name(simpleValueEnd, "simpleValueEnd");
2230
+ function servesTheCatalogue(text, at) {
2231
+ let start = at;
2232
+ while (start > 0) {
2233
+ const ch = text[start - 1];
2234
+ if (ch === "`" || ch === "'" || ch === '"' || ch === "\n") break;
2235
+ start -= 1;
2236
+ }
2237
+ const url = text.slice(start, at);
2238
+ const query = url.indexOf("?");
2239
+ return (query < 0 ? url : url.slice(0, query)).includes("/catalog/");
2240
+ }
2241
+ __name(servesTheCatalogue, "servesTheCatalogue");
2242
+ function isIdentifierChar(ch) {
2243
+ return ch !== void 0 && /[A-Za-z0-9_$]/.test(ch);
2244
+ }
2245
+ __name(isIdentifierChar, "isIdentifierChar");
2246
+ function lineAt(text, at) {
2247
+ let line = 1;
2248
+ for (let i = 0; i < at; i += 1) if (text[i] === "\n") line += 1;
2249
+ return line;
2250
+ }
2251
+ __name(lineAt, "lineAt");
2252
+ function removeProjectKey(text, kind = "source") {
2253
+ if (kind === "yaml") return removeFromYaml(text);
2254
+ const query = stripQueryParameter(text);
2255
+ const reported = /* @__PURE__ */ new Set();
2256
+ for (let at = text.indexOf("projectKey"); at >= 0; at = text.indexOf("projectKey", at + 1)) {
2257
+ if (query.taken.has(at)) continue;
2258
+ if (isIdentifierChar(text[at - 1])) continue;
2259
+ if (isIdentifierChar(text[at + "projectKey".length])) continue;
2260
+ reported.add(lineAt(text, at));
2261
+ }
2262
+ return {
2263
+ text: query.text,
2264
+ rewritten: query.taken.size,
2265
+ undecided: [
2266
+ ...reported
2267
+ ].sort((a, b) => a - b)
2268
+ };
2269
+ }
2270
+ __name(removeProjectKey, "removeProjectKey");
2271
+ function removeFromYaml(text) {
2272
+ const lines = text.split("\n");
2273
+ const kept = [];
2274
+ let rewritten = 0;
2275
+ for (const line of lines) {
2276
+ if (line.startsWith("projectKey:")) {
2277
+ rewritten += 1;
2278
+ continue;
2279
+ }
2280
+ kept.push(line);
2281
+ }
2282
+ return {
2283
+ text: kept.join("\n"),
2284
+ rewritten,
2285
+ undecided: []
2286
+ };
2287
+ }
2288
+ __name(removeFromYaml, "removeFromYaml");
2289
+
2290
+ // src/codemods/v1-moved-settings.ts
2291
+ import { planCatalogSchema as planCatalogSchema2 } from "@saasicat/spec";
2292
+ var SETTINGS_THAT_MOVED = Object.keys(planCatalogSchema2.properties?.tenantBilling?.properties ?? (() => {
2293
+ throw new Error("plan-catalog.schema.json declares no tenantBilling properties \u2014 @saasicat/spec and @saasicat/cli are out of step.");
2294
+ })());
2295
+ var isIdentifierChar2 = /* @__PURE__ */ __name((ch) => ch !== void 0 && /[A-Za-z0-9_$]/.test(ch), "isIdentifierChar");
2296
+ function lineAt2(text, index) {
2297
+ let line = 1;
2298
+ for (let at = 0; at < index; at += 1) {
2299
+ if (text[at] === "\n") line += 1;
2300
+ }
2301
+ return line;
2302
+ }
2303
+ __name(lineAt2, "lineAt");
2304
+ var SCANNED_FOR_MOVED_SETTINGS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/;
2305
+ function findMovedSettings(text) {
2306
+ const occurrences = [];
2307
+ for (const setting of SETTINGS_THAT_MOVED) {
2308
+ for (let at = text.indexOf(setting); at >= 0; at = text.indexOf(setting, at + 1)) {
2309
+ if (isIdentifierChar2(text[at - 1])) continue;
2310
+ if (isIdentifierChar2(text[at + setting.length])) continue;
2311
+ occurrences.push({
2312
+ setting,
2313
+ line: lineAt2(text, at)
2314
+ });
2315
+ }
2316
+ }
2317
+ return {
2318
+ occurrences: occurrences.sort((a, b) => a.line - b.line || a.setting.localeCompare(b.setting))
2319
+ };
2320
+ }
2321
+ __name(findMovedSettings, "findMovedSettings");
2322
+ var WHERE_IT_GOES = {
2323
+ 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.",
2324
+ selfServiceBlockedPlans: 'config/saas.yaml \u2192 tenantBilling.selfServiceBlockedPlans \u2014 both `asTarget` and `asSource` are required, and `[]` is the way to say "nothing is blocked".'
2325
+ };
2326
+
2327
+ // src/codemods/v1-db-catalog.ts
2328
+ import { DB_CATALOG_MEMBERS } from "@saasicat/nest/platform";
2329
+ var SCANNED_FOR_DB_CATALOG = SCANNED_FOR_MOVED_SETTINGS;
2330
+ var PROPERTY = "dbCatalog";
2331
+ var TAKEN = new Set(DB_CATALOG_MEMBERS);
2332
+ var isIdentifierStart = /* @__PURE__ */ __name((ch) => ch !== void 0 && /[A-Za-z_$]/.test(ch), "isIdentifierStart");
2333
+ var isIdentifierChar3 = /* @__PURE__ */ __name((ch) => ch !== void 0 && /[A-Za-z0-9_$]/.test(ch), "isIdentifierChar");
2334
+ var isBlank = /* @__PURE__ */ __name((ch) => ch === " " || ch === " " || ch === "\n" || ch === "\r", "isBlank");
2335
+ function lineAt3(text, index) {
2336
+ let line = 1;
2337
+ for (let at = 0; at < index; at += 1) {
2338
+ if (text[at] === "\n") line += 1;
2339
+ }
2340
+ return line;
2341
+ }
2342
+ __name(lineAt3, "lineAt");
2343
+ function skipBlanks(text, from) {
2344
+ let at = from;
2345
+ while (isBlank(text[at])) at += 1;
2346
+ return at;
2347
+ }
2348
+ __name(skipBlanks, "skipBlanks");
2349
+ function closingQuote(text, open) {
2350
+ const quote = text[open];
2351
+ for (let at = open + 1; at < text.length; at += 1) {
2352
+ if (text[at] === "\\") {
2353
+ at += 1;
2354
+ continue;
2355
+ }
2356
+ if (text[at] === quote) return at;
2357
+ }
2358
+ return text.length;
2359
+ }
2360
+ __name(closingQuote, "closingQuote");
2361
+ function skipCommentOrString(text, at) {
2362
+ const ch = text[at];
2363
+ if (ch === "/" && text[at + 1] === "/") {
2364
+ const end = text.indexOf("\n", at);
2365
+ return end === -1 ? text.length : end;
2366
+ }
2367
+ if (ch === "/" && text[at + 1] === "*") {
2368
+ const end = text.indexOf("*/", at + 2);
2369
+ return end === -1 ? text.length : end + 2;
2370
+ }
2371
+ if (ch === "'" || ch === '"' || ch === "`") return closingQuote(text, at) + 1;
2372
+ return at;
2373
+ }
2374
+ __name(skipCommentOrString, "skipCommentOrString");
2375
+ function objectLiteralAt(text, open) {
2376
+ const members = [];
2377
+ let depth = 0;
2378
+ let expectingKey = true;
2379
+ let at = open + 1;
2380
+ while (at < text.length) {
2381
+ const ch = text[at];
2382
+ if (ch === "'" || ch === '"' || ch === "`") {
2383
+ const end = closingQuote(text, at);
2384
+ if (depth === 0 && expectingKey && text[skipBlanks(text, end + 1)] === ":") {
2385
+ members.push(text.slice(at + 1, end));
2386
+ }
2387
+ at = end + 1;
2388
+ continue;
2389
+ }
2390
+ const skipped = skipCommentOrString(text, at);
2391
+ if (skipped > at) {
2392
+ at = skipped;
2393
+ continue;
2394
+ }
2395
+ if (ch === "{" || ch === "[" || ch === "(") {
2396
+ depth += 1;
2397
+ at += 1;
2398
+ continue;
2399
+ }
2400
+ if (ch === "}" || ch === "]" || ch === ")") {
2401
+ if (depth === 0) return {
2402
+ members,
2403
+ end: at + 1
2404
+ };
2405
+ depth -= 1;
2406
+ at += 1;
2407
+ continue;
2408
+ }
2409
+ if (depth === 0 && ch === ":") expectingKey = false;
2410
+ if (depth === 0 && ch === ",") expectingKey = true;
2411
+ if (depth === 0 && expectingKey && ch === "." && text.startsWith("...", at)) {
2412
+ let end = at + 3;
2413
+ while (isIdentifierChar3(text[end])) end += 1;
2414
+ members.push(text.slice(at, end));
2415
+ at = end;
2416
+ continue;
2417
+ }
2418
+ if (depth === 0 && expectingKey && isIdentifierStart(ch)) {
2419
+ let end = at;
2420
+ while (isIdentifierChar3(text[end])) end += 1;
2421
+ const next = text[skipBlanks(text, end)];
2422
+ if (next === ":" || next === "," || next === "}") members.push(text.slice(at, end));
2423
+ at = end;
2424
+ continue;
2425
+ }
2426
+ at += 1;
2427
+ }
2428
+ return {
2429
+ members,
2430
+ end: text.length
2431
+ };
2432
+ }
2433
+ __name(objectLiteralAt, "objectLiteralAt");
2434
+ function findDbCatalogBlocks(text) {
2435
+ const occurrences = [];
2436
+ let at = 0;
2437
+ while (at < text.length) {
2438
+ const skipped = skipCommentOrString(text, at);
2439
+ if (skipped > at) {
2440
+ at = skipped;
2441
+ continue;
2442
+ }
2443
+ if (!text.startsWith(PROPERTY, at) || isIdentifierChar3(text[at - 1])) {
2444
+ at += 1;
2445
+ continue;
2446
+ }
2447
+ const afterName = at + PROPERTY.length;
2448
+ if (isIdentifierChar3(text[afterName])) {
2449
+ at = afterName;
2450
+ continue;
2451
+ }
2452
+ const colon = skipBlanks(text, afterName);
2453
+ if (text[colon] !== ":") {
2454
+ at = afterName;
2455
+ continue;
2456
+ }
2457
+ const value = skipBlanks(text, colon + 1);
2458
+ const line = lineAt3(text, at);
2459
+ if (text[value] !== "{") {
2460
+ occurrences.push({
2461
+ line,
2462
+ shape: "reference",
2463
+ leftovers: []
2464
+ });
2465
+ at = value;
2466
+ continue;
2467
+ }
2468
+ const { members, end } = objectLiteralAt(text, value);
2469
+ const leftovers = members.filter((member) => !TAKEN.has(member));
2470
+ if (!members.includes("path")) {
2471
+ occurrences.push({
2472
+ line,
2473
+ shape: "values",
2474
+ leftovers
2475
+ });
2476
+ } else if (leftovers.length > 0) {
2477
+ occurrences.push({
2478
+ line,
2479
+ shape: "mixed",
2480
+ leftovers
2481
+ });
2482
+ }
2483
+ at = end;
2484
+ }
2485
+ return {
2486
+ occurrences
2487
+ };
2488
+ }
2489
+ __name(findDbCatalogBlocks, "findDbCatalogBlocks");
2490
+ function describeDbCatalogOccurrence(occurrence) {
2491
+ const { shape, leftovers } = occurrence;
2492
+ if (shape === "reference") return "carries something this cannot see into";
2493
+ if (shape === "mixed") return `names the path but still carries ${leftovers.join(", ")}`;
2494
+ return leftovers.length > 0 ? `carries the values: ${leftovers.join(", ")}` : "names no path";
2495
+ }
2496
+ __name(describeDbCatalogOccurrence, "describeDbCatalogOccurrence");
2497
+ var WHERE_DB_CATALOG_GOES = "dbCatalog: { path: 'config/saas.yaml' } \u2014 the file the values were forwarded from. Delete the values; the platform reads app, currency, vatRate, tenantBilling, marketing and notifications from the file it names, and the plans from the read sink as before.";
2498
+
2048
2499
  // src/init/patch-app-module.ts
2049
2500
  var MARKER = "SaaSiCatModule.forRoot";
2050
2501
  function patchAppModule(source, options) {
@@ -2144,7 +2595,7 @@ function renderForRootBlock(options) {
2144
2595
  "SaaSiCatModule.forRoot(",
2145
2596
  " defineSaaSiCat({",
2146
2597
  " // Plans straight from the YAML. Apps that manage plans in the",
2147
- " // SuperAdmin UI pass `dbCatalog` instead.",
2598
+ " // SuperAdmin UI pass `dbCatalog: { path: 'config/saas.yaml' }` instead.",
2148
2599
  " planCatalog: loadPlanCatalogFromFile({ path: 'config/saas.yaml' }),",
2149
2600
  " // Your authentication guard. This does NOT compile until you",
2150
2601
  " // name one, and that is deliberate: an empty array is how the",
@@ -3248,6 +3699,7 @@ export {
3248
3699
  DiscoverySnapshotDoctorCheck,
3249
3700
  DoctorCommands,
3250
3701
  DoctorFlow,
3702
+ IssuerIdentityDoctorCheck,
3251
3703
  LIMIT_FILTER_IMPORTS,
3252
3704
  LIMIT_FILTER_PROVIDER,
3253
3705
  MANIFEST_ACCESS_PORT_TOKEN,
@@ -3261,17 +3713,23 @@ export {
3261
3713
  MfaSetupFlow,
3262
3714
  PLATFORM_DOCTOR_CHECK_PROVIDERS,
3263
3715
  PlanCatalogDoctorCheck,
3716
+ SCANNED_FOR_DB_CATALOG,
3717
+ SCANNED_FOR_MOVED_SETTINGS,
3718
+ SETTINGS_THAT_MOVED,
3264
3719
  UI_VUE_SPECIFIER,
3265
3720
  USER_MANAGEMENT_PORT_TOKEN,
3266
3721
  USER_PORT_TOKEN,
3267
3722
  UserCommands,
3268
3723
  UserPortDoctorCheck,
3724
+ WHERE_DB_CATALOG_GOES,
3725
+ WHERE_IT_GOES,
3269
3726
  WhoAmIFlow,
3727
+ appKeyPattern,
3270
3728
  appendConstraints,
3271
3729
  applyFragmentBlocks,
3272
3730
  applyTokens,
3273
3731
  assertModelsExist,
3274
- assertValidProjectKey,
3732
+ assertValidAppKey,
3275
3733
  assertValidQuotaKey,
3276
3734
  blankStringLiterals,
3277
3735
  blockBodyLines,
@@ -3279,6 +3737,7 @@ export {
3279
3737
  buildImportMap,
3280
3738
  checkSchema,
3281
3739
  constraintsFor,
3740
+ describeDbCatalogOccurrence,
3282
3741
  enableFkPointers,
3283
3742
  extractBlockNames,
3284
3743
  extractBlocks,
@@ -3287,7 +3746,9 @@ export {
3287
3746
  extractFragmentBlocks,
3288
3747
  extractModelBlocks,
3289
3748
  extractModelNames,
3749
+ findDbCatalogBlocks,
3290
3750
  findFkPointers,
3751
+ findMovedSettings,
3291
3752
  foreignKeyOf,
3292
3753
  hasBackRelation,
3293
3754
  hasConstraints,
@@ -3297,6 +3758,7 @@ export {
3297
3758
  kebabCase,
3298
3759
  migrationCreatedBy,
3299
3760
  minimumQuotasPerPlan,
3761
+ modelsKeptPastTheTenant,
3300
3762
  namedImports,
3301
3763
  parseBlockAttributes,
3302
3764
  parseEnumValues,
@@ -3307,15 +3769,16 @@ export {
3307
3769
  patchAppModule,
3308
3770
  patchOptionsFor,
3309
3771
  planInit,
3310
- projectKeyPattern,
3311
3772
  quotaKeyPattern,
3312
3773
  readEffectiveModuleResolution,
3313
3774
  relationNameOf,
3775
+ removeProjectKey,
3314
3776
  reportConstraints,
3315
3777
  rewriteImports,
3316
3778
  rewriteManifest,
3317
3779
  rewriteNames,
3318
3780
  rewriteSubpath,
3781
+ settingsWrittenTo,
3319
3782
  stripLineComment,
3320
3783
  structuralOnly,
3321
3784
  tablesAddressedBy