@saasicat/cli 0.26.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +90 -201
- package/bin/saasicat.js +416 -16
- package/dist/index.cjs +561 -15
- package/dist/index.d.cts +377 -1
- package/dist/index.d.ts +377 -1
- package/dist/index.js +532 -14
- package/package.json +7 -6
- package/templates/init/config/saas.yaml.tpl +30 -0
- package/templates/init/src/auth/password.hasher.ts.tpl +33 -0
- package/templates/init/src/saas/admin-manifest.contribution.ts.tpl +33 -0
- package/templates/init/src/saas/admin.module.ts.tpl +24 -0
- package/templates/init/src/saas/feature-ui-registry.ts.tpl +16 -0
- package/templates/init/src/saas/persistence-without-hasher.ts.tpl +24 -0
- package/templates/init/src/saas/persistence.ts.tpl +22 -0
- package/templates/init/src/saas/quota.provider.ts.tpl +31 -0
package/dist/index.cjs
CHANGED
|
@@ -40,6 +40,7 @@ __export(index_exports, {
|
|
|
40
40
|
AuditTailCommand: () => AuditTailCommand,
|
|
41
41
|
AuditTailFlow: () => AuditTailFlow,
|
|
42
42
|
CLI_CONTEXT_CONFIG_TOKEN: () => CLI_CONTEXT_CONFIG_TOKEN,
|
|
43
|
+
CONSTRAINTS_MARKER: () => CONSTRAINTS_MARKER,
|
|
43
44
|
CliContextModule: () => CliContextModule,
|
|
44
45
|
CliContextService: () => CliContextService,
|
|
45
46
|
CliError: () => CliError,
|
|
@@ -50,6 +51,8 @@ __export(index_exports, {
|
|
|
50
51
|
DiscoverySnapshotDoctorCheck: () => DiscoverySnapshotDoctorCheck,
|
|
51
52
|
DoctorCommands: () => DoctorCommands,
|
|
52
53
|
DoctorFlow: () => DoctorFlow,
|
|
54
|
+
LIMIT_FILTER_IMPORTS: () => LIMIT_FILTER_IMPORTS,
|
|
55
|
+
LIMIT_FILTER_PROVIDER: () => LIMIT_FILTER_PROVIDER,
|
|
53
56
|
MANIFEST_ACCESS_PORT_TOKEN: () => MANIFEST_ACCESS_PORT_TOKEN,
|
|
54
57
|
MANIFEST_CHECKS_TOKEN: () => MANIFEST_CHECKS_TOKEN,
|
|
55
58
|
ManifestCheckCommand: () => ManifestCheckCommand,
|
|
@@ -66,21 +69,46 @@ __export(index_exports, {
|
|
|
66
69
|
UserCommands: () => UserCommands,
|
|
67
70
|
UserPortDoctorCheck: () => UserPortDoctorCheck,
|
|
68
71
|
WhoAmIFlow: () => WhoAmIFlow,
|
|
72
|
+
appendConstraints: () => appendConstraints,
|
|
69
73
|
applyFragmentBlocks: () => applyFragmentBlocks,
|
|
74
|
+
applyTokens: () => applyTokens,
|
|
75
|
+
assertModelsExist: () => assertModelsExist,
|
|
76
|
+
assertValidProjectKey: () => assertValidProjectKey,
|
|
77
|
+
assertValidQuotaKey: () => assertValidQuotaKey,
|
|
70
78
|
blankStringLiterals: () => blankStringLiterals,
|
|
71
79
|
blockBodyLines: () => blockBodyLines,
|
|
72
80
|
breaksContract: () => breaksContract,
|
|
73
81
|
checkSchema: () => checkSchema,
|
|
82
|
+
constraintsFor: () => constraintsFor,
|
|
83
|
+
enableFkPointers: () => enableFkPointers,
|
|
74
84
|
extractBlockNames: () => extractBlockNames,
|
|
75
85
|
extractBlocks: () => extractBlocks,
|
|
76
86
|
extractModelBlocks: () => extractModelBlocks,
|
|
77
87
|
extractModelNames: () => extractModelNames,
|
|
88
|
+
findFkPointers: () => findFkPointers,
|
|
89
|
+
foreignKeyOf: () => foreignKeyOf,
|
|
90
|
+
hasBackRelation: () => hasBackRelation,
|
|
91
|
+
hasConstraints: () => hasConstraints,
|
|
92
|
+
isOneToOne: () => isOneToOne,
|
|
93
|
+
kebabCase: () => kebabCase,
|
|
94
|
+
migrationCreatedBy: () => migrationCreatedBy,
|
|
95
|
+
minimumQuotasPerPlan: () => minimumQuotasPerPlan,
|
|
78
96
|
parseBlockAttributes: () => parseBlockAttributes,
|
|
79
97
|
parseEnumValues: () => parseEnumValues,
|
|
80
98
|
parseFields: () => parseFields,
|
|
99
|
+
parseQuota: () => parseQuota,
|
|
81
100
|
parseSchema: () => parseSchema,
|
|
101
|
+
pascalCase: () => pascalCase,
|
|
102
|
+
patchAppModule: () => patchAppModule,
|
|
103
|
+
patchOptionsFor: () => patchOptionsFor,
|
|
104
|
+
planInit: () => planInit,
|
|
105
|
+
projectKeyPattern: () => projectKeyPattern,
|
|
106
|
+
quotaKeyPattern: () => quotaKeyPattern,
|
|
107
|
+
relationNameOf: () => relationNameOf,
|
|
108
|
+
reportConstraints: () => reportConstraints,
|
|
82
109
|
stripLineComment: () => stripLineComment,
|
|
83
|
-
structuralOnly: () => structuralOnly
|
|
110
|
+
structuralOnly: () => structuralOnly,
|
|
111
|
+
tablesAddressedBy: () => tablesAddressedBy
|
|
84
112
|
});
|
|
85
113
|
module.exports = __toCommonJS(index_exports);
|
|
86
114
|
|
|
@@ -1155,21 +1183,21 @@ function structuralOnly(line) {
|
|
|
1155
1183
|
return stripLineComment(blankStringLiterals(line));
|
|
1156
1184
|
}
|
|
1157
1185
|
__name(structuralOnly, "structuralOnly");
|
|
1158
|
-
function extractBlockNames(
|
|
1186
|
+
function extractBlockNames(schema2, keyword) {
|
|
1159
1187
|
const pattern = declarationPattern(keyword);
|
|
1160
1188
|
const names = [];
|
|
1161
|
-
for (const line of
|
|
1189
|
+
for (const line of schema2.split("\n")) {
|
|
1162
1190
|
const match = structuralOnly(line).match(pattern);
|
|
1163
1191
|
if (match) names.push(match[1]);
|
|
1164
1192
|
}
|
|
1165
1193
|
return names;
|
|
1166
1194
|
}
|
|
1167
1195
|
__name(extractBlockNames, "extractBlockNames");
|
|
1168
|
-
function extractBlocks(
|
|
1196
|
+
function extractBlocks(schema2, keyword) {
|
|
1169
1197
|
const pattern = declarationPattern(keyword);
|
|
1170
1198
|
const blocks = /* @__PURE__ */ new Map();
|
|
1171
1199
|
let current = null;
|
|
1172
|
-
for (const rawLine of
|
|
1200
|
+
for (const rawLine of schema2.split("\n")) {
|
|
1173
1201
|
const stripped = structuralOnly(rawLine);
|
|
1174
1202
|
const openCount = (stripped.match(/\{/g) ?? []).length;
|
|
1175
1203
|
const closeCount = (stripped.match(/\}/g) ?? []).length;
|
|
@@ -1204,16 +1232,16 @@ function blockBodyLines(block) {
|
|
|
1204
1232
|
__name(blockBodyLines, "blockBodyLines");
|
|
1205
1233
|
|
|
1206
1234
|
// src/schema-apply.ts
|
|
1207
|
-
function extractModelNames(
|
|
1208
|
-
return extractBlockNames(
|
|
1235
|
+
function extractModelNames(schema2) {
|
|
1236
|
+
return extractBlockNames(schema2, "model");
|
|
1209
1237
|
}
|
|
1210
1238
|
__name(extractModelNames, "extractModelNames");
|
|
1211
1239
|
function extractModelBlocks(fragment) {
|
|
1212
1240
|
return extractBlocks(fragment, "model");
|
|
1213
1241
|
}
|
|
1214
1242
|
__name(extractModelBlocks, "extractModelBlocks");
|
|
1215
|
-
function applyFragmentBlocks(
|
|
1216
|
-
const existing = new Set(extractModelNames(
|
|
1243
|
+
function applyFragmentBlocks(schema2, fragmentBlocks, options = {}) {
|
|
1244
|
+
const existing = new Set(extractModelNames(schema2));
|
|
1217
1245
|
const added = [];
|
|
1218
1246
|
const skipped = [];
|
|
1219
1247
|
const additions = [];
|
|
@@ -1229,7 +1257,7 @@ function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
|
|
|
1229
1257
|
return {
|
|
1230
1258
|
added,
|
|
1231
1259
|
skipped,
|
|
1232
|
-
schema
|
|
1260
|
+
schema: schema2
|
|
1233
1261
|
};
|
|
1234
1262
|
}
|
|
1235
1263
|
const header = options.fragmentLabel ? `
|
|
@@ -1241,7 +1269,7 @@ function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
|
|
|
1241
1269
|
|
|
1242
1270
|
// Inserted by \`saasicat schema apply\`
|
|
1243
1271
|
`;
|
|
1244
|
-
const trimmedSchema =
|
|
1272
|
+
const trimmedSchema = schema2.endsWith("\n") ? schema2 : schema2 + "\n";
|
|
1245
1273
|
return {
|
|
1246
1274
|
added,
|
|
1247
1275
|
skipped,
|
|
@@ -1299,15 +1327,15 @@ function parseBlockAttributes(name, block) {
|
|
|
1299
1327
|
};
|
|
1300
1328
|
}
|
|
1301
1329
|
__name(parseBlockAttributes, "parseBlockAttributes");
|
|
1302
|
-
function parseSchema(
|
|
1330
|
+
function parseSchema(schema2) {
|
|
1303
1331
|
const models = /* @__PURE__ */ new Map();
|
|
1304
1332
|
const modelAttributes = /* @__PURE__ */ new Map();
|
|
1305
|
-
for (const [name, block] of extractBlocks(
|
|
1333
|
+
for (const [name, block] of extractBlocks(schema2, "model")) {
|
|
1306
1334
|
models.set(name, parseFields(block));
|
|
1307
1335
|
modelAttributes.set(name, parseBlockAttributes(name, block));
|
|
1308
1336
|
}
|
|
1309
1337
|
const enums = /* @__PURE__ */ new Map();
|
|
1310
|
-
for (const [name, block] of extractBlocks(
|
|
1338
|
+
for (const [name, block] of extractBlocks(schema2, "enum")) {
|
|
1311
1339
|
enums.set(name, parseEnumValues(block));
|
|
1312
1340
|
}
|
|
1313
1341
|
return {
|
|
@@ -1433,6 +1461,496 @@ function checkSchema(specSchema, appSchema) {
|
|
|
1433
1461
|
}
|
|
1434
1462
|
__name(checkSchema, "checkSchema");
|
|
1435
1463
|
|
|
1464
|
+
// src/migration-constraints.ts
|
|
1465
|
+
var CONSTRAINTS_MARKER = "-- saasicat:constraints";
|
|
1466
|
+
function migrationCreatedBy(before, after) {
|
|
1467
|
+
const existing = new Set(before);
|
|
1468
|
+
const created = after.filter((name) => /^\d{14}_/.test(name) && !existing.has(name)).sort();
|
|
1469
|
+
return created.length > 0 ? created[created.length - 1] : null;
|
|
1470
|
+
}
|
|
1471
|
+
__name(migrationCreatedBy, "migrationCreatedBy");
|
|
1472
|
+
function hasConstraints(migrationSql) {
|
|
1473
|
+
return migrationSql.includes(CONSTRAINTS_MARKER);
|
|
1474
|
+
}
|
|
1475
|
+
__name(hasConstraints, "hasConstraints");
|
|
1476
|
+
function tablesAddressedBy(statement) {
|
|
1477
|
+
return [
|
|
1478
|
+
...statement.matchAll(/\bON\s+"?(\w+)"?|\bALTER\s+TABLE\s+"?(\w+)"?/gi)
|
|
1479
|
+
].map((m) => m[1] ?? m[2]);
|
|
1480
|
+
}
|
|
1481
|
+
__name(tablesAddressedBy, "tablesAddressedBy");
|
|
1482
|
+
function constraintsFor(constraintsSql, tables) {
|
|
1483
|
+
const known = new Set(tables);
|
|
1484
|
+
return constraintsSql.split(/\n\s*\n/).filter((block) => {
|
|
1485
|
+
const addressed = tablesAddressedBy(block);
|
|
1486
|
+
return addressed.length === 0 || addressed.every((table) => known.has(table));
|
|
1487
|
+
}).join("\n\n").trimEnd();
|
|
1488
|
+
}
|
|
1489
|
+
__name(constraintsFor, "constraintsFor");
|
|
1490
|
+
function appendConstraints(migrationSql, constraintsSql) {
|
|
1491
|
+
if (hasConstraints(migrationSql)) return migrationSql;
|
|
1492
|
+
if (constraintsSql.trim() === "") return migrationSql;
|
|
1493
|
+
const body = migrationSql.endsWith("\n") ? migrationSql : `${migrationSql}
|
|
1494
|
+
`;
|
|
1495
|
+
return `${body}
|
|
1496
|
+
${CONSTRAINTS_MARKER} \u2014 appended by \`saasicat schema migrate\`.
|
|
1497
|
+
-- Source: @saasicat/spec/sql/constraints.postgres.sql. These are part of the
|
|
1498
|
+
-- canonical schema: the adapter contract tests run against a database that has
|
|
1499
|
+
-- them. Edit the spec, not this copy.
|
|
1500
|
+
${constraintsSql.trimEnd()}
|
|
1501
|
+
`;
|
|
1502
|
+
}
|
|
1503
|
+
__name(appendConstraints, "appendConstraints");
|
|
1504
|
+
function reportConstraints(outcome, context) {
|
|
1505
|
+
switch (outcome) {
|
|
1506
|
+
case "appended":
|
|
1507
|
+
return {
|
|
1508
|
+
outcome,
|
|
1509
|
+
mayApply: true,
|
|
1510
|
+
message: ` + appended to ${context.migration}/migration.sql`
|
|
1511
|
+
};
|
|
1512
|
+
case "already-present":
|
|
1513
|
+
return {
|
|
1514
|
+
outcome,
|
|
1515
|
+
mayApply: true,
|
|
1516
|
+
message: ` = ${context.migration} already carries them.`
|
|
1517
|
+
};
|
|
1518
|
+
case "not-applicable":
|
|
1519
|
+
return {
|
|
1520
|
+
outcome,
|
|
1521
|
+
mayApply: true,
|
|
1522
|
+
message: " = none of them apply to the tables in this schema."
|
|
1523
|
+
};
|
|
1524
|
+
case "no-migration":
|
|
1525
|
+
return {
|
|
1526
|
+
outcome,
|
|
1527
|
+
mayApply: true,
|
|
1528
|
+
message: " = Prisma created no migration \u2014 nothing to append to, and nothing new to apply."
|
|
1529
|
+
};
|
|
1530
|
+
case "failed":
|
|
1531
|
+
return {
|
|
1532
|
+
outcome,
|
|
1533
|
+
mayApply: false,
|
|
1534
|
+
message: ` ! Could not append them, so the migration is incomplete. Add ${context.sqlPath} to it by hand, before applying it \u2014 nothing was applied.`
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
__name(reportConstraints, "reportConstraints");
|
|
1539
|
+
|
|
1540
|
+
// src/fk-pointers.ts
|
|
1541
|
+
var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
|
|
1542
|
+
function findFkPointers(schema2) {
|
|
1543
|
+
const found = [];
|
|
1544
|
+
let model = "";
|
|
1545
|
+
schema2.split("\n").forEach((text, line) => {
|
|
1546
|
+
const opening = /^model\s+(\w+)\s*\{/.exec(text);
|
|
1547
|
+
if (opening) model = opening[1];
|
|
1548
|
+
const match = POINTER.exec(text);
|
|
1549
|
+
if (match) found.push({
|
|
1550
|
+
line,
|
|
1551
|
+
target: match[4],
|
|
1552
|
+
model,
|
|
1553
|
+
text
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
return found;
|
|
1557
|
+
}
|
|
1558
|
+
__name(findFkPointers, "findFkPointers");
|
|
1559
|
+
function relationNameOf(relationAttribute) {
|
|
1560
|
+
const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
|
|
1561
|
+
return match ? match[1] : null;
|
|
1562
|
+
}
|
|
1563
|
+
__name(relationNameOf, "relationNameOf");
|
|
1564
|
+
function escapeForRegExp(value) {
|
|
1565
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1566
|
+
}
|
|
1567
|
+
__name(escapeForRegExp, "escapeForRegExp");
|
|
1568
|
+
function modelBody(schema2, model) {
|
|
1569
|
+
const name = escapeForRegExp(model);
|
|
1570
|
+
const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
|
|
1571
|
+
return block ? block[2] : null;
|
|
1572
|
+
}
|
|
1573
|
+
__name(modelBody, "modelBody");
|
|
1574
|
+
function isOneToOne(schema2, model, foreignKey) {
|
|
1575
|
+
const body = modelBody(schema2, model);
|
|
1576
|
+
if (!body) return false;
|
|
1577
|
+
return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
|
|
1578
|
+
}
|
|
1579
|
+
__name(isOneToOne, "isOneToOne");
|
|
1580
|
+
function hasBackRelation(schema2, model, owner, relationName, singular = false) {
|
|
1581
|
+
const body = modelBody(schema2, owner);
|
|
1582
|
+
if (body === null) return false;
|
|
1583
|
+
const name = escapeForRegExp(model);
|
|
1584
|
+
const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
|
|
1585
|
+
const candidates = [
|
|
1586
|
+
...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
|
|
1587
|
+
];
|
|
1588
|
+
return candidates.some((line) => relationNameOf(line[0]) === relationName);
|
|
1589
|
+
}
|
|
1590
|
+
__name(hasBackRelation, "hasBackRelation");
|
|
1591
|
+
function enableFkPointers(schema2, models) {
|
|
1592
|
+
const lines = schema2.split("\n");
|
|
1593
|
+
const enabled = [];
|
|
1594
|
+
const skipped = [];
|
|
1595
|
+
const needsBackRelation = [];
|
|
1596
|
+
for (const pointer of findFkPointers(schema2)) {
|
|
1597
|
+
const model = pointer.target === "Tenant" ? models.tenant : models.user;
|
|
1598
|
+
if (!model) {
|
|
1599
|
+
skipped.push({
|
|
1600
|
+
line: pointer.line,
|
|
1601
|
+
target: pointer.target
|
|
1602
|
+
});
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
const match = POINTER.exec(pointer.text);
|
|
1606
|
+
const relationName = relationNameOf(match[7]);
|
|
1607
|
+
const foreignKey = foreignKeyOf(match[7]);
|
|
1608
|
+
const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
|
|
1609
|
+
if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
|
|
1610
|
+
needsBackRelation.push({
|
|
1611
|
+
line: pointer.line,
|
|
1612
|
+
owner: model,
|
|
1613
|
+
suggestion: backRelationSuggestion(pointer.model, relationName, singular)
|
|
1614
|
+
});
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
const [, indent, field, gap1, , optional, gap2, relation] = match;
|
|
1618
|
+
lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
|
|
1619
|
+
enabled.push({
|
|
1620
|
+
line: pointer.line,
|
|
1621
|
+
model
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
schema: lines.join("\n"),
|
|
1626
|
+
enabled,
|
|
1627
|
+
skipped,
|
|
1628
|
+
needsBackRelation
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
__name(enableFkPointers, "enableFkPointers");
|
|
1632
|
+
var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
|
|
1633
|
+
function foreignKeyOf(relationAttribute) {
|
|
1634
|
+
const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
|
|
1635
|
+
return match ? match[1] : null;
|
|
1636
|
+
}
|
|
1637
|
+
__name(foreignKeyOf, "foreignKeyOf");
|
|
1638
|
+
function backRelationSuggestion(model, relationName, singular) {
|
|
1639
|
+
const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
|
|
1640
|
+
const type = singular ? `${model}?` : `${model}[]`;
|
|
1641
|
+
return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
|
|
1642
|
+
}
|
|
1643
|
+
__name(backRelationSuggestion, "backRelationSuggestion");
|
|
1644
|
+
function assertModelsExist(declaredModels, models) {
|
|
1645
|
+
const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
|
|
1646
|
+
if (missing.length === 0) return;
|
|
1647
|
+
throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
|
|
1648
|
+
}
|
|
1649
|
+
__name(assertModelsExist, "assertModelsExist");
|
|
1650
|
+
|
|
1651
|
+
// src/init/catalog-keys.ts
|
|
1652
|
+
var import_spec = require("@saasicat/spec");
|
|
1653
|
+
var schema = import_spec.planCatalogSchema;
|
|
1654
|
+
function required(value, what) {
|
|
1655
|
+
if (value === void 0) {
|
|
1656
|
+
throw new Error(`plan-catalog.schema.json declares no ${what} \u2014 @saasicat/spec and @saasicat/cli are out of step.`);
|
|
1657
|
+
}
|
|
1658
|
+
return value;
|
|
1659
|
+
}
|
|
1660
|
+
__name(required, "required");
|
|
1661
|
+
function projectKeyPattern() {
|
|
1662
|
+
return new RegExp(required(schema.properties?.projectKey?.pattern, "pattern for projectKey"));
|
|
1663
|
+
}
|
|
1664
|
+
__name(projectKeyPattern, "projectKeyPattern");
|
|
1665
|
+
function quotaKeyPattern() {
|
|
1666
|
+
const patterns = Object.keys(required(schema.$defs?.PlanDef?.properties?.quotas?.patternProperties, "quota key pattern"));
|
|
1667
|
+
if (patterns.length !== 1) {
|
|
1668
|
+
throw new Error(`plan-catalog.schema.json declares ${patterns.length} quota key patterns; this derivation can express one.`);
|
|
1669
|
+
}
|
|
1670
|
+
return new RegExp(patterns[0]);
|
|
1671
|
+
}
|
|
1672
|
+
__name(quotaKeyPattern, "quotaKeyPattern");
|
|
1673
|
+
function minimumQuotasPerPlan() {
|
|
1674
|
+
return required(schema.$defs?.PlanDef?.properties?.quotas?.minProperties, "minProperties");
|
|
1675
|
+
}
|
|
1676
|
+
__name(minimumQuotasPerPlan, "minimumQuotasPerPlan");
|
|
1677
|
+
function assertValidProjectKey(projectKey) {
|
|
1678
|
+
const pattern = projectKeyPattern();
|
|
1679
|
+
if (pattern.test(projectKey)) return;
|
|
1680
|
+
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.`);
|
|
1681
|
+
}
|
|
1682
|
+
__name(assertValidProjectKey, "assertValidProjectKey");
|
|
1683
|
+
function assertValidQuotaKey(quotaKey) {
|
|
1684
|
+
const pattern = quotaKeyPattern();
|
|
1685
|
+
if (pattern.test(quotaKey)) return;
|
|
1686
|
+
throw new Error(`--quota=${quotaKey}:\u2026 is not a valid quota key. It has to match ${pattern.source} \u2014 lower camel case, no separators, so \`activeSeats\` rather than \`active-seats\` or \`active_seats\`. The plan's \`quotas\` object forbids additional properties, so the platform rejects config/saas.yaml at boot otherwise.`);
|
|
1687
|
+
}
|
|
1688
|
+
__name(assertValidQuotaKey, "assertValidQuotaKey");
|
|
1689
|
+
|
|
1690
|
+
// src/init/plan.ts
|
|
1691
|
+
function parseQuota(spec) {
|
|
1692
|
+
const [key, model] = spec.split(":");
|
|
1693
|
+
if (!key) throw new Error(`--quota needs a key: got '${spec}'`);
|
|
1694
|
+
assertValidQuotaKey(key);
|
|
1695
|
+
return {
|
|
1696
|
+
key,
|
|
1697
|
+
model: delegateName(model ?? key)
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
__name(parseQuota, "parseQuota");
|
|
1701
|
+
var delegateName = /* @__PURE__ */ __name((model) => model.charAt(0).toLowerCase() + model.slice(1), "delegateName");
|
|
1702
|
+
function assertEnoughQuotas(quotas) {
|
|
1703
|
+
const minimum = minimumQuotasPerPlan();
|
|
1704
|
+
if (quotas.length >= minimum) return;
|
|
1705
|
+
throw new Error(`init needs at least ${minimum} --quota=<key>:<Model>. Every plan in config/saas.yaml must declare one, and the platform refuses a catalogue without it \u2014 so a project generated without one cannot boot. Name what your app counts, for example --quota=notes:Note; the generated provider is where you say how to count it.`);
|
|
1706
|
+
}
|
|
1707
|
+
__name(assertEnoughQuotas, "assertEnoughQuotas");
|
|
1708
|
+
function pascalCase(value) {
|
|
1709
|
+
return value.split(/[^A-Za-z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
1710
|
+
}
|
|
1711
|
+
__name(pascalCase, "pascalCase");
|
|
1712
|
+
var quotaFileName = /* @__PURE__ */ __name((key) => `${key.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}-quota.provider.ts`, "quotaFileName");
|
|
1713
|
+
function planInit(options) {
|
|
1714
|
+
const projectKey = options.projectKey;
|
|
1715
|
+
if (!projectKey) throw new Error("init needs a --project-key.");
|
|
1716
|
+
assertValidProjectKey(projectKey);
|
|
1717
|
+
const appLabel = options.appName ?? pascalCase(projectKey);
|
|
1718
|
+
const appName = pascalCase(appLabel);
|
|
1719
|
+
const apiBase = options.apiBase ?? "/api/v1/admin";
|
|
1720
|
+
const quotas = (options.quotas ?? []).map(parseQuota);
|
|
1721
|
+
assertEnoughQuotas(quotas);
|
|
1722
|
+
const hasherClass = options.skipHasher ? null : `${appName}PasswordHasher`;
|
|
1723
|
+
const featureKey = `${projectKey.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}_CORE`;
|
|
1724
|
+
const shared = {
|
|
1725
|
+
PROJECT_KEY: projectKey,
|
|
1726
|
+
APP_NAME: appName,
|
|
1727
|
+
APP_LABEL: appLabel,
|
|
1728
|
+
API_BASE: apiBase,
|
|
1729
|
+
FEATURE_KEY: featureKey,
|
|
1730
|
+
REGISTRY_CONST: `${constantCase(projectKey)}_FEATURE_UI_REGISTRY`,
|
|
1731
|
+
MANIFEST_CONST: `${constantCase(projectKey)}_MANIFEST_CONTRIBUTION`,
|
|
1732
|
+
ADMIN_MODULE_CLASS: `${appName}AdminModule`,
|
|
1733
|
+
HASHER_CLASS: hasherClass ?? "",
|
|
1734
|
+
HASHER_FILE: hasherClass ? `${kebabCase(appName)}-password.hasher` : "",
|
|
1735
|
+
STARTER_QUOTAS: renderQuotaBlock(quotas, 25),
|
|
1736
|
+
PRO_QUOTAS: renderQuotaBlock(quotas, 1e3)
|
|
1737
|
+
};
|
|
1738
|
+
const files = [
|
|
1739
|
+
{
|
|
1740
|
+
path: "config/saas.yaml",
|
|
1741
|
+
template: "config/saas.yaml",
|
|
1742
|
+
tokens: {}
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
path: "src/saas/feature-ui-registry.ts",
|
|
1746
|
+
template: "src/saas/feature-ui-registry.ts",
|
|
1747
|
+
tokens: {}
|
|
1748
|
+
},
|
|
1749
|
+
{
|
|
1750
|
+
path: "src/saas/admin-manifest.contribution.ts",
|
|
1751
|
+
template: "src/saas/admin-manifest.contribution.ts",
|
|
1752
|
+
tokens: {}
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
path: `src/saas/${kebabCase(appName)}-admin.module.ts`,
|
|
1756
|
+
template: "src/saas/admin.module.ts",
|
|
1757
|
+
tokens: {}
|
|
1758
|
+
}
|
|
1759
|
+
];
|
|
1760
|
+
files.push({
|
|
1761
|
+
path: "src/saas/persistence.ts",
|
|
1762
|
+
template: hasherClass ? "src/saas/persistence.ts" : "src/saas/persistence-without-hasher.ts",
|
|
1763
|
+
tokens: {}
|
|
1764
|
+
});
|
|
1765
|
+
if (hasherClass) {
|
|
1766
|
+
files.push({
|
|
1767
|
+
path: `src/auth/${kebabCase(appName)}-password.hasher.ts`,
|
|
1768
|
+
template: "src/auth/password.hasher.ts",
|
|
1769
|
+
tokens: {}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
const quotaProviders = quotas.map((quota) => ({
|
|
1773
|
+
className: `${pascalCase(quota.key)}QuotaProvider`,
|
|
1774
|
+
path: `src/saas/${quotaFileName(quota.key)}`
|
|
1775
|
+
}));
|
|
1776
|
+
for (const quota of quotas) {
|
|
1777
|
+
files.push({
|
|
1778
|
+
path: `src/saas/${quotaFileName(quota.key)}`,
|
|
1779
|
+
template: "src/saas/quota.provider.ts",
|
|
1780
|
+
tokens: {
|
|
1781
|
+
QUOTA_KEY: quota.key,
|
|
1782
|
+
QUOTA_LABEL: `${pascalCase(quota.key)} count`,
|
|
1783
|
+
QUOTA_CLASS: `${pascalCase(quota.key)}QuotaProvider`,
|
|
1784
|
+
QUOTA_MODEL: quota.model
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
files,
|
|
1790
|
+
tokens: shared,
|
|
1791
|
+
quotaProviders,
|
|
1792
|
+
hasherClass
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
__name(planInit, "planInit");
|
|
1796
|
+
var constantCase = /* @__PURE__ */ __name((value) => value.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase(), "constantCase");
|
|
1797
|
+
var kebabCase = /* @__PURE__ */ __name((value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").toLowerCase(), "kebabCase");
|
|
1798
|
+
function renderQuotaBlock(quotas, limit) {
|
|
1799
|
+
if (quotas.length === 0) return " {}";
|
|
1800
|
+
return quotas.map((quota) => `
|
|
1801
|
+
${quota.key}: ${limit}`).join("");
|
|
1802
|
+
}
|
|
1803
|
+
__name(renderQuotaBlock, "renderQuotaBlock");
|
|
1804
|
+
function applyTokens(content, tokens) {
|
|
1805
|
+
return content.replace(/__([A-Z_]+)__/g, (full, key) => tokens[key] === void 0 ? full : tokens[key]);
|
|
1806
|
+
}
|
|
1807
|
+
__name(applyTokens, "applyTokens");
|
|
1808
|
+
function patchOptionsFor(plan) {
|
|
1809
|
+
const generates = /* @__PURE__ */ __name((path) => plan.files.some((file) => file.path === path), "generates");
|
|
1810
|
+
return {
|
|
1811
|
+
persistenceImport: generates("src/saas/persistence.ts") ? "./saas/persistence" : null,
|
|
1812
|
+
adminModule: {
|
|
1813
|
+
className: plan.tokens.ADMIN_MODULE_CLASS,
|
|
1814
|
+
importPath: `./saas/${kebabCase(plan.tokens.APP_NAME)}-admin.module`
|
|
1815
|
+
},
|
|
1816
|
+
// Read off the plan, not re-derived: the file it wrote is the file the
|
|
1817
|
+
// import has to name.
|
|
1818
|
+
quotaProviders: plan.quotaProviders.map(({ className, path }) => ({
|
|
1819
|
+
className,
|
|
1820
|
+
importPath: `./${path.replace(/^src\//, "").replace(/\.ts$/, "")}`
|
|
1821
|
+
})),
|
|
1822
|
+
registry: {
|
|
1823
|
+
constName: plan.tokens.REGISTRY_CONST,
|
|
1824
|
+
importPath: "./saas/feature-ui-registry"
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
__name(patchOptionsFor, "patchOptionsFor");
|
|
1829
|
+
|
|
1830
|
+
// src/init/patch-app-module.ts
|
|
1831
|
+
var MARKER = "SaaSiCatModule.forRoot";
|
|
1832
|
+
function patchAppModule(source, options) {
|
|
1833
|
+
const block = renderForRootBlock(options);
|
|
1834
|
+
const manualBlock = `${renderImports(options)}
|
|
1835
|
+
|
|
1836
|
+
${block}`;
|
|
1837
|
+
if (source.includes(MARKER)) {
|
|
1838
|
+
return {
|
|
1839
|
+
source,
|
|
1840
|
+
status: "already-wired",
|
|
1841
|
+
reason: "",
|
|
1842
|
+
manualBlock: ""
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
const importsArray = findImportsArray(source);
|
|
1846
|
+
if (!importsArray) {
|
|
1847
|
+
return {
|
|
1848
|
+
source,
|
|
1849
|
+
status: "declined",
|
|
1850
|
+
reason: "no `@Module({ imports: [ ... ] })` was found in this file, so there is nowhere to add the platform without guessing at the structure",
|
|
1851
|
+
manualBlock
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
const withImports = addImportStatements(source, options);
|
|
1855
|
+
const target = findImportsArray(withImports);
|
|
1856
|
+
const indent = " ".repeat(target.indent + 4);
|
|
1857
|
+
const entries = `${block},
|
|
1858
|
+
${options.adminModule.className},`;
|
|
1859
|
+
const wasEmpty = /^\s*\]/.test(withImports.slice(target.openBracket + 1));
|
|
1860
|
+
const trailing = wasEmpty ? " ".repeat(target.indent) : indent;
|
|
1861
|
+
const inserted = `
|
|
1862
|
+
${indent}${entries.split("\n").join(`
|
|
1863
|
+
${indent}`)}
|
|
1864
|
+
${trailing}`;
|
|
1865
|
+
return {
|
|
1866
|
+
source: withImports.slice(0, target.openBracket + 1) + inserted + withImports.slice(target.openBracket + 1),
|
|
1867
|
+
status: "patched",
|
|
1868
|
+
reason: "",
|
|
1869
|
+
manualBlock: ""
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
__name(patchAppModule, "patchAppModule");
|
|
1873
|
+
function findImportsArray(source) {
|
|
1874
|
+
const decorator = source.indexOf("@Module(");
|
|
1875
|
+
if (decorator === -1) return null;
|
|
1876
|
+
const match = /(^|\n)([ \t]*)imports\s*:\s*\[/.exec(source.slice(decorator));
|
|
1877
|
+
if (!match) return null;
|
|
1878
|
+
const openBracket = decorator + match.index + match[0].length - 1;
|
|
1879
|
+
return {
|
|
1880
|
+
openBracket,
|
|
1881
|
+
indent: match[2].length
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
__name(findImportsArray, "findImportsArray");
|
|
1885
|
+
function renderImports(options) {
|
|
1886
|
+
const lines = [
|
|
1887
|
+
"import { loadPlanCatalogFromFile } from '@saasicat/nest/billing';",
|
|
1888
|
+
"import { SaaSiCatModule, defineSaaSiCat } from '@saasicat/nest/platform';",
|
|
1889
|
+
`import { ${options.registry.constName} } from '${options.registry.importPath}';`,
|
|
1890
|
+
`import { ${options.adminModule.className} } from '${options.adminModule.importPath}';`
|
|
1891
|
+
];
|
|
1892
|
+
if (options.persistenceImport) {
|
|
1893
|
+
lines.push(`import { persistence } from '${options.persistenceImport}';`);
|
|
1894
|
+
}
|
|
1895
|
+
for (const quota of options.quotaProviders) {
|
|
1896
|
+
lines.push(`import { ${quota.className} } from '${quota.importPath}';`);
|
|
1897
|
+
}
|
|
1898
|
+
return lines.join("\n");
|
|
1899
|
+
}
|
|
1900
|
+
__name(renderImports, "renderImports");
|
|
1901
|
+
function addImportStatements(source, options) {
|
|
1902
|
+
const lines = source.split("\n");
|
|
1903
|
+
const block = renderImports(options).split("\n");
|
|
1904
|
+
lines.splice(endOfLastImport(lines) + 1, 0, ...block);
|
|
1905
|
+
return lines.join("\n");
|
|
1906
|
+
}
|
|
1907
|
+
__name(addImportStatements, "addImportStatements");
|
|
1908
|
+
function endOfLastImport(lines) {
|
|
1909
|
+
let end = -1;
|
|
1910
|
+
let open = false;
|
|
1911
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1912
|
+
const line = lines[index];
|
|
1913
|
+
if (!open && !/^\s*import\s/.test(line)) continue;
|
|
1914
|
+
open = true;
|
|
1915
|
+
if (/\bfrom\s+['"][^'"]+['"]/.test(line) || /^\s*import\s+['"][^'"]+['"]/.test(line)) {
|
|
1916
|
+
end = index;
|
|
1917
|
+
open = false;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return end;
|
|
1921
|
+
}
|
|
1922
|
+
__name(endOfLastImport, "endOfLastImport");
|
|
1923
|
+
function renderForRootBlock(options) {
|
|
1924
|
+
const quotaList = options.quotaProviders.map((q) => q.className).join(", ");
|
|
1925
|
+
return [
|
|
1926
|
+
"SaaSiCatModule.forRoot(",
|
|
1927
|
+
" defineSaaSiCat({",
|
|
1928
|
+
" // Plans straight from the YAML. Apps that manage plans in the",
|
|
1929
|
+
" // SuperAdmin UI pass `dbCatalog` instead.",
|
|
1930
|
+
" planCatalog: loadPlanCatalogFromFile({ path: 'config/saas.yaml' }),",
|
|
1931
|
+
" // Your authentication guard. This does NOT compile until you",
|
|
1932
|
+
" // name one, and that is deliberate: an empty array is how the",
|
|
1933
|
+
" // platform is told an endpoint should be auth-free, so a",
|
|
1934
|
+
" // placeholder `[]` here would publish GET /admin/discovery \u2014",
|
|
1935
|
+
" // your whole capability inventory \u2014 and the manifest routes to",
|
|
1936
|
+
" // anyone who asks. Import your guard and put it in.",
|
|
1937
|
+
" controller: { guards: [YourAuthGuard] },",
|
|
1938
|
+
options.persistenceImport ? " persistence," : " // persistence: prismaPersistence({ client: PrismaService }),",
|
|
1939
|
+
" catalog: { featureUiRegistry: " + options.registry.constName + " },",
|
|
1940
|
+
" adminResources: true,",
|
|
1941
|
+
" promoCodes: true,",
|
|
1942
|
+
quotaList ? ` quotaProviders: [${quotaList}],` : " quotaProviders: [],",
|
|
1943
|
+
" }),",
|
|
1944
|
+
")"
|
|
1945
|
+
].join("\n");
|
|
1946
|
+
}
|
|
1947
|
+
__name(renderForRootBlock, "renderForRootBlock");
|
|
1948
|
+
var LIMIT_FILTER_PROVIDER = "{ provide: APP_FILTER, useClass: LimitExceededFilter }";
|
|
1949
|
+
var LIMIT_FILTER_IMPORTS = [
|
|
1950
|
+
"import { APP_FILTER } from '@nestjs/core';",
|
|
1951
|
+
"import { LimitExceededFilter } from '@saasicat/nest/billing';"
|
|
1952
|
+
].join("\n");
|
|
1953
|
+
|
|
1436
1954
|
// src/module.ts
|
|
1437
1955
|
var import_common8 = require("@nestjs/common");
|
|
1438
1956
|
var import_nest5 = require("@saasicat/nest");
|
|
@@ -2495,6 +3013,7 @@ UserCommands = _ts_decorate14([
|
|
|
2495
3013
|
AuditTailCommand,
|
|
2496
3014
|
AuditTailFlow,
|
|
2497
3015
|
CLI_CONTEXT_CONFIG_TOKEN,
|
|
3016
|
+
CONSTRAINTS_MARKER,
|
|
2498
3017
|
CliContextModule,
|
|
2499
3018
|
CliContextService,
|
|
2500
3019
|
CliError,
|
|
@@ -2505,6 +3024,8 @@ UserCommands = _ts_decorate14([
|
|
|
2505
3024
|
DiscoverySnapshotDoctorCheck,
|
|
2506
3025
|
DoctorCommands,
|
|
2507
3026
|
DoctorFlow,
|
|
3027
|
+
LIMIT_FILTER_IMPORTS,
|
|
3028
|
+
LIMIT_FILTER_PROVIDER,
|
|
2508
3029
|
MANIFEST_ACCESS_PORT_TOKEN,
|
|
2509
3030
|
MANIFEST_CHECKS_TOKEN,
|
|
2510
3031
|
ManifestCheckCommand,
|
|
@@ -2521,19 +3042,44 @@ UserCommands = _ts_decorate14([
|
|
|
2521
3042
|
UserCommands,
|
|
2522
3043
|
UserPortDoctorCheck,
|
|
2523
3044
|
WhoAmIFlow,
|
|
3045
|
+
appendConstraints,
|
|
2524
3046
|
applyFragmentBlocks,
|
|
3047
|
+
applyTokens,
|
|
3048
|
+
assertModelsExist,
|
|
3049
|
+
assertValidProjectKey,
|
|
3050
|
+
assertValidQuotaKey,
|
|
2525
3051
|
blankStringLiterals,
|
|
2526
3052
|
blockBodyLines,
|
|
2527
3053
|
breaksContract,
|
|
2528
3054
|
checkSchema,
|
|
3055
|
+
constraintsFor,
|
|
3056
|
+
enableFkPointers,
|
|
2529
3057
|
extractBlockNames,
|
|
2530
3058
|
extractBlocks,
|
|
2531
3059
|
extractModelBlocks,
|
|
2532
3060
|
extractModelNames,
|
|
3061
|
+
findFkPointers,
|
|
3062
|
+
foreignKeyOf,
|
|
3063
|
+
hasBackRelation,
|
|
3064
|
+
hasConstraints,
|
|
3065
|
+
isOneToOne,
|
|
3066
|
+
kebabCase,
|
|
3067
|
+
migrationCreatedBy,
|
|
3068
|
+
minimumQuotasPerPlan,
|
|
2533
3069
|
parseBlockAttributes,
|
|
2534
3070
|
parseEnumValues,
|
|
2535
3071
|
parseFields,
|
|
3072
|
+
parseQuota,
|
|
2536
3073
|
parseSchema,
|
|
3074
|
+
pascalCase,
|
|
3075
|
+
patchAppModule,
|
|
3076
|
+
patchOptionsFor,
|
|
3077
|
+
planInit,
|
|
3078
|
+
projectKeyPattern,
|
|
3079
|
+
quotaKeyPattern,
|
|
3080
|
+
relationNameOf,
|
|
3081
|
+
reportConstraints,
|
|
2537
3082
|
stripLineComment,
|
|
2538
|
-
structuralOnly
|
|
3083
|
+
structuralOnly,
|
|
3084
|
+
tablesAddressedBy
|
|
2539
3085
|
});
|