@farthershore/product 0.0.0 → 0.2.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/README.md +153 -0
- package/dist/bin.js +457 -75
- package/dist/codegen.js +23 -0
- package/dist/index.js +418 -70
- package/dist/types/business.d.ts +35 -10
- package/dist/types/index.d.ts +2 -1
- package/dist/types/ir-types.d.ts +25 -0
- package/dist/types/resource-graph.d.ts +36 -0
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
|
|
3
3
|
|
|
4
4
|
// src/bin.ts
|
|
5
|
-
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { access, writeFile } from "node:fs/promises";
|
|
6
|
+
import { constants } from "node:fs";
|
|
6
7
|
import { resolve } from "node:path";
|
|
7
8
|
import { pathToFileURL } from "node:url";
|
|
8
9
|
import { tsImport } from "tsx/esm/api";
|
|
@@ -27,6 +28,97 @@ var ManifestBuilderError = class extends Error {
|
|
|
27
28
|
}
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// src/resource-graph.ts
|
|
32
|
+
var ManifestResourceGraph = class {
|
|
33
|
+
nodes = /* @__PURE__ */ new Map();
|
|
34
|
+
declarationOrder = 0;
|
|
35
|
+
register(kind, key, value, dependsOn = []) {
|
|
36
|
+
const id = nodeId(kind, key);
|
|
37
|
+
if (this.nodes.has(id)) {
|
|
38
|
+
throw new Error(`duplicate resource node ${id}`);
|
|
39
|
+
}
|
|
40
|
+
const node = {
|
|
41
|
+
urn: resourceUrn(kind, key),
|
|
42
|
+
kind,
|
|
43
|
+
key,
|
|
44
|
+
value,
|
|
45
|
+
dependsOn: [...new Set(dependsOn)],
|
|
46
|
+
declarationOrder: this.declarationOrder
|
|
47
|
+
};
|
|
48
|
+
this.declarationOrder += 1;
|
|
49
|
+
this.nodes.set(id, node);
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
upsert(kind, key, value, dependsOn = []) {
|
|
53
|
+
const id = nodeId(kind, key);
|
|
54
|
+
const existing = this.nodes.get(id);
|
|
55
|
+
if (!existing) {
|
|
56
|
+
return this.register(kind, key, value, dependsOn);
|
|
57
|
+
}
|
|
58
|
+
const node = {
|
|
59
|
+
urn: existing.urn,
|
|
60
|
+
kind,
|
|
61
|
+
key,
|
|
62
|
+
value,
|
|
63
|
+
dependsOn: [...new Set(dependsOn)],
|
|
64
|
+
declarationOrder: existing.declarationOrder
|
|
65
|
+
};
|
|
66
|
+
this.nodes.set(id, node);
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
clearKind(kind) {
|
|
70
|
+
for (const [id, node] of this.nodes.entries()) {
|
|
71
|
+
if (node.kind === kind) this.nodes.delete(id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
has(kind, key) {
|
|
75
|
+
return this.nodes.has(nodeId(kind, key));
|
|
76
|
+
}
|
|
77
|
+
get(kind, key) {
|
|
78
|
+
return this.nodes.get(nodeId(kind, key)) ?? null;
|
|
79
|
+
}
|
|
80
|
+
values(kind) {
|
|
81
|
+
return [...this.nodes.values()].filter((node) => node.kind === kind).map((node) => node.value);
|
|
82
|
+
}
|
|
83
|
+
sortedValues(kind, key) {
|
|
84
|
+
return sortBy(this.values(kind), key);
|
|
85
|
+
}
|
|
86
|
+
snapshot() {
|
|
87
|
+
return {
|
|
88
|
+
nodes: sortBy([...this.nodes.values()], (node) => node.urn).map(
|
|
89
|
+
({ value: _value, ...node }) => node
|
|
90
|
+
)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
missingDependencies() {
|
|
94
|
+
const known = new Set([...this.nodes.values()].map((node) => node.urn));
|
|
95
|
+
return [...this.nodes.values()].flatMap(
|
|
96
|
+
(node) => node.dependsOn.filter((dependency) => !known.has(dependency)).map((dependency) => ({
|
|
97
|
+
from: node.urn,
|
|
98
|
+
missing: dependency
|
|
99
|
+
}))
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
function resourceUrn(kind, key) {
|
|
104
|
+
return `urn:farthershore:product:${kind}:${encodeURIComponent(
|
|
105
|
+
key
|
|
106
|
+
)}`;
|
|
107
|
+
}
|
|
108
|
+
function resourceDependency(kind, key) {
|
|
109
|
+
return resourceUrn(kind, key);
|
|
110
|
+
}
|
|
111
|
+
function nodeId(kind, key) {
|
|
112
|
+
return `${kind}:${key}`;
|
|
113
|
+
}
|
|
114
|
+
function sortBy(items, key) {
|
|
115
|
+
return [...items].sort((a, b) => {
|
|
116
|
+
const ka = key(a);
|
|
117
|
+
const kb = key(b);
|
|
118
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
30
122
|
// ../contracts/dist/plans/limits-schema.js
|
|
31
123
|
import { z } from "zod";
|
|
32
124
|
var limitDimensionSchema = z.string().min(1);
|
|
@@ -1251,6 +1343,77 @@ var featureCatalogEntrySchema = z13.object({
|
|
|
1251
1343
|
plans: z13.array(z13.string().min(1)).min(1).max(20)
|
|
1252
1344
|
});
|
|
1253
1345
|
var featureCatalogSchema = z13.record(z13.string().min(1).max(100).regex(/^[a-z0-9_.:-]+$/), featureCatalogEntrySchema);
|
|
1346
|
+
var productCleanupPolicyModeSchema = z13.enum([
|
|
1347
|
+
"report",
|
|
1348
|
+
"pull_request"
|
|
1349
|
+
]);
|
|
1350
|
+
var productChangeApprovalRiskSchema = z13.enum([
|
|
1351
|
+
"safe",
|
|
1352
|
+
"non_blocking",
|
|
1353
|
+
"economic_risk",
|
|
1354
|
+
"blocking"
|
|
1355
|
+
]);
|
|
1356
|
+
var productOperatorPoliciesSchema = z13.object({
|
|
1357
|
+
/**
|
|
1358
|
+
* Route cleanup operator. Disabled by default; report-mode is the safe
|
|
1359
|
+
* default so a product can surface zero-traffic runtime-route candidates
|
|
1360
|
+
* before it opts into draft PR mutation.
|
|
1361
|
+
*/
|
|
1362
|
+
cleanup: z13.object({
|
|
1363
|
+
enabled: z13.boolean().default(false),
|
|
1364
|
+
mode: productCleanupPolicyModeSchema.default("report")
|
|
1365
|
+
}).default({ enabled: false, mode: "report" }),
|
|
1366
|
+
/**
|
|
1367
|
+
* PR approval thresholds for manifest impact reports. This schema makes
|
|
1368
|
+
* the policy a first-class product-as-code field even while enforcement is
|
|
1369
|
+
* still report/label-only.
|
|
1370
|
+
*/
|
|
1371
|
+
change_approval: z13.object({
|
|
1372
|
+
auto_merge_max_risk: z13.enum(["none", "safe", "non_blocking"]).default("none"),
|
|
1373
|
+
require_human_for: z13.array(productChangeApprovalRiskSchema).default(["economic_risk", "blocking"])
|
|
1374
|
+
}).default({
|
|
1375
|
+
auto_merge_max_risk: "none",
|
|
1376
|
+
require_human_for: ["economic_risk", "blocking"]
|
|
1377
|
+
})
|
|
1378
|
+
}).default({
|
|
1379
|
+
cleanup: { enabled: false, mode: "report" },
|
|
1380
|
+
change_approval: {
|
|
1381
|
+
auto_merge_max_risk: "none",
|
|
1382
|
+
require_human_for: ["economic_risk", "blocking"]
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
var customerIdentityRequirementSchema = z13.enum([
|
|
1386
|
+
"org_only",
|
|
1387
|
+
"org_and_user"
|
|
1388
|
+
]);
|
|
1389
|
+
var customerPortalAuthStrategySchema = z13.enum([
|
|
1390
|
+
"clerk",
|
|
1391
|
+
"test-personas"
|
|
1392
|
+
]);
|
|
1393
|
+
var productCustomerContextSchema = z13.object({
|
|
1394
|
+
/**
|
|
1395
|
+
* Edge credential identity policy. This is intentionally Product-scoped:
|
|
1396
|
+
* B7 keeps Product as the business boundary and avoids customer-side
|
|
1397
|
+
* Workspace/subject models until per-subject entitlements need them.
|
|
1398
|
+
*/
|
|
1399
|
+
identity_requirement: customerIdentityRequirementSchema.optional(),
|
|
1400
|
+
/**
|
|
1401
|
+
* Enables signed customer-context tokens (`fsc_*`) without putting the
|
|
1402
|
+
* runtime signing secret in product/product.config.ts. Core generates/preserves the
|
|
1403
|
+
* secret when this is true and clears it when explicitly false.
|
|
1404
|
+
*/
|
|
1405
|
+
context_tokens: z13.object({
|
|
1406
|
+
enabled: z13.boolean().default(true)
|
|
1407
|
+
}).optional(),
|
|
1408
|
+
/**
|
|
1409
|
+
* Portal auth strategy for environment-scoped product applies. Production
|
|
1410
|
+
* portal auth is provisioner-owned; preview/test environments can opt into
|
|
1411
|
+
* test personas through Product-as-Code.
|
|
1412
|
+
*/
|
|
1413
|
+
portal_auth: z13.object({
|
|
1414
|
+
strategy: customerPortalAuthStrategySchema
|
|
1415
|
+
}).optional()
|
|
1416
|
+
});
|
|
1254
1417
|
var productSpecSchema = z13.object({
|
|
1255
1418
|
product: z13.object({
|
|
1256
1419
|
name: z13.string().min(1).max(100),
|
|
@@ -1282,11 +1445,12 @@ var productSpecSchema = z13.object({
|
|
|
1282
1445
|
}).optional(),
|
|
1283
1446
|
features: featureCatalogSchema.optional(),
|
|
1284
1447
|
resources: countedResourcesSchema,
|
|
1448
|
+
policies: productOperatorPoliciesSchema,
|
|
1449
|
+
customer_context: productCustomerContextSchema.optional(),
|
|
1285
1450
|
/**
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
* sections remain template-owned.
|
|
1451
|
+
* Legacy/internal declarative frontend surface. New product repos customize
|
|
1452
|
+
* the generated `frontend/` project directly; this remains for existing IR
|
|
1453
|
+
* and internal template metadata.
|
|
1290
1454
|
*/
|
|
1291
1455
|
frontend: frontendManifestSchema.optional(),
|
|
1292
1456
|
migrations: migrationDeclsSchema.optional(),
|
|
@@ -1410,7 +1574,7 @@ var productSpecSchema = z13.object({
|
|
|
1410
1574
|
*
|
|
1411
1575
|
* Once a product has compiled with a `webhooks` block, API mutations
|
|
1412
1576
|
* on those endpoints fail with `409 MANAGED_BY_CODE` — see
|
|
1413
|
-
* `core/src/routes/management-webhooks.ts`. Tie-breaker: product.config.ts
|
|
1577
|
+
* `core/src/routes/management-webhooks.ts`. Tie-breaker: product/product.config.ts
|
|
1414
1578
|
* wins over API state if an endpoint id appears in both.
|
|
1415
1579
|
*/
|
|
1416
1580
|
webhooks: webhooksBlockSchema.optional(),
|
|
@@ -1997,6 +2161,8 @@ var productSpecV2Schema = z20.object({
|
|
|
1997
2161
|
frontend: frontendManifestSchema.optional(),
|
|
1998
2162
|
migrations: migrationDeclsSchema.optional(),
|
|
1999
2163
|
resources: countedResourcesSchema,
|
|
2164
|
+
policies: productOperatorPoliciesSchema,
|
|
2165
|
+
customer_context: productCustomerContextSchema.optional(),
|
|
2000
2166
|
billing: z20.object({
|
|
2001
2167
|
gracePeriodDays: z20.number().int().nonnegative().default(3),
|
|
2002
2168
|
// When true (default), a plan limit INCREASE re-projects onto active
|
|
@@ -2194,7 +2360,7 @@ function hashIr(ir) {
|
|
|
2194
2360
|
}
|
|
2195
2361
|
|
|
2196
2362
|
// src/version.ts
|
|
2197
|
-
var SDK_VERSION = true ? "0.
|
|
2363
|
+
var SDK_VERSION = true ? "0.2.0" : "0.0.0-dev";
|
|
2198
2364
|
|
|
2199
2365
|
// src/business.ts
|
|
2200
2366
|
var BUSINESS_BRAND = Symbol.for("farthershore.product.business");
|
|
@@ -2243,13 +2409,7 @@ var Business = class {
|
|
|
2243
2409
|
[BUSINESS_BRAND] = true;
|
|
2244
2410
|
name;
|
|
2245
2411
|
options;
|
|
2246
|
-
|
|
2247
|
-
resources = /* @__PURE__ */ new Map();
|
|
2248
|
-
plans = /* @__PURE__ */ new Map();
|
|
2249
|
-
features = /* @__PURE__ */ new Map();
|
|
2250
|
-
policies = /* @__PURE__ */ new Map();
|
|
2251
|
-
capabilities = /* @__PURE__ */ new Map();
|
|
2252
|
-
migrations = [];
|
|
2412
|
+
graph = new ManifestResourceGraph();
|
|
2253
2413
|
frontendManifest;
|
|
2254
2414
|
productPatch = {};
|
|
2255
2415
|
/** Sugar for binding API routes to features. */
|
|
@@ -2269,16 +2429,21 @@ var Business = class {
|
|
|
2269
2429
|
}
|
|
2270
2430
|
this.name = name;
|
|
2271
2431
|
this.options = options;
|
|
2432
|
+
this.graph.register("product", name, {
|
|
2433
|
+
name,
|
|
2434
|
+
options
|
|
2435
|
+
});
|
|
2272
2436
|
this.api = {
|
|
2273
2437
|
route: (match, options2) => {
|
|
2274
2438
|
const featureKey = keyOf(options2.feature);
|
|
2275
|
-
const file = this.
|
|
2439
|
+
const file = this.getFeatureFile(featureKey);
|
|
2276
2440
|
if (!file) {
|
|
2277
2441
|
throw new ManifestBuilderError(
|
|
2278
2442
|
`api.route("${match}"): feature "${featureKey}" is not declared \u2014 call business.feature("${featureKey}", \u2026) first`
|
|
2279
2443
|
);
|
|
2280
2444
|
}
|
|
2281
2445
|
file.routes.push(this.buildRoute(match, options2));
|
|
2446
|
+
this.syncFeatureGraphNode(file);
|
|
2282
2447
|
return this;
|
|
2283
2448
|
}
|
|
2284
2449
|
};
|
|
@@ -2290,6 +2455,7 @@ var Business = class {
|
|
|
2290
2455
|
path: item.path,
|
|
2291
2456
|
...item.capability !== void 0 ? { capability: keyOf(item.capability) } : {}
|
|
2292
2457
|
}));
|
|
2458
|
+
this.syncFrontendGraphNode(manifest);
|
|
2293
2459
|
return this;
|
|
2294
2460
|
},
|
|
2295
2461
|
page: (path, options2) => {
|
|
@@ -2314,17 +2480,19 @@ var Business = class {
|
|
|
2314
2480
|
}))
|
|
2315
2481
|
} : {}
|
|
2316
2482
|
});
|
|
2483
|
+
this.syncFrontendGraphNode(manifest);
|
|
2317
2484
|
return this;
|
|
2318
2485
|
},
|
|
2319
2486
|
manifest: (manifest) => {
|
|
2320
2487
|
this.frontendManifest = manifest;
|
|
2488
|
+
this.syncFrontendGraphNode(manifest);
|
|
2321
2489
|
return this;
|
|
2322
2490
|
}
|
|
2323
2491
|
};
|
|
2324
2492
|
this.lifecycle = {
|
|
2325
2493
|
migration: (id, options2) => {
|
|
2326
|
-
this.
|
|
2327
|
-
|
|
2494
|
+
this.assertNewKey("lifecycle_migration", id, "migration");
|
|
2495
|
+
const migration = {
|
|
2328
2496
|
id,
|
|
2329
2497
|
from: this.normalizeMigrationPlanRef(options2.from),
|
|
2330
2498
|
to: this.normalizeMigrationTargetRef(options2.to),
|
|
@@ -2341,12 +2509,26 @@ var Business = class {
|
|
|
2341
2509
|
...pin.notes !== void 0 ? { notes: pin.notes } : {}
|
|
2342
2510
|
}))
|
|
2343
2511
|
} : {}
|
|
2344
|
-
}
|
|
2512
|
+
};
|
|
2513
|
+
this.graph.register(
|
|
2514
|
+
"lifecycle_migration",
|
|
2515
|
+
id,
|
|
2516
|
+
migration,
|
|
2517
|
+
this.migrationDependsOn(migration)
|
|
2518
|
+
);
|
|
2345
2519
|
return this;
|
|
2346
2520
|
},
|
|
2347
2521
|
migrations: (migrations) => {
|
|
2348
2522
|
this.assertUniqueMigrationIds(migrations);
|
|
2349
|
-
this.
|
|
2523
|
+
this.graph.clearKind("lifecycle_migration");
|
|
2524
|
+
for (const migration of migrations) {
|
|
2525
|
+
this.graph.register(
|
|
2526
|
+
"lifecycle_migration",
|
|
2527
|
+
migration.id,
|
|
2528
|
+
migration,
|
|
2529
|
+
this.migrationDependsOn(migration)
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2350
2532
|
return this;
|
|
2351
2533
|
}
|
|
2352
2534
|
};
|
|
@@ -2356,43 +2538,44 @@ var Business = class {
|
|
|
2356
2538
|
return this;
|
|
2357
2539
|
},
|
|
2358
2540
|
plan: (spec) => {
|
|
2359
|
-
this.assertNewKey(
|
|
2360
|
-
this.
|
|
2541
|
+
this.assertNewKey("plan", spec.key, "plan");
|
|
2542
|
+
this.graph.register("plan", spec.key, spec, this.planDependsOn(spec));
|
|
2361
2543
|
return this;
|
|
2362
2544
|
},
|
|
2363
2545
|
routesFile: (file) => {
|
|
2364
|
-
this.assertNewKey(
|
|
2365
|
-
this.
|
|
2546
|
+
this.assertNewKey("feature", file.feature, "feature");
|
|
2547
|
+
this.registerFeatureFile(file);
|
|
2366
2548
|
return this;
|
|
2367
2549
|
},
|
|
2368
2550
|
policyFile: (file) => {
|
|
2369
|
-
this.assertNewKey(
|
|
2370
|
-
this.
|
|
2551
|
+
this.assertNewKey("policy", file.name, "policy");
|
|
2552
|
+
this.graph.register("policy", file.name, file);
|
|
2371
2553
|
return this;
|
|
2372
2554
|
},
|
|
2373
2555
|
capabilityFile: (file) => {
|
|
2374
|
-
this.assertNewKey(
|
|
2375
|
-
this.
|
|
2556
|
+
this.assertNewKey("capability", file.capability, "capability");
|
|
2557
|
+
this.graph.register("capability", file.capability, file);
|
|
2376
2558
|
return this;
|
|
2377
2559
|
},
|
|
2378
2560
|
frontend: (manifest) => {
|
|
2379
2561
|
this.frontendManifest = manifest;
|
|
2562
|
+
this.syncFrontendGraphNode(manifest);
|
|
2380
2563
|
return this;
|
|
2381
2564
|
}
|
|
2382
2565
|
};
|
|
2383
2566
|
}
|
|
2384
2567
|
meter(key, options) {
|
|
2385
|
-
this.assertNewKey(
|
|
2386
|
-
this.
|
|
2568
|
+
this.assertNewKey("meter", key, "meter");
|
|
2569
|
+
this.graph.register("meter", key, { key, ...options });
|
|
2387
2570
|
return { kind: "meter", key };
|
|
2388
2571
|
}
|
|
2389
2572
|
resource(name, options = {}) {
|
|
2390
|
-
this.assertNewKey(
|
|
2391
|
-
this.
|
|
2573
|
+
this.assertNewKey("counted_resource", name, "resource");
|
|
2574
|
+
this.graph.register("counted_resource", name, { name, ...options });
|
|
2392
2575
|
return { kind: "resource", key: name };
|
|
2393
2576
|
}
|
|
2394
2577
|
capability(key, options = {}) {
|
|
2395
|
-
this.assertNewKey(
|
|
2578
|
+
this.assertNewKey("capability", key, "capability");
|
|
2396
2579
|
const file = {
|
|
2397
2580
|
capability: key,
|
|
2398
2581
|
...options.description !== void 0 || options.title !== void 0 ? { description: options.description ?? options.title } : {},
|
|
@@ -2401,7 +2584,12 @@ var Business = class {
|
|
|
2401
2584
|
...options.includesPolicies?.length ? { includes_policies: options.includesPolicies.map(keyOf) } : {},
|
|
2402
2585
|
...options.includesCapabilities?.length ? { includes_capabilities: options.includesCapabilities.map(keyOf) } : {}
|
|
2403
2586
|
};
|
|
2404
|
-
this.
|
|
2587
|
+
this.graph.register(
|
|
2588
|
+
"capability",
|
|
2589
|
+
key,
|
|
2590
|
+
file,
|
|
2591
|
+
this.capabilityDependsOn(file)
|
|
2592
|
+
);
|
|
2405
2593
|
return {
|
|
2406
2594
|
kind: "capability",
|
|
2407
2595
|
key,
|
|
@@ -2413,7 +2601,7 @@ var Business = class {
|
|
|
2413
2601
|
};
|
|
2414
2602
|
}
|
|
2415
2603
|
feature(key, options = {}) {
|
|
2416
|
-
this.assertNewKey(
|
|
2604
|
+
this.assertNewKey("feature", key, "feature");
|
|
2417
2605
|
const file = {
|
|
2418
2606
|
feature: key,
|
|
2419
2607
|
routes: [],
|
|
@@ -2440,30 +2628,34 @@ var Business = class {
|
|
|
2440
2628
|
for (const route of options.routes ?? []) {
|
|
2441
2629
|
file.routes.push(this.buildRoute(route.match, route));
|
|
2442
2630
|
}
|
|
2443
|
-
this.
|
|
2631
|
+
this.registerFeatureFile(file);
|
|
2444
2632
|
const ref = {
|
|
2445
2633
|
kind: "feature",
|
|
2446
2634
|
key,
|
|
2447
2635
|
action: (id, actionOptions) => {
|
|
2448
2636
|
file.actions ??= [];
|
|
2449
|
-
if (file.actions.some((
|
|
2637
|
+
if (file.actions.some((action2) => action2.id === id) || this.graph.has("action", id)) {
|
|
2450
2638
|
throw new ManifestBuilderError(
|
|
2451
2639
|
`duplicate action "${id}" \u2014 each action id must be declared once`
|
|
2452
2640
|
);
|
|
2453
2641
|
}
|
|
2454
|
-
|
|
2642
|
+
const action = { id, ...actionOptions };
|
|
2643
|
+
file.actions.push(action);
|
|
2644
|
+
this.registerAction(key, action);
|
|
2645
|
+
this.syncFeatureGraphNode(file);
|
|
2455
2646
|
return { kind: "action", key: id };
|
|
2456
2647
|
},
|
|
2457
2648
|
route: (match, routeOptions) => {
|
|
2458
2649
|
file.routes.push(this.buildRoute(match, routeOptions ?? {}));
|
|
2650
|
+
this.syncFeatureGraphNode(file);
|
|
2459
2651
|
return ref;
|
|
2460
2652
|
}
|
|
2461
2653
|
};
|
|
2462
2654
|
return ref;
|
|
2463
2655
|
}
|
|
2464
2656
|
policy(name, options) {
|
|
2465
|
-
this.assertNewKey(
|
|
2466
|
-
|
|
2657
|
+
this.assertNewKey("policy", name, "policy");
|
|
2658
|
+
const file = {
|
|
2467
2659
|
name,
|
|
2468
2660
|
type: options.type,
|
|
2469
2661
|
config: options.config,
|
|
@@ -2477,11 +2669,12 @@ var Business = class {
|
|
|
2477
2669
|
...options.compatibleWith.authModes ? { auth_modes: options.compatibleWith.authModes } : {}
|
|
2478
2670
|
}
|
|
2479
2671
|
} : {}
|
|
2480
|
-
}
|
|
2672
|
+
};
|
|
2673
|
+
this.graph.register("policy", name, file, this.policyDependsOn(file));
|
|
2481
2674
|
return { kind: "policy", key: name };
|
|
2482
2675
|
}
|
|
2483
2676
|
plan(key, options) {
|
|
2484
|
-
this.assertNewKey(
|
|
2677
|
+
this.assertNewKey("plan", key, "plan");
|
|
2485
2678
|
const capabilityKeys = (options.capabilities ?? []).map(keyOf);
|
|
2486
2679
|
const capabilityLimits = {
|
|
2487
2680
|
...options.capabilityLimits ?? {}
|
|
@@ -2530,20 +2723,37 @@ var Business = class {
|
|
|
2530
2723
|
if (mergedCaps.length) {
|
|
2531
2724
|
spec.capabilities = mergedCaps;
|
|
2532
2725
|
}
|
|
2533
|
-
this.
|
|
2726
|
+
this.graph.register("plan", key, spec, this.planDependsOn(spec));
|
|
2534
2727
|
return { kind: "plan", key };
|
|
2535
2728
|
}
|
|
2729
|
+
use(...modules) {
|
|
2730
|
+
for (const module of modules) {
|
|
2731
|
+
module(this);
|
|
2732
|
+
}
|
|
2733
|
+
return this;
|
|
2734
|
+
}
|
|
2735
|
+
/** Inspect the SDK-local declaration graph used to emit the Manifest IR. */
|
|
2736
|
+
resourceGraph() {
|
|
2737
|
+
return this.graph.snapshot();
|
|
2738
|
+
}
|
|
2536
2739
|
/** Assemble + validate the Manifest IR. Throws ManifestValidationError
|
|
2537
2740
|
* with structured issues when the declared state is invalid. */
|
|
2538
2741
|
toIR() {
|
|
2742
|
+
this.assertGraphDependenciesSatisfied();
|
|
2539
2743
|
const candidate = {
|
|
2540
2744
|
irVersion: 1,
|
|
2541
2745
|
sdkVersion: SDK_VERSION,
|
|
2542
2746
|
product: this.buildProductSpec(),
|
|
2543
|
-
routes:
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2747
|
+
routes: this.graph.sortedValues(
|
|
2748
|
+
"feature",
|
|
2749
|
+
(file) => file.feature
|
|
2750
|
+
),
|
|
2751
|
+
policies: this.graph.sortedValues(
|
|
2752
|
+
"policy",
|
|
2753
|
+
(file) => file.name
|
|
2754
|
+
),
|
|
2755
|
+
capabilities: this.graph.sortedValues(
|
|
2756
|
+
"capability",
|
|
2547
2757
|
(file) => file.capability
|
|
2548
2758
|
),
|
|
2549
2759
|
runtime: { rollout: null, flags: null, migrations: null }
|
|
@@ -2571,19 +2781,34 @@ var Business = class {
|
|
|
2571
2781
|
...options.upstreamAuth !== void 0 ? { upstreamAuth: options.upstreamAuth } : {}
|
|
2572
2782
|
},
|
|
2573
2783
|
metering: {
|
|
2574
|
-
meters:
|
|
2784
|
+
meters: this.graph.sortedValues(
|
|
2785
|
+
"meter",
|
|
2786
|
+
(meter) => meter.key
|
|
2787
|
+
),
|
|
2575
2788
|
...options.billOn4xx !== void 0 ? { billOn4xx: options.billOn4xx } : {}
|
|
2576
2789
|
},
|
|
2577
2790
|
...options.billing !== void 0 ? { billing: options.billing } : {},
|
|
2578
|
-
...
|
|
2579
|
-
...
|
|
2580
|
-
...this.
|
|
2581
|
-
|
|
2582
|
-
|
|
2791
|
+
...options.operatorPolicies !== void 0 ? { policies: options.operatorPolicies } : {},
|
|
2792
|
+
...options.customerContext !== void 0 ? { customer_context: buildCustomerContext(options.customerContext) } : {},
|
|
2793
|
+
...this.graph.has("frontend", "manifest") ? {
|
|
2794
|
+
frontend: this.graph.get(
|
|
2795
|
+
"frontend",
|
|
2796
|
+
"manifest"
|
|
2797
|
+
)?.value
|
|
2798
|
+
} : {},
|
|
2799
|
+
...this.graph.values("lifecycle_migration").length ? {
|
|
2800
|
+
migrations: this.graph.sortedValues(
|
|
2801
|
+
"lifecycle_migration",
|
|
2802
|
+
(migration) => migration.id
|
|
2803
|
+
)
|
|
2804
|
+
} : {},
|
|
2805
|
+
...this.graph.values("counted_resource").length ? {
|
|
2806
|
+
resources: this.graph.sortedValues(
|
|
2807
|
+
"counted_resource",
|
|
2583
2808
|
(resource) => resource.name
|
|
2584
2809
|
)
|
|
2585
2810
|
} : {},
|
|
2586
|
-
plans:
|
|
2811
|
+
plans: this.graph.sortedValues("plan", (plan) => plan.key)
|
|
2587
2812
|
};
|
|
2588
2813
|
return deepMerge(
|
|
2589
2814
|
base,
|
|
@@ -2601,6 +2826,7 @@ var Business = class {
|
|
|
2601
2826
|
}
|
|
2602
2827
|
ensureFrontendManifest() {
|
|
2603
2828
|
this.frontendManifest ??= { version: 1, nav: [], pages: [] };
|
|
2829
|
+
this.syncFrontendGraphNode(this.frontendManifest);
|
|
2604
2830
|
return this.frontendManifest;
|
|
2605
2831
|
}
|
|
2606
2832
|
normalizeMigrationPlanRef(ref) {
|
|
@@ -2615,13 +2841,6 @@ var Business = class {
|
|
|
2615
2841
|
version: ref.version ?? "head"
|
|
2616
2842
|
};
|
|
2617
2843
|
}
|
|
2618
|
-
assertUniqueMigrationId(id) {
|
|
2619
|
-
if (this.migrations.some((migration) => migration.id === id)) {
|
|
2620
|
-
throw new ManifestBuilderError(
|
|
2621
|
-
`duplicate migration "${id}" \u2014 each migration id must be declared once`
|
|
2622
|
-
);
|
|
2623
|
-
}
|
|
2624
|
-
}
|
|
2625
2844
|
assertUniqueMigrationIds(migrations) {
|
|
2626
2845
|
const seen = /* @__PURE__ */ new Set();
|
|
2627
2846
|
for (const migration of migrations) {
|
|
@@ -2633,27 +2852,149 @@ var Business = class {
|
|
|
2633
2852
|
seen.add(migration.id);
|
|
2634
2853
|
}
|
|
2635
2854
|
}
|
|
2636
|
-
assertNewKey(
|
|
2637
|
-
if (
|
|
2855
|
+
assertNewKey(kind, key, label) {
|
|
2856
|
+
if (this.graph.has(kind, key)) {
|
|
2638
2857
|
throw new ManifestBuilderError(
|
|
2639
2858
|
`duplicate ${label} "${key}" \u2014 each ${label} key must be declared once`
|
|
2640
2859
|
);
|
|
2641
2860
|
}
|
|
2642
2861
|
}
|
|
2862
|
+
assertGraphDependenciesSatisfied() {
|
|
2863
|
+
const missing = this.graph.missingDependencies();
|
|
2864
|
+
if (missing.length === 0) return;
|
|
2865
|
+
const details = missing.slice(0, 8).map(
|
|
2866
|
+
({ from, missing: dependency }) => `${describeResourceUrn(from)} depends on missing ${describeResourceUrn(
|
|
2867
|
+
dependency
|
|
2868
|
+
)}`
|
|
2869
|
+
).join("; ");
|
|
2870
|
+
const suffix = missing.length > 8 ? `; plus ${missing.length - 8} more` : "";
|
|
2871
|
+
throw new ManifestBuilderError(
|
|
2872
|
+
`manifest has unresolved resource reference(s): ${details}${suffix}`
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
getFeatureFile(key) {
|
|
2876
|
+
return this.graph.get("feature", key)?.value ?? null;
|
|
2877
|
+
}
|
|
2878
|
+
registerFeatureFile(file) {
|
|
2879
|
+
this.graph.register(
|
|
2880
|
+
"feature",
|
|
2881
|
+
file.feature,
|
|
2882
|
+
file,
|
|
2883
|
+
this.featureDependsOn(file)
|
|
2884
|
+
);
|
|
2885
|
+
for (const action of file.actions ?? []) {
|
|
2886
|
+
this.registerAction(file.feature, action);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
syncFeatureGraphNode(file) {
|
|
2890
|
+
this.graph.upsert(
|
|
2891
|
+
"feature",
|
|
2892
|
+
file.feature,
|
|
2893
|
+
file,
|
|
2894
|
+
this.featureDependsOn(file)
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
registerAction(featureKey, action) {
|
|
2898
|
+
this.assertNewKey("action", action.id, "action");
|
|
2899
|
+
this.graph.register(
|
|
2900
|
+
"action",
|
|
2901
|
+
action.id,
|
|
2902
|
+
action,
|
|
2903
|
+
this.actionDependsOn(featureKey, action)
|
|
2904
|
+
);
|
|
2905
|
+
}
|
|
2906
|
+
syncFrontendGraphNode(manifest) {
|
|
2907
|
+
this.graph.upsert(
|
|
2908
|
+
"frontend",
|
|
2909
|
+
"manifest",
|
|
2910
|
+
manifest,
|
|
2911
|
+
this.frontendDependsOn(manifest)
|
|
2912
|
+
);
|
|
2913
|
+
}
|
|
2914
|
+
capabilityDependsOn(file) {
|
|
2915
|
+
return [
|
|
2916
|
+
...this.dependenciesFor("feature", file.includes_features),
|
|
2917
|
+
...this.dependenciesFor("policy", file.includes_policies),
|
|
2918
|
+
...this.dependenciesFor("capability", file.includes_capabilities)
|
|
2919
|
+
];
|
|
2920
|
+
}
|
|
2921
|
+
policyDependsOn(file) {
|
|
2922
|
+
return this.dependenciesFor("meter", file.compatible_with?.meters);
|
|
2923
|
+
}
|
|
2924
|
+
featureDependsOn(file) {
|
|
2925
|
+
const meterKeys = file.routes.flatMap((route) => route.meters ?? []);
|
|
2926
|
+
return [
|
|
2927
|
+
...this.dependenciesFor("policy", file.policies),
|
|
2928
|
+
...this.dependenciesFor("capability", file.capabilities),
|
|
2929
|
+
...this.dependenciesFor("plan", file.plans),
|
|
2930
|
+
...this.dependenciesFor("meter", meterKeys)
|
|
2931
|
+
];
|
|
2932
|
+
}
|
|
2933
|
+
actionDependsOn(featureKey, action) {
|
|
2934
|
+
return [
|
|
2935
|
+
resourceDependency("feature", featureKey),
|
|
2936
|
+
...action.resource ? [resourceDependency("counted_resource", action.resource.resource)] : []
|
|
2937
|
+
];
|
|
2938
|
+
}
|
|
2939
|
+
planDependsOn(plan) {
|
|
2940
|
+
const caps = Array.isArray(plan.capabilities) ? plan.capabilities.map(String) : [];
|
|
2941
|
+
const limitDimensions = (plan.limits ?? []).map((limit) => limit.dimension);
|
|
2942
|
+
const pricedMeterDimensions = (plan.meters ?? []).map(
|
|
2943
|
+
(meter) => meter.dimension
|
|
2944
|
+
);
|
|
2945
|
+
const capacityKeys = Object.keys(plan.capability_limits ?? {});
|
|
2946
|
+
return [
|
|
2947
|
+
...this.dependenciesFor("capability", caps),
|
|
2948
|
+
...this.existingDependenciesFor("meter", [
|
|
2949
|
+
...limitDimensions,
|
|
2950
|
+
...pricedMeterDimensions
|
|
2951
|
+
]),
|
|
2952
|
+
...this.existingDependenciesFor("counted_resource", capacityKeys)
|
|
2953
|
+
];
|
|
2954
|
+
}
|
|
2955
|
+
migrationDependsOn(migration) {
|
|
2956
|
+
return [
|
|
2957
|
+
resourceDependency("plan", migration.from.plan),
|
|
2958
|
+
resourceDependency("plan", migration.to.plan),
|
|
2959
|
+
...this.dependenciesFor(
|
|
2960
|
+
"plan",
|
|
2961
|
+
migration.pins?.map((pin) => pin.pinTo.plan)
|
|
2962
|
+
)
|
|
2963
|
+
];
|
|
2964
|
+
}
|
|
2965
|
+
frontendDependsOn(manifest) {
|
|
2966
|
+
return this.dependenciesFor("capability", [
|
|
2967
|
+
...(manifest.nav ?? []).flatMap(
|
|
2968
|
+
(item) => item.capability ? [item.capability] : []
|
|
2969
|
+
),
|
|
2970
|
+
...(manifest.pages ?? []).flatMap((page) => [
|
|
2971
|
+
...page.capability ? [page.capability] : [],
|
|
2972
|
+
...(page.components ?? []).flatMap(
|
|
2973
|
+
(component) => component.capability ? [component.capability] : []
|
|
2974
|
+
)
|
|
2975
|
+
])
|
|
2976
|
+
]);
|
|
2977
|
+
}
|
|
2978
|
+
dependenciesFor(kind, keys) {
|
|
2979
|
+
return [...new Set(keys ?? [])].map((key) => resourceDependency(kind, key));
|
|
2980
|
+
}
|
|
2981
|
+
existingDependenciesFor(kind, keys) {
|
|
2982
|
+
return [...new Set(keys)].filter((key) => this.graph.has(kind, key)).map((key) => resourceDependency(kind, key));
|
|
2983
|
+
}
|
|
2643
2984
|
};
|
|
2644
2985
|
function isBusiness(value) {
|
|
2645
2986
|
return typeof value === "object" && value !== null && value[BUSINESS_BRAND] === true;
|
|
2646
2987
|
}
|
|
2647
|
-
function sortBy(items, key) {
|
|
2648
|
-
return [...items].sort((a, b) => {
|
|
2649
|
-
const ka = key(a);
|
|
2650
|
-
const kb = key(b);
|
|
2651
|
-
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
2652
|
-
});
|
|
2653
|
-
}
|
|
2654
2988
|
function isPlainObject(value) {
|
|
2655
2989
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2656
2990
|
}
|
|
2991
|
+
function buildCustomerContext(options) {
|
|
2992
|
+
return {
|
|
2993
|
+
...options.identityRequirement !== void 0 ? { identity_requirement: options.identityRequirement } : {},
|
|
2994
|
+
...options.contextTokens !== void 0 ? { context_tokens: options.contextTokens } : {},
|
|
2995
|
+
...options.portalAuth !== void 0 ? { portal_auth: options.portalAuth } : {}
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2657
2998
|
function deepMerge(base, patch) {
|
|
2658
2999
|
const out = { ...base };
|
|
2659
3000
|
for (const [key, value] of Object.entries(patch)) {
|
|
@@ -2666,11 +3007,21 @@ function deepMerge(base, patch) {
|
|
|
2666
3007
|
}
|
|
2667
3008
|
return out;
|
|
2668
3009
|
}
|
|
3010
|
+
function describeResourceUrn(urn) {
|
|
3011
|
+
const parts = urn.split(":");
|
|
3012
|
+
const kind = parts[3] ?? "resource";
|
|
3013
|
+
const key = parts.slice(4).join(":");
|
|
3014
|
+
return `${kind} "${decodeURIComponent(key)}"`;
|
|
3015
|
+
}
|
|
2669
3016
|
|
|
2670
3017
|
// src/bin.ts
|
|
3018
|
+
var DEFAULT_ENTRY_CANDIDATES = [
|
|
3019
|
+
"product/product.config.ts",
|
|
3020
|
+
"product.config.ts"
|
|
3021
|
+
];
|
|
2671
3022
|
function parseArgs(argv) {
|
|
2672
3023
|
const args = {
|
|
2673
|
-
entry:
|
|
3024
|
+
entry: null,
|
|
2674
3025
|
out: "manifest-ir.json",
|
|
2675
3026
|
diagnosticsOut: "manifest-diagnostics.json"
|
|
2676
3027
|
};
|
|
@@ -2688,13 +3039,35 @@ function parseArgs(argv) {
|
|
|
2688
3039
|
i++;
|
|
2689
3040
|
} else if (flag === "--help" || flag === "-h") {
|
|
2690
3041
|
process.stdout.write(
|
|
2691
|
-
"Usage: farthershore-manifest-build [--entry product.config.ts] [--out manifest-ir.json] [--diagnostics-out manifest-diagnostics.json]\n"
|
|
3042
|
+
"Usage: farthershore-manifest-build [--entry product/product.config.ts] [--out manifest-ir.json] [--diagnostics-out manifest-diagnostics.json]\n"
|
|
2692
3043
|
);
|
|
2693
3044
|
process.exit(0);
|
|
2694
3045
|
}
|
|
2695
3046
|
}
|
|
2696
3047
|
return args;
|
|
2697
3048
|
}
|
|
3049
|
+
async function fileExists(path) {
|
|
3050
|
+
try {
|
|
3051
|
+
await access(path, constants.R_OK);
|
|
3052
|
+
return true;
|
|
3053
|
+
} catch {
|
|
3054
|
+
return false;
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
async function resolveEntry(args) {
|
|
3058
|
+
if (args.entry) {
|
|
3059
|
+
return { entry: args.entry, entryPath: resolve(process.cwd(), args.entry) };
|
|
3060
|
+
}
|
|
3061
|
+
for (const candidate of DEFAULT_ENTRY_CANDIDATES) {
|
|
3062
|
+
const entryPath = resolve(process.cwd(), candidate);
|
|
3063
|
+
if (await fileExists(entryPath)) {
|
|
3064
|
+
return { entry: candidate, entryPath };
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
throw new Error(
|
|
3068
|
+
`no Product SDK entry found; expected ${DEFAULT_ENTRY_CANDIDATES.join(" or ")}`
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
2698
3071
|
function validationIssues(error) {
|
|
2699
3072
|
if (error instanceof ManifestValidationError) return error.issues;
|
|
2700
3073
|
if (typeof error === "object" && error !== null && error.name === "ManifestValidationError" && Array.isArray(error.issues)) {
|
|
@@ -2707,16 +3080,25 @@ function errorMessage(error) {
|
|
|
2707
3080
|
}
|
|
2708
3081
|
async function main() {
|
|
2709
3082
|
const args = parseArgs(process.argv.slice(2));
|
|
2710
|
-
|
|
3083
|
+
let resolvedEntry;
|
|
3084
|
+
try {
|
|
3085
|
+
resolvedEntry = await resolveEntry(args);
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
process.stderr.write(
|
|
3088
|
+
`farthershore-manifest-build: ${errorMessage(error)}
|
|
3089
|
+
`
|
|
3090
|
+
);
|
|
3091
|
+
process.exit(1);
|
|
3092
|
+
}
|
|
2711
3093
|
let mod;
|
|
2712
3094
|
try {
|
|
2713
3095
|
mod = await tsImport(
|
|
2714
|
-
pathToFileURL(entryPath).href,
|
|
3096
|
+
pathToFileURL(resolvedEntry.entryPath).href,
|
|
2715
3097
|
import.meta.url
|
|
2716
3098
|
);
|
|
2717
3099
|
} catch (error) {
|
|
2718
3100
|
process.stderr.write(
|
|
2719
|
-
`farthershore-manifest-build: failed to load ${
|
|
3101
|
+
`farthershore-manifest-build: failed to load ${resolvedEntry.entry}: ${error instanceof Error ? error.message : String(error)}
|
|
2720
3102
|
`
|
|
2721
3103
|
);
|
|
2722
3104
|
process.exit(1);
|
|
@@ -2726,7 +3108,7 @@ async function main() {
|
|
|
2726
3108
|
const candidate = isBusiness(direct) ? direct : interop;
|
|
2727
3109
|
if (!isBusiness(candidate)) {
|
|
2728
3110
|
process.stderr.write(
|
|
2729
|
-
`farthershore-manifest-build: ${
|
|
3111
|
+
`farthershore-manifest-build: ${resolvedEntry.entry} must \`export default\` the Business returned by fs.business(\u2026)
|
|
2730
3112
|
`
|
|
2731
3113
|
);
|
|
2732
3114
|
process.exit(1);
|