@stardeck-customer-apps/compose 0.13.0 → 0.14.1

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/SKILL.md CHANGED
@@ -12,7 +12,9 @@ Compose turns that declaration into the code Next.js actually builds:
12
12
  - `apps/web/src/modules.gen.ts` — the Module registry, `isModuleEnabled` and `isMountHeadless`
13
13
  - `apps/web/src/module-contributions.gen.ts`
14
14
  - `apps/web/src/module-i18n.gen.ts`
15
- - `apps/web/src/module-init.server.gen.ts`
15
+ - `apps/web/src/module-init.server.gen.ts` — memoized `initializeActiveEnhancements()`:
16
+ each enabled Module's root `initializeModule`, then each active slice's
17
+ `initializeEnhancement`
16
18
  - `apps/web/src/module-datastores.gen.ts`
17
19
  - `apps/web/src/module-server.gen.ts` — `MODULE_SERVER_REGISTRATIONS`, one entry
18
20
  per composed Module that ships `server/registrations.ts`
@@ -149,6 +151,38 @@ a silent fallback. It holds deployment facts: gitignore it (`apps/web/.gitignore
149
151
  the bundle. Compose does not check the export names or shapes. The file
150
152
  counts as `server/**/*.ts`, so the Module also needs `server/index.ts`.
151
153
  Needs compose 0.8.0 or later.
154
+ - **Module initializers.** Server registrations a Module makes into shared
155
+ registries (observers, resolvers, facets, drains) go in a root
156
+ `initialize.server.ts`, any Module kind:
157
+
158
+ ```ts
159
+ // src/modules/guest-ordering/initialize.server.ts — server-only, idempotent
160
+ import { registerPosCloseObserver } from "@/modules/pos/server";
161
+
162
+ import { wakeReconcileForClosedOrder } from "./server/reconcile";
163
+
164
+ export async function initializeModule() {
165
+ registerPosCloseObserver(wakeReconcileForClosedOrder);
166
+ }
167
+ ```
168
+
169
+ `initializeModule` must be a direct named function declaration callable with
170
+ zero required arguments (no arrow function, default export, re-export or
171
+ generator); compose fails otherwise. Compose imports it into
172
+ `module-init.server.gen.ts`, guarded by `isModuleEnabled("<module>")`, and
173
+ runs every Module initializer before any enhancement initializer, once per
174
+ instance. Every generated page stub (its render and its `generateMetadata`)
175
+ and every generated API route stub awaits `initializeActiveEnhancements()`
176
+ before its enablement guards, so the registrations exist in the route's own
177
+ module graph. Page stubs await it from compose 0.14.0; before that only API
178
+ stubs did. An `initialize()` export
179
+ run only from `instrumentation.ts` does not give you that: Next.js loads
180
+ instrumentation in a separate module graph, so on a cold instance the route
181
+ handlers and server components never see what it registered. A Module left
182
+ out by `--enabled` is not imported. Needs compose 0.14.0 or later (and
183
+ `@stardeck-customer-apps/eslint-plugin` 1.8.0 or later, whose
184
+ `no-module-internal-import` lets the generated file import it).
185
+
152
186
  - `@stardeck-customer-apps/compose/runtime` exports `isModuleEnabledByList`,
153
187
  the single enablement rule, and imports nothing — it is safe in client
154
188
  components.
package/dist/cli.js CHANGED
@@ -2259,10 +2259,12 @@ var modulePermissionClaimSchema = import_zod.z.object({
2259
2259
  /** Human label for the deployment auth config row. */
2260
2260
  description: descriptionSchema,
2261
2261
  /**
2262
- * The default organization roles that hold this key when the platform
2263
- * first registers it on an app (a Blueprint fork, a Module install). Admin
2264
- * holds every key without being listed; External Viewer never holds one.
2265
- * Changing a role's grants afterwards is the organization's business.
2262
+ * The standard organization roles that hold this key. The platform sets them on every app
2263
+ * (a Blueprint fork, a Module install or uninstall, every Blueprint update), so this list is
2264
+ * the truth: a role added here gains the key, a role removed loses it, and nobody can edit a
2265
+ * standard role's grants on a Module key. Admin holds every key without being listed;
2266
+ * External Viewer never holds one. An organization that wants different access duplicates the
2267
+ * standard role and edits the copy.
2266
2268
  */
2267
2269
  roles: import_zod.z.array(import_zod.z.enum(MODULE_PERMISSION_DEFAULT_ROLES)).optional()
2268
2270
  }).strict();
@@ -3560,17 +3562,20 @@ var import_typescript3 = __toESM(require("typescript"), 1);
3560
3562
  var CONVENTIONAL_ENTRY_EXPORT_NAME = {
3561
3563
  contributions: "contributions",
3562
3564
  slotCatalogs: "slotCatalogs",
3563
- initializeEnhancement: "initializeEnhancement"
3565
+ initializeEnhancement: "initializeEnhancement",
3566
+ initializeModule: "initializeModule"
3564
3567
  };
3565
3568
  var CONVENTIONAL_ENTRY_DECLARATION_KIND = {
3566
3569
  contributions: "variable",
3567
3570
  slotCatalogs: "variable",
3568
- initializeEnhancement: "function"
3571
+ initializeEnhancement: "function",
3572
+ initializeModule: "function"
3569
3573
  };
3570
3574
  var CONVENTIONAL_ENTRY_FILENAME = {
3571
3575
  contributions: "contributions.ts",
3572
3576
  slotCatalogs: "slots.ts",
3573
- initializeEnhancement: "initialize.server.ts"
3577
+ initializeEnhancement: "initialize.server.ts",
3578
+ initializeModule: "initialize.server.ts"
3574
3579
  };
3575
3580
  function hasModifierKind(modifiers, kind) {
3576
3581
  return Boolean(modifiers?.some((m) => m.kind === kind));
@@ -3669,7 +3674,7 @@ function analyzeConventionalEntryExportWithTs(typescript, sourceText, kind, file
3669
3674
  }
3670
3675
  if (!isZeroArgumentInvocable(typescript, stmt.parameters)) {
3671
3676
  errors.push(
3672
- `"${entryName}" must be invocable with zero arguments (generated caller uses initializeEnhancement()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3677
+ `"${entryName}" must be invocable with zero arguments (generated caller uses ${entryName}()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3673
3678
  );
3674
3679
  continue;
3675
3680
  }
@@ -3979,6 +3984,7 @@ var JSON_SECRET_PAIR = new RegExp(
3979
3984
  "g"
3980
3985
  );
3981
3986
  var SECRET_QUOTE_OPENER = new RegExp(String.raw`\b${SECRET_NAME}\s*=\s*(['"])`, "g");
3987
+ var SHELL_QUOTE_CONTINUATION = String.raw`\''`;
3982
3988
  function redactSecrets(text) {
3983
3989
  return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(SECRET_ASSIGNMENT, "$1=***").replace(JSON_SECRET_PAIR, '"$1"$2"***"').replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
3984
3990
  }
@@ -4150,6 +4156,9 @@ function assertSlotCatalogsExport(source, pathLabel) {
4150
4156
  function assertInitializeEnhancementExport(source, pathLabel) {
4151
4157
  assertConventionalEntryExport(source, pathLabel, "initializeEnhancement");
4152
4158
  }
4159
+ function assertInitializeModuleExport(source, pathLabel) {
4160
+ assertConventionalEntryExport(source, pathLabel, "initializeModule");
4161
+ }
4153
4162
  function assertClientSafeConventionalEntryImports(source, pathLabel, kind) {
4154
4163
  const { analysisErrors } = analyzeClientSafeEntryImports(
4155
4164
  source,
@@ -4669,30 +4678,47 @@ function deriveModuleLocaleLayers(registry, facts) {
4669
4678
  }
4670
4679
  return layers;
4671
4680
  }
4672
- function emitModuleInitServerGenTs(input) {
4681
+ function moduleInitializerCalls(registry, facts) {
4682
+ const installed = new Set(registry.modules.map((m) => m.name));
4683
+ return facts.filter((f) => f.hasRootInitializeServer && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames).map((name, index) => {
4684
+ if (!isValidModuleName(name)) {
4685
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4686
+ }
4687
+ return {
4688
+ exportName: "initializeModule",
4689
+ guard: [name],
4690
+ ident: `initializeModule${index}`,
4691
+ importPath: `@/modules/${name}/initialize.server`
4692
+ };
4693
+ });
4694
+ }
4695
+ function enhancementInitializerCalls(registry, facts) {
4673
4696
  const calls = [];
4674
- const installed = new Set(input.registry.modules.map((m) => m.name));
4675
- let initializerIndex = 0;
4676
- for (const facts of [...input.facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4677
- if (!installed.has(facts.moduleName)) continue;
4678
- const peers = activePeers(input.registry, facts.moduleName);
4679
- for (const enh of [...facts.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4697
+ const installed = new Set(registry.modules.map((m) => m.name));
4698
+ for (const f of [...facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4699
+ if (!installed.has(f.moduleName)) continue;
4700
+ const peers = activePeers(registry, f.moduleName);
4701
+ for (const enh of [...f.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4680
4702
  if (!enh.active || !peers.has(enh.peer)) continue;
4681
4703
  if (!enh.hasInitializeServer) continue;
4682
- const ident = `initializeEnhancement${initializerIndex++}`;
4683
4704
  calls.push({
4684
- owner: facts.moduleName,
4685
- peer: enh.peer,
4686
- ident,
4687
- importPath: `@/modules/${facts.moduleName}/enhancements/${enh.peer}/initialize.server`
4705
+ exportName: "initializeEnhancement",
4706
+ guard: [f.moduleName, enh.peer],
4707
+ ident: `initializeEnhancement${calls.length}`,
4708
+ importPath: `@/modules/${f.moduleName}/enhancements/${enh.peer}/initialize.server`
4688
4709
  });
4689
4710
  }
4690
4711
  }
4691
- const importLines = calls.map(
4692
- (c) => `import { initializeEnhancement as ${c.ident} } from ${JSON.stringify(c.importPath)};`
4693
- );
4712
+ return calls;
4713
+ }
4714
+ function emitModuleInitServerGenTs(input) {
4715
+ const moduleCalls = moduleInitializerCalls(input.registry, input.facts);
4716
+ const enhancementCalls = enhancementInitializerCalls(input.registry, input.facts);
4717
+ const calls = [...moduleCalls, ...enhancementCalls];
4718
+ const importFor = (c) => `import { ${c.exportName} as ${c.ident} } from ${JSON.stringify(c.importPath)};`;
4719
+ const importLines = moduleCalls.length === 0 ? calls.map(importFor) : [...calls].sort((a, b) => compareImportSpecifiers(a.importPath, b.importPath)).map(importFor);
4694
4720
  const callLines = calls.length === 0 ? [" // Empty composition \u2014 no active enhancement initializers."] : calls.map(
4695
- (c) => ` if (enabled(${JSON.stringify(c.owner)}) && enabled(${JSON.stringify(c.peer)})) await ${c.ident}();`
4721
+ (c) => ` if (${c.guard.map((name) => `enabled(${JSON.stringify(name)})`).join(" && ")}) await ${c.ident}();`
4696
4722
  );
4697
4723
  return [
4698
4724
  formatInitMarker(),
@@ -4804,6 +4830,9 @@ function enhancementImport(owner, peer) {
4804
4830
  function scopeLabel(scope) {
4805
4831
  return scope.kind === "root" ? "root" : `enhancement:${scope.peer}`;
4806
4832
  }
4833
+ function endpointMarkerOwnsStub(marker, stub) {
4834
+ return stub != null && marker?.kind === "endpoint" && marker.owner === stub.owner;
4835
+ }
4807
4836
  function formatEndpointMarker(owner, scope) {
4808
4837
  return `// ${MODULE_STUB_MARKER} owner=${owner} scope=${scopeLabel(scope)}`;
4809
4838
  }
@@ -5142,6 +5171,7 @@ function buildRouteStubContent(input) {
5142
5171
  'import { notFound } from "next/navigation";',
5143
5172
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5144
5173
  "",
5174
+ 'import { initializeActiveEnhancements } from "@/module-init.server.gen";',
5145
5175
  'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5146
5176
  `import Page from ${emitTsString(input.importModule)};`
5147
5177
  ];
@@ -5150,6 +5180,7 @@ function buildRouteStubContent(input) {
5150
5180
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5151
5181
  "",
5152
5182
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5183
+ " await initializeActiveEnhancements();",
5153
5184
  ...emitGuardLines(guards, "return {};"),
5154
5185
  " return sourceGenerateMetadata(...args);",
5155
5186
  "}"
@@ -5160,7 +5191,10 @@ function buildRouteStubContent(input) {
5160
5191
  "type PageProps =",
5161
5192
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5162
5193
  "",
5163
- "export default function ModulePage(props: PageProps) {",
5194
+ // Module and enhancement initializers run in this route's own module graph
5195
+ // (memoized), so a cold server render sees their registrations.
5196
+ "export default async function ModulePage(props: PageProps) {",
5197
+ " await initializeActiveEnhancements();",
5164
5198
  ...emitGuardLines(guards, "notFound();"),
5165
5199
  " return <Page {...props} />;",
5166
5200
  "}",
@@ -5500,7 +5534,7 @@ function planCompositionArtifacts(input) {
5500
5534
  const legacy = parseLegacyApiAdapter(pagePath, content);
5501
5535
  const plannedRoute = plannedRouteByPath.get(pagePath);
5502
5536
  const plannedEndpoint = plannedEndpointByPath.get(pagePath);
5503
- const isExactPlannedMatch = plannedRoute != null && marker != null && marker.kind === "route" && marker.owner === plannedRoute.owner && marker.nextRelativePath === plannedRoute.nextRelativePath || plannedEndpoint != null && (marker != null && marker.kind === "endpoint" && marker.owner === plannedEndpoint.owner && scopeLabel(marker.scope) === scopeLabel(plannedEndpoint.scope) || legacy != null) || plannedRoute != null && legacyRouteAdapterMatchesStub(pagePath, content, plannedRoute);
5537
+ const isExactPlannedMatch = plannedRoute != null && marker != null && marker.kind === "route" && marker.owner === plannedRoute.owner && marker.nextRelativePath === plannedRoute.nextRelativePath || plannedEndpoint != null && (endpointMarkerOwnsStub(marker, plannedEndpoint) || legacy != null) || plannedRoute != null && legacyRouteAdapterMatchesStub(pagePath, content, plannedRoute);
5504
5538
  if (isExactPlannedMatch) {
5505
5539
  continue;
5506
5540
  }
@@ -5643,9 +5677,7 @@ function planCompositionArtifacts(input) {
5643
5677
  }
5644
5678
  if (marker.kind === "endpoint") {
5645
5679
  const stub = endpointStubByPath.get(path5);
5646
- const ownerMatches = stub != null && marker.owner === stub.owner;
5647
- const scopeMatches = stub != null && scopeLabel(marker.scope) === scopeLabel(stub.scope);
5648
- if (!ownerMatches || !scopeMatches) {
5680
+ if (!endpointMarkerOwnsStub(marker, stub)) {
5649
5681
  return {
5650
5682
  ok: false,
5651
5683
  error: `[ModuleRail] composition artifacts: refusing to overwrite incongruent marker ownership at ${path5} (existing owner=${marker.owner} scope=${scopeLabel(marker.scope)} vs planned owner=${stub?.owner ?? "?"} scope=${stub ? scopeLabel(stub.scope) : "?"})`,
@@ -6288,39 +6320,24 @@ async function assertNoRootRoutesWhenMountsEmpty(context, moduleName) {
6288
6320
  );
6289
6321
  }
6290
6322
  }
6291
- async function assertNoEnhancementRoutes(context, moduleName) {
6292
- const enhancementsDir = `${MODULES_SANDBOX_DIR}/${moduleName}/enhancements`;
6293
- const enhancementEntries = await listImmediateEntries(context.sandbox, enhancementsDir, {
6294
- optional: true,
6295
- label: `enhancements for ${moduleName}`
6296
- });
6297
- for (const entry of enhancementEntries) {
6298
- if (entry.kind !== "dir") continue;
6299
- const sliceRoutes = `${enhancementsDir}/${entry.name}/routes`;
6300
- const sliceProbe = await context.sandbox.exec(
6301
- [
6302
- `p=${shellQuote(sliceRoutes)}`,
6303
- `if test -L "$p" || test -e "$p"; then exit 2; else exit 0; fi`
6304
- ].join("\n"),
6305
- { raiseOnError: false }
6306
- );
6307
- if (sliceProbe.exitCode === 2) {
6323
+ async function assertNoEnhancementRoutePaths(context, paths) {
6324
+ const kinds = await classifySandboxPathPresenceKindMany(context, paths, "enhancement-route-kind");
6325
+ for (const sliceRoutes of paths) {
6326
+ const kind = kinds.get(sliceRoutes);
6327
+ if (kind === "absent") continue;
6328
+ if (kind !== "unreadable" && kind !== void 0) {
6308
6329
  throw new Error(
6309
6330
  `[ModuleRail] composition artifacts: enhancement slice routes/ are forbidden at ${sliceRoutes}`
6310
6331
  );
6311
6332
  }
6312
- if (sliceProbe.exitCode !== 0) {
6313
- const detail = typeof sliceProbe.error === "string" && sliceProbe.error.trim() || sliceProbe.output.trim() || `exit ${sliceProbe.exitCode}`;
6314
- throw new Error(
6315
- `[ModuleRail] composition artifacts: cannot probe ${sliceRoutes}: ${redactSecrets(detail)}`
6316
- );
6317
- }
6333
+ throw new Error(
6334
+ `[ModuleRail] composition artifacts: cannot probe ${sliceRoutes}: entry-kind:${kind ?? "missing"}`
6335
+ );
6318
6336
  }
6319
6337
  }
6320
- async function discoverModuleRouteEntries(context, moduleName) {
6338
+ async function discoverRootRouteEntries(context, moduleName) {
6321
6339
  const modulePath = `${MODULES_SANDBOX_DIR}/${moduleName}`;
6322
6340
  const routesDir = `${modulePath}/routes`;
6323
- await assertNoEnhancementRoutes(context, moduleName);
6324
6341
  const quotedRoutes = shellQuote(routesDir);
6325
6342
  const listResult = await context.sandbox.exec(
6326
6343
  [
@@ -6774,7 +6791,8 @@ async function classifySandboxPathPresenceKindMany(context, paths, probeToken) {
6774
6791
  `# Leaf is missing. A missing intermediate parent means the path is simply`,
6775
6792
  `# absent (new nested writes). Only an existing-but-inaccessible parent`,
6776
6793
  `# is unreadable \u2014 never conflate ENOENT parents with EACCES.`,
6777
- `parent=$(dirname -- "$p")`,
6794
+ // Probe paths are nested repo-relative paths; do not spawn dirname per missing file.
6795
+ `parent=\${p%/*}`,
6778
6796
  `if test ! -e "$parent" && test ! -L "$parent"; then`,
6779
6797
  ` printf 'entry-kind:absent\\t%s\\n' "$p"`,
6780
6798
  `elif ls -ld -- "$parent" >/dev/null 2>&1; then`,
@@ -6958,6 +6976,9 @@ async function readConventionalEntryIfRealFile(path5, kind, presences, files) {
6958
6976
  case "initializeEnhancement":
6959
6977
  assertInitializeEnhancementExport(source, path5);
6960
6978
  break;
6979
+ case "initializeModule":
6980
+ assertInitializeModuleExport(source, path5);
6981
+ break;
6961
6982
  }
6962
6983
  return source;
6963
6984
  }
@@ -6994,7 +7015,11 @@ async function collectCompositionEntryFacts(context, registry) {
6994
7015
  for (const mod of registry.modules) {
6995
7016
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6996
7017
  localeDirs.push(`${modulePath}/i18n`);
6997
- conventionalPaths.push(`${modulePath}/contributions.ts`, `${modulePath}/slots.ts`);
7018
+ conventionalPaths.push(
7019
+ `${modulePath}/contributions.ts`,
7020
+ `${modulePath}/slots.ts`,
7021
+ `${modulePath}/initialize.server.ts`
7022
+ );
6998
7023
  for (const enh of mod.enhancements) {
6999
7024
  const peerDir = `${modulePath}/enhancements/${enh.peer}`;
7000
7025
  localeDirs.push(`${peerDir}/i18n`);
@@ -7082,6 +7107,17 @@ async function collectCompositionEntryFacts(context, registry) {
7082
7107
  }
7083
7108
  }
7084
7109
  const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7110
+ const hasRootInitializeServer = await readConventionalEntryIfRealFile(
7111
+ `${modulePath}/initialize.server.ts`,
7112
+ "initializeModule",
7113
+ conventionalPresences,
7114
+ files
7115
+ ) != null;
7116
+ if (hasRootInitializeServer) {
7117
+ console.log(
7118
+ `[ModuleRail] runId=${context.runId} module=${mod.name} has root initialize.server.ts (initializeModule)`
7119
+ );
7120
+ }
7085
7121
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7086
7122
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7087
7123
  throw new Error(
@@ -7122,6 +7158,7 @@ async function collectCompositionEntryFacts(context, registry) {
7122
7158
  hasRootContributions,
7123
7159
  hasRootSlots,
7124
7160
  hasServerRegistrations,
7161
+ hasRootInitializeServer,
7125
7162
  rootLocales,
7126
7163
  enhancements
7127
7164
  });
@@ -7300,14 +7337,21 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7300
7337
  manifestsByName
7301
7338
  );
7302
7339
  const routeEntriesByOwner = {};
7340
+ await assertNoEnhancementRoutePaths(
7341
+ context,
7342
+ moduleRuntimeFacts.flatMap(
7343
+ ({ moduleName, onDiskEnhancementDirs }) => onDiskEnhancementDirs.map(
7344
+ (peer) => `${MODULES_SANDBOX_DIR}/${moduleName}/enhancements/${peer}/routes`
7345
+ )
7346
+ )
7347
+ );
7303
7348
  for (const moduleName of composedNames) {
7304
7349
  const manifest = manifestsByName[moduleName];
7305
- await assertNoEnhancementRoutes(context, moduleName);
7306
7350
  if (manifest.mounts.length === 0) {
7307
7351
  await assertNoRootRoutesWhenMountsEmpty(context, moduleName);
7308
7352
  routeEntriesByOwner[moduleName] = [];
7309
7353
  } else {
7310
- routeEntriesByOwner[moduleName] = await discoverModuleRouteEntries(context, moduleName);
7354
+ routeEntriesByOwner[moduleName] = await discoverRootRouteEntries(context, moduleName);
7311
7355
  }
7312
7356
  }
7313
7357
  const existingGeneratedPaths = await listExistingGeneratedStubPaths(context);
package/dist/index.js CHANGED
@@ -2097,6 +2097,7 @@ var JSON_SECRET_PAIR = new RegExp(
2097
2097
  "g"
2098
2098
  );
2099
2099
  var SECRET_QUOTE_OPENER = new RegExp(String.raw`\b${SECRET_NAME}\s*=\s*(['"])`, "g");
2100
+ var SHELL_QUOTE_CONTINUATION = String.raw`\''`;
2100
2101
  function redactSecrets(text) {
2101
2102
  return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(SECRET_ASSIGNMENT, "$1=***").replace(JSON_SECRET_PAIR, '"$1"$2"***"').replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
2102
2103
  }
@@ -2388,10 +2389,12 @@ var modulePermissionClaimSchema = import_zod2.z.object({
2388
2389
  /** Human label for the deployment auth config row. */
2389
2390
  description: descriptionSchema,
2390
2391
  /**
2391
- * The default organization roles that hold this key when the platform
2392
- * first registers it on an app (a Blueprint fork, a Module install). Admin
2393
- * holds every key without being listed; External Viewer never holds one.
2394
- * Changing a role's grants afterwards is the organization's business.
2392
+ * The standard organization roles that hold this key. The platform sets them on every app
2393
+ * (a Blueprint fork, a Module install or uninstall, every Blueprint update), so this list is
2394
+ * the truth: a role added here gains the key, a role removed loses it, and nobody can edit a
2395
+ * standard role's grants on a Module key. Admin holds every key without being listed;
2396
+ * External Viewer never holds one. An organization that wants different access duplicates the
2397
+ * standard role and edits the copy.
2395
2398
  */
2396
2399
  roles: import_zod2.z.array(import_zod2.z.enum(MODULE_PERMISSION_DEFAULT_ROLES)).optional()
2397
2400
  }).strict();
@@ -3689,17 +3692,20 @@ var import_typescript3 = __toESM(require("typescript"), 1);
3689
3692
  var CONVENTIONAL_ENTRY_EXPORT_NAME = {
3690
3693
  contributions: "contributions",
3691
3694
  slotCatalogs: "slotCatalogs",
3692
- initializeEnhancement: "initializeEnhancement"
3695
+ initializeEnhancement: "initializeEnhancement",
3696
+ initializeModule: "initializeModule"
3693
3697
  };
3694
3698
  var CONVENTIONAL_ENTRY_DECLARATION_KIND = {
3695
3699
  contributions: "variable",
3696
3700
  slotCatalogs: "variable",
3697
- initializeEnhancement: "function"
3701
+ initializeEnhancement: "function",
3702
+ initializeModule: "function"
3698
3703
  };
3699
3704
  var CONVENTIONAL_ENTRY_FILENAME = {
3700
3705
  contributions: "contributions.ts",
3701
3706
  slotCatalogs: "slots.ts",
3702
- initializeEnhancement: "initialize.server.ts"
3707
+ initializeEnhancement: "initialize.server.ts",
3708
+ initializeModule: "initialize.server.ts"
3703
3709
  };
3704
3710
  function hasModifierKind(modifiers, kind) {
3705
3711
  return Boolean(modifiers?.some((m) => m.kind === kind));
@@ -3798,7 +3804,7 @@ function analyzeConventionalEntryExportWithTs(typescript, sourceText, kind, file
3798
3804
  }
3799
3805
  if (!isZeroArgumentInvocable(typescript, stmt.parameters)) {
3800
3806
  errors.push(
3801
- `"${entryName}" must be invocable with zero arguments (generated caller uses initializeEnhancement()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3807
+ `"${entryName}" must be invocable with zero arguments (generated caller uses ${entryName}()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3802
3808
  );
3803
3809
  continue;
3804
3810
  }
@@ -4160,6 +4166,9 @@ function assertSlotCatalogsExport(source, pathLabel) {
4160
4166
  function assertInitializeEnhancementExport(source, pathLabel) {
4161
4167
  assertConventionalEntryExport(source, pathLabel, "initializeEnhancement");
4162
4168
  }
4169
+ function assertInitializeModuleExport(source, pathLabel) {
4170
+ assertConventionalEntryExport(source, pathLabel, "initializeModule");
4171
+ }
4163
4172
  function assertClientSafeConventionalEntryImports(source, pathLabel, kind) {
4164
4173
  const { analysisErrors } = analyzeClientSafeEntryImports(
4165
4174
  source,
@@ -4679,30 +4688,47 @@ function deriveModuleLocaleLayers(registry, facts) {
4679
4688
  }
4680
4689
  return layers;
4681
4690
  }
4682
- function emitModuleInitServerGenTs(input) {
4691
+ function moduleInitializerCalls(registry, facts) {
4692
+ const installed = new Set(registry.modules.map((m) => m.name));
4693
+ return facts.filter((f) => f.hasRootInitializeServer && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames).map((name, index) => {
4694
+ if (!isValidModuleName(name)) {
4695
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4696
+ }
4697
+ return {
4698
+ exportName: "initializeModule",
4699
+ guard: [name],
4700
+ ident: `initializeModule${index}`,
4701
+ importPath: `@/modules/${name}/initialize.server`
4702
+ };
4703
+ });
4704
+ }
4705
+ function enhancementInitializerCalls(registry, facts) {
4683
4706
  const calls = [];
4684
- const installed = new Set(input.registry.modules.map((m) => m.name));
4685
- let initializerIndex = 0;
4686
- for (const facts of [...input.facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4687
- if (!installed.has(facts.moduleName)) continue;
4688
- const peers = activePeers(input.registry, facts.moduleName);
4689
- for (const enh of [...facts.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4707
+ const installed = new Set(registry.modules.map((m) => m.name));
4708
+ for (const f of [...facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4709
+ if (!installed.has(f.moduleName)) continue;
4710
+ const peers = activePeers(registry, f.moduleName);
4711
+ for (const enh of [...f.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4690
4712
  if (!enh.active || !peers.has(enh.peer)) continue;
4691
4713
  if (!enh.hasInitializeServer) continue;
4692
- const ident = `initializeEnhancement${initializerIndex++}`;
4693
4714
  calls.push({
4694
- owner: facts.moduleName,
4695
- peer: enh.peer,
4696
- ident,
4697
- importPath: `@/modules/${facts.moduleName}/enhancements/${enh.peer}/initialize.server`
4715
+ exportName: "initializeEnhancement",
4716
+ guard: [f.moduleName, enh.peer],
4717
+ ident: `initializeEnhancement${calls.length}`,
4718
+ importPath: `@/modules/${f.moduleName}/enhancements/${enh.peer}/initialize.server`
4698
4719
  });
4699
4720
  }
4700
4721
  }
4701
- const importLines = calls.map(
4702
- (c) => `import { initializeEnhancement as ${c.ident} } from ${JSON.stringify(c.importPath)};`
4703
- );
4722
+ return calls;
4723
+ }
4724
+ function emitModuleInitServerGenTs(input) {
4725
+ const moduleCalls = moduleInitializerCalls(input.registry, input.facts);
4726
+ const enhancementCalls = enhancementInitializerCalls(input.registry, input.facts);
4727
+ const calls = [...moduleCalls, ...enhancementCalls];
4728
+ const importFor = (c) => `import { ${c.exportName} as ${c.ident} } from ${JSON.stringify(c.importPath)};`;
4729
+ const importLines = moduleCalls.length === 0 ? calls.map(importFor) : [...calls].sort((a, b) => compareImportSpecifiers(a.importPath, b.importPath)).map(importFor);
4704
4730
  const callLines = calls.length === 0 ? [" // Empty composition \u2014 no active enhancement initializers."] : calls.map(
4705
- (c) => ` if (enabled(${JSON.stringify(c.owner)}) && enabled(${JSON.stringify(c.peer)})) await ${c.ident}();`
4731
+ (c) => ` if (${c.guard.map((name) => `enabled(${JSON.stringify(name)})`).join(" && ")}) await ${c.ident}();`
4706
4732
  );
4707
4733
  return [
4708
4734
  formatInitMarker(),
@@ -4814,6 +4840,9 @@ function enhancementImport(owner, peer) {
4814
4840
  function scopeLabel(scope) {
4815
4841
  return scope.kind === "root" ? "root" : `enhancement:${scope.peer}`;
4816
4842
  }
4843
+ function endpointMarkerOwnsStub(marker, stub) {
4844
+ return stub != null && marker?.kind === "endpoint" && marker.owner === stub.owner;
4845
+ }
4817
4846
  function formatEndpointMarker(owner, scope) {
4818
4847
  return `// ${MODULE_STUB_MARKER} owner=${owner} scope=${scopeLabel(scope)}`;
4819
4848
  }
@@ -5152,6 +5181,7 @@ function buildRouteStubContent(input) {
5152
5181
  'import { notFound } from "next/navigation";',
5153
5182
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5154
5183
  "",
5184
+ 'import { initializeActiveEnhancements } from "@/module-init.server.gen";',
5155
5185
  'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5156
5186
  `import Page from ${emitTsString(input.importModule)};`
5157
5187
  ];
@@ -5160,6 +5190,7 @@ function buildRouteStubContent(input) {
5160
5190
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5161
5191
  "",
5162
5192
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5193
+ " await initializeActiveEnhancements();",
5163
5194
  ...emitGuardLines(guards, "return {};"),
5164
5195
  " return sourceGenerateMetadata(...args);",
5165
5196
  "}"
@@ -5170,7 +5201,10 @@ function buildRouteStubContent(input) {
5170
5201
  "type PageProps =",
5171
5202
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5172
5203
  "",
5173
- "export default function ModulePage(props: PageProps) {",
5204
+ // Module and enhancement initializers run in this route's own module graph
5205
+ // (memoized), so a cold server render sees their registrations.
5206
+ "export default async function ModulePage(props: PageProps) {",
5207
+ " await initializeActiveEnhancements();",
5174
5208
  ...emitGuardLines(guards, "notFound();"),
5175
5209
  " return <Page {...props} />;",
5176
5210
  "}",
@@ -5510,7 +5544,7 @@ function planCompositionArtifacts(input) {
5510
5544
  const legacy = parseLegacyApiAdapter(pagePath, content);
5511
5545
  const plannedRoute = plannedRouteByPath.get(pagePath);
5512
5546
  const plannedEndpoint = plannedEndpointByPath.get(pagePath);
5513
- const isExactPlannedMatch = plannedRoute != null && marker != null && marker.kind === "route" && marker.owner === plannedRoute.owner && marker.nextRelativePath === plannedRoute.nextRelativePath || plannedEndpoint != null && (marker != null && marker.kind === "endpoint" && marker.owner === plannedEndpoint.owner && scopeLabel(marker.scope) === scopeLabel(plannedEndpoint.scope) || legacy != null) || plannedRoute != null && legacyRouteAdapterMatchesStub(pagePath, content, plannedRoute);
5547
+ const isExactPlannedMatch = plannedRoute != null && marker != null && marker.kind === "route" && marker.owner === plannedRoute.owner && marker.nextRelativePath === plannedRoute.nextRelativePath || plannedEndpoint != null && (endpointMarkerOwnsStub(marker, plannedEndpoint) || legacy != null) || plannedRoute != null && legacyRouteAdapterMatchesStub(pagePath, content, plannedRoute);
5514
5548
  if (isExactPlannedMatch) {
5515
5549
  continue;
5516
5550
  }
@@ -5653,9 +5687,7 @@ function planCompositionArtifacts(input) {
5653
5687
  }
5654
5688
  if (marker.kind === "endpoint") {
5655
5689
  const stub = endpointStubByPath.get(path4);
5656
- const ownerMatches = stub != null && marker.owner === stub.owner;
5657
- const scopeMatches = stub != null && scopeLabel(marker.scope) === scopeLabel(stub.scope);
5658
- if (!ownerMatches || !scopeMatches) {
5690
+ if (!endpointMarkerOwnsStub(marker, stub)) {
5659
5691
  return {
5660
5692
  ok: false,
5661
5693
  error: `[ModuleRail] composition artifacts: refusing to overwrite incongruent marker ownership at ${path4} (existing owner=${marker.owner} scope=${scopeLabel(marker.scope)} vs planned owner=${stub?.owner ?? "?"} scope=${stub ? scopeLabel(stub.scope) : "?"})`,
@@ -6298,39 +6330,24 @@ async function assertNoRootRoutesWhenMountsEmpty(context, moduleName) {
6298
6330
  );
6299
6331
  }
6300
6332
  }
6301
- async function assertNoEnhancementRoutes(context, moduleName) {
6302
- const enhancementsDir = `${MODULES_SANDBOX_DIR}/${moduleName}/enhancements`;
6303
- const enhancementEntries = await listImmediateEntries(context.sandbox, enhancementsDir, {
6304
- optional: true,
6305
- label: `enhancements for ${moduleName}`
6306
- });
6307
- for (const entry of enhancementEntries) {
6308
- if (entry.kind !== "dir") continue;
6309
- const sliceRoutes = `${enhancementsDir}/${entry.name}/routes`;
6310
- const sliceProbe = await context.sandbox.exec(
6311
- [
6312
- `p=${shellQuote(sliceRoutes)}`,
6313
- `if test -L "$p" || test -e "$p"; then exit 2; else exit 0; fi`
6314
- ].join("\n"),
6315
- { raiseOnError: false }
6316
- );
6317
- if (sliceProbe.exitCode === 2) {
6333
+ async function assertNoEnhancementRoutePaths(context, paths) {
6334
+ const kinds = await classifySandboxPathPresenceKindMany(context, paths, "enhancement-route-kind");
6335
+ for (const sliceRoutes of paths) {
6336
+ const kind = kinds.get(sliceRoutes);
6337
+ if (kind === "absent") continue;
6338
+ if (kind !== "unreadable" && kind !== void 0) {
6318
6339
  throw new Error(
6319
6340
  `[ModuleRail] composition artifacts: enhancement slice routes/ are forbidden at ${sliceRoutes}`
6320
6341
  );
6321
6342
  }
6322
- if (sliceProbe.exitCode !== 0) {
6323
- const detail = typeof sliceProbe.error === "string" && sliceProbe.error.trim() || sliceProbe.output.trim() || `exit ${sliceProbe.exitCode}`;
6324
- throw new Error(
6325
- `[ModuleRail] composition artifacts: cannot probe ${sliceRoutes}: ${redactSecrets(detail)}`
6326
- );
6327
- }
6343
+ throw new Error(
6344
+ `[ModuleRail] composition artifacts: cannot probe ${sliceRoutes}: entry-kind:${kind ?? "missing"}`
6345
+ );
6328
6346
  }
6329
6347
  }
6330
- async function discoverModuleRouteEntries(context, moduleName) {
6348
+ async function discoverRootRouteEntries(context, moduleName) {
6331
6349
  const modulePath = `${MODULES_SANDBOX_DIR}/${moduleName}`;
6332
6350
  const routesDir = `${modulePath}/routes`;
6333
- await assertNoEnhancementRoutes(context, moduleName);
6334
6351
  const quotedRoutes = shellQuote(routesDir);
6335
6352
  const listResult = await context.sandbox.exec(
6336
6353
  [
@@ -6784,7 +6801,8 @@ async function classifySandboxPathPresenceKindMany(context, paths, probeToken) {
6784
6801
  `# Leaf is missing. A missing intermediate parent means the path is simply`,
6785
6802
  `# absent (new nested writes). Only an existing-but-inaccessible parent`,
6786
6803
  `# is unreadable \u2014 never conflate ENOENT parents with EACCES.`,
6787
- `parent=$(dirname -- "$p")`,
6804
+ // Probe paths are nested repo-relative paths; do not spawn dirname per missing file.
6805
+ `parent=\${p%/*}`,
6788
6806
  `if test ! -e "$parent" && test ! -L "$parent"; then`,
6789
6807
  ` printf 'entry-kind:absent\\t%s\\n' "$p"`,
6790
6808
  `elif ls -ld -- "$parent" >/dev/null 2>&1; then`,
@@ -6968,6 +6986,9 @@ async function readConventionalEntryIfRealFile(path4, kind, presences, files) {
6968
6986
  case "initializeEnhancement":
6969
6987
  assertInitializeEnhancementExport(source, path4);
6970
6988
  break;
6989
+ case "initializeModule":
6990
+ assertInitializeModuleExport(source, path4);
6991
+ break;
6971
6992
  }
6972
6993
  return source;
6973
6994
  }
@@ -7004,7 +7025,11 @@ async function collectCompositionEntryFacts(context, registry) {
7004
7025
  for (const mod of registry.modules) {
7005
7026
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
7006
7027
  localeDirs.push(`${modulePath}/i18n`);
7007
- conventionalPaths.push(`${modulePath}/contributions.ts`, `${modulePath}/slots.ts`);
7028
+ conventionalPaths.push(
7029
+ `${modulePath}/contributions.ts`,
7030
+ `${modulePath}/slots.ts`,
7031
+ `${modulePath}/initialize.server.ts`
7032
+ );
7008
7033
  for (const enh of mod.enhancements) {
7009
7034
  const peerDir = `${modulePath}/enhancements/${enh.peer}`;
7010
7035
  localeDirs.push(`${peerDir}/i18n`);
@@ -7092,6 +7117,17 @@ async function collectCompositionEntryFacts(context, registry) {
7092
7117
  }
7093
7118
  }
7094
7119
  const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7120
+ const hasRootInitializeServer = await readConventionalEntryIfRealFile(
7121
+ `${modulePath}/initialize.server.ts`,
7122
+ "initializeModule",
7123
+ conventionalPresences,
7124
+ files
7125
+ ) != null;
7126
+ if (hasRootInitializeServer) {
7127
+ console.log(
7128
+ `[ModuleRail] runId=${context.runId} module=${mod.name} has root initialize.server.ts (initializeModule)`
7129
+ );
7130
+ }
7095
7131
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7096
7132
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7097
7133
  throw new Error(
@@ -7132,6 +7168,7 @@ async function collectCompositionEntryFacts(context, registry) {
7132
7168
  hasRootContributions,
7133
7169
  hasRootSlots,
7134
7170
  hasServerRegistrations,
7171
+ hasRootInitializeServer,
7135
7172
  rootLocales,
7136
7173
  enhancements
7137
7174
  });
@@ -7310,14 +7347,21 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7310
7347
  manifestsByName
7311
7348
  );
7312
7349
  const routeEntriesByOwner = {};
7350
+ await assertNoEnhancementRoutePaths(
7351
+ context,
7352
+ moduleRuntimeFacts.flatMap(
7353
+ ({ moduleName, onDiskEnhancementDirs }) => onDiskEnhancementDirs.map(
7354
+ (peer) => `${MODULES_SANDBOX_DIR}/${moduleName}/enhancements/${peer}/routes`
7355
+ )
7356
+ )
7357
+ );
7313
7358
  for (const moduleName of composedNames) {
7314
7359
  const manifest = manifestsByName[moduleName];
7315
- await assertNoEnhancementRoutes(context, moduleName);
7316
7360
  if (manifest.mounts.length === 0) {
7317
7361
  await assertNoRootRoutesWhenMountsEmpty(context, moduleName);
7318
7362
  routeEntriesByOwner[moduleName] = [];
7319
7363
  } else {
7320
- routeEntriesByOwner[moduleName] = await discoverModuleRouteEntries(context, moduleName);
7364
+ routeEntriesByOwner[moduleName] = await discoverRootRouteEntries(context, moduleName);
7321
7365
  }
7322
7366
  }
7323
7367
  const existingGeneratedPaths = await listExistingGeneratedStubPaths(context);
package/dist/index.mjs CHANGED
@@ -2078,6 +2078,7 @@ var JSON_SECRET_PAIR = new RegExp(
2078
2078
  "g"
2079
2079
  );
2080
2080
  var SECRET_QUOTE_OPENER = new RegExp(String.raw`\b${SECRET_NAME}\s*=\s*(['"])`, "g");
2081
+ var SHELL_QUOTE_CONTINUATION = String.raw`\''`;
2081
2082
  function redactSecrets(text) {
2082
2083
  return text.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@").replace(/https:\/\/(?!x-access-token:)[^/\s:@]+:[^/\s@]+@/gi, "https://***:***@").replace(/\b(gh[pousr]_)[A-Za-z0-9]+/g, "$1***").replace(/\b(github_pat_)[A-Za-z0-9_]+/g, "$1***").replace(/\b(postgres(?:ql)?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1$2:***@").replace(/\b(npm_[A-Za-z0-9]+)/g, "npm_***").replace(/(_authToken=)([^\s&]+)/gi, "$1***").replace(SECRET_ASSIGNMENT, "$1=***").replace(JSON_SECRET_PAIR, '"$1"$2"***"').replace(/\b(Authorization:\s*(?:Bearer|Basic)\s+)\S+/gi, "$1***");
2083
2084
  }
@@ -2369,10 +2370,12 @@ var modulePermissionClaimSchema = z2.object({
2369
2370
  /** Human label for the deployment auth config row. */
2370
2371
  description: descriptionSchema,
2371
2372
  /**
2372
- * The default organization roles that hold this key when the platform
2373
- * first registers it on an app (a Blueprint fork, a Module install). Admin
2374
- * holds every key without being listed; External Viewer never holds one.
2375
- * Changing a role's grants afterwards is the organization's business.
2373
+ * The standard organization roles that hold this key. The platform sets them on every app
2374
+ * (a Blueprint fork, a Module install or uninstall, every Blueprint update), so this list is
2375
+ * the truth: a role added here gains the key, a role removed loses it, and nobody can edit a
2376
+ * standard role's grants on a Module key. Admin holds every key without being listed;
2377
+ * External Viewer never holds one. An organization that wants different access duplicates the
2378
+ * standard role and edits the copy.
2376
2379
  */
2377
2380
  roles: z2.array(z2.enum(MODULE_PERMISSION_DEFAULT_ROLES)).optional()
2378
2381
  }).strict();
@@ -3670,17 +3673,20 @@ import ts3 from "typescript";
3670
3673
  var CONVENTIONAL_ENTRY_EXPORT_NAME = {
3671
3674
  contributions: "contributions",
3672
3675
  slotCatalogs: "slotCatalogs",
3673
- initializeEnhancement: "initializeEnhancement"
3676
+ initializeEnhancement: "initializeEnhancement",
3677
+ initializeModule: "initializeModule"
3674
3678
  };
3675
3679
  var CONVENTIONAL_ENTRY_DECLARATION_KIND = {
3676
3680
  contributions: "variable",
3677
3681
  slotCatalogs: "variable",
3678
- initializeEnhancement: "function"
3682
+ initializeEnhancement: "function",
3683
+ initializeModule: "function"
3679
3684
  };
3680
3685
  var CONVENTIONAL_ENTRY_FILENAME = {
3681
3686
  contributions: "contributions.ts",
3682
3687
  slotCatalogs: "slots.ts",
3683
- initializeEnhancement: "initialize.server.ts"
3688
+ initializeEnhancement: "initialize.server.ts",
3689
+ initializeModule: "initialize.server.ts"
3684
3690
  };
3685
3691
  function hasModifierKind(modifiers, kind) {
3686
3692
  return Boolean(modifiers?.some((m) => m.kind === kind));
@@ -3779,7 +3785,7 @@ function analyzeConventionalEntryExportWithTs(typescript, sourceText, kind, file
3779
3785
  }
3780
3786
  if (!isZeroArgumentInvocable(typescript, stmt.parameters)) {
3781
3787
  errors.push(
3782
- `"${entryName}" must be invocable with zero arguments (generated caller uses initializeEnhancement()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3788
+ `"${entryName}" must be invocable with zero arguments (generated caller uses ${entryName}()); required parameters are forbidden \u2014 use optional, default, rest, or a this-parameter only`
3783
3789
  );
3784
3790
  continue;
3785
3791
  }
@@ -4141,6 +4147,9 @@ function assertSlotCatalogsExport(source, pathLabel) {
4141
4147
  function assertInitializeEnhancementExport(source, pathLabel) {
4142
4148
  assertConventionalEntryExport(source, pathLabel, "initializeEnhancement");
4143
4149
  }
4150
+ function assertInitializeModuleExport(source, pathLabel) {
4151
+ assertConventionalEntryExport(source, pathLabel, "initializeModule");
4152
+ }
4144
4153
  function assertClientSafeConventionalEntryImports(source, pathLabel, kind) {
4145
4154
  const { analysisErrors } = analyzeClientSafeEntryImports(
4146
4155
  source,
@@ -4660,30 +4669,47 @@ function deriveModuleLocaleLayers(registry, facts) {
4660
4669
  }
4661
4670
  return layers;
4662
4671
  }
4663
- function emitModuleInitServerGenTs(input) {
4672
+ function moduleInitializerCalls(registry, facts) {
4673
+ const installed = new Set(registry.modules.map((m) => m.name));
4674
+ return facts.filter((f) => f.hasRootInitializeServer && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames).map((name, index) => {
4675
+ if (!isValidModuleName(name)) {
4676
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4677
+ }
4678
+ return {
4679
+ exportName: "initializeModule",
4680
+ guard: [name],
4681
+ ident: `initializeModule${index}`,
4682
+ importPath: `@/modules/${name}/initialize.server`
4683
+ };
4684
+ });
4685
+ }
4686
+ function enhancementInitializerCalls(registry, facts) {
4664
4687
  const calls = [];
4665
- const installed = new Set(input.registry.modules.map((m) => m.name));
4666
- let initializerIndex = 0;
4667
- for (const facts of [...input.facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4668
- if (!installed.has(facts.moduleName)) continue;
4669
- const peers = activePeers(input.registry, facts.moduleName);
4670
- for (const enh of [...facts.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4688
+ const installed = new Set(registry.modules.map((m) => m.name));
4689
+ for (const f of [...facts].sort((a, b) => compareNames(a.moduleName, b.moduleName))) {
4690
+ if (!installed.has(f.moduleName)) continue;
4691
+ const peers = activePeers(registry, f.moduleName);
4692
+ for (const enh of [...f.enhancements].sort((a, b) => compareNames(a.peer, b.peer))) {
4671
4693
  if (!enh.active || !peers.has(enh.peer)) continue;
4672
4694
  if (!enh.hasInitializeServer) continue;
4673
- const ident = `initializeEnhancement${initializerIndex++}`;
4674
4695
  calls.push({
4675
- owner: facts.moduleName,
4676
- peer: enh.peer,
4677
- ident,
4678
- importPath: `@/modules/${facts.moduleName}/enhancements/${enh.peer}/initialize.server`
4696
+ exportName: "initializeEnhancement",
4697
+ guard: [f.moduleName, enh.peer],
4698
+ ident: `initializeEnhancement${calls.length}`,
4699
+ importPath: `@/modules/${f.moduleName}/enhancements/${enh.peer}/initialize.server`
4679
4700
  });
4680
4701
  }
4681
4702
  }
4682
- const importLines = calls.map(
4683
- (c) => `import { initializeEnhancement as ${c.ident} } from ${JSON.stringify(c.importPath)};`
4684
- );
4703
+ return calls;
4704
+ }
4705
+ function emitModuleInitServerGenTs(input) {
4706
+ const moduleCalls = moduleInitializerCalls(input.registry, input.facts);
4707
+ const enhancementCalls = enhancementInitializerCalls(input.registry, input.facts);
4708
+ const calls = [...moduleCalls, ...enhancementCalls];
4709
+ const importFor = (c) => `import { ${c.exportName} as ${c.ident} } from ${JSON.stringify(c.importPath)};`;
4710
+ const importLines = moduleCalls.length === 0 ? calls.map(importFor) : [...calls].sort((a, b) => compareImportSpecifiers(a.importPath, b.importPath)).map(importFor);
4685
4711
  const callLines = calls.length === 0 ? [" // Empty composition \u2014 no active enhancement initializers."] : calls.map(
4686
- (c) => ` if (enabled(${JSON.stringify(c.owner)}) && enabled(${JSON.stringify(c.peer)})) await ${c.ident}();`
4712
+ (c) => ` if (${c.guard.map((name) => `enabled(${JSON.stringify(name)})`).join(" && ")}) await ${c.ident}();`
4687
4713
  );
4688
4714
  return [
4689
4715
  formatInitMarker(),
@@ -4795,6 +4821,9 @@ function enhancementImport(owner, peer) {
4795
4821
  function scopeLabel(scope) {
4796
4822
  return scope.kind === "root" ? "root" : `enhancement:${scope.peer}`;
4797
4823
  }
4824
+ function endpointMarkerOwnsStub(marker, stub) {
4825
+ return stub != null && marker?.kind === "endpoint" && marker.owner === stub.owner;
4826
+ }
4798
4827
  function formatEndpointMarker(owner, scope) {
4799
4828
  return `// ${MODULE_STUB_MARKER} owner=${owner} scope=${scopeLabel(scope)}`;
4800
4829
  }
@@ -5133,6 +5162,7 @@ function buildRouteStubContent(input) {
5133
5162
  'import { notFound } from "next/navigation";',
5134
5163
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5135
5164
  "",
5165
+ 'import { initializeActiveEnhancements } from "@/module-init.server.gen";',
5136
5166
  'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5137
5167
  `import Page from ${emitTsString(input.importModule)};`
5138
5168
  ];
@@ -5141,6 +5171,7 @@ function buildRouteStubContent(input) {
5141
5171
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5142
5172
  "",
5143
5173
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5174
+ " await initializeActiveEnhancements();",
5144
5175
  ...emitGuardLines(guards, "return {};"),
5145
5176
  " return sourceGenerateMetadata(...args);",
5146
5177
  "}"
@@ -5151,7 +5182,10 @@ function buildRouteStubContent(input) {
5151
5182
  "type PageProps =",
5152
5183
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5153
5184
  "",
5154
- "export default function ModulePage(props: PageProps) {",
5185
+ // Module and enhancement initializers run in this route's own module graph
5186
+ // (memoized), so a cold server render sees their registrations.
5187
+ "export default async function ModulePage(props: PageProps) {",
5188
+ " await initializeActiveEnhancements();",
5155
5189
  ...emitGuardLines(guards, "notFound();"),
5156
5190
  " return <Page {...props} />;",
5157
5191
  "}",
@@ -5491,7 +5525,7 @@ function planCompositionArtifacts(input) {
5491
5525
  const legacy = parseLegacyApiAdapter(pagePath, content);
5492
5526
  const plannedRoute = plannedRouteByPath.get(pagePath);
5493
5527
  const plannedEndpoint = plannedEndpointByPath.get(pagePath);
5494
- const isExactPlannedMatch = plannedRoute != null && marker != null && marker.kind === "route" && marker.owner === plannedRoute.owner && marker.nextRelativePath === plannedRoute.nextRelativePath || plannedEndpoint != null && (marker != null && marker.kind === "endpoint" && marker.owner === plannedEndpoint.owner && scopeLabel(marker.scope) === scopeLabel(plannedEndpoint.scope) || legacy != null) || plannedRoute != null && legacyRouteAdapterMatchesStub(pagePath, content, plannedRoute);
5528
+ const isExactPlannedMatch = plannedRoute != null && marker != null && marker.kind === "route" && marker.owner === plannedRoute.owner && marker.nextRelativePath === plannedRoute.nextRelativePath || plannedEndpoint != null && (endpointMarkerOwnsStub(marker, plannedEndpoint) || legacy != null) || plannedRoute != null && legacyRouteAdapterMatchesStub(pagePath, content, plannedRoute);
5495
5529
  if (isExactPlannedMatch) {
5496
5530
  continue;
5497
5531
  }
@@ -5634,9 +5668,7 @@ function planCompositionArtifacts(input) {
5634
5668
  }
5635
5669
  if (marker.kind === "endpoint") {
5636
5670
  const stub = endpointStubByPath.get(path4);
5637
- const ownerMatches = stub != null && marker.owner === stub.owner;
5638
- const scopeMatches = stub != null && scopeLabel(marker.scope) === scopeLabel(stub.scope);
5639
- if (!ownerMatches || !scopeMatches) {
5671
+ if (!endpointMarkerOwnsStub(marker, stub)) {
5640
5672
  return {
5641
5673
  ok: false,
5642
5674
  error: `[ModuleRail] composition artifacts: refusing to overwrite incongruent marker ownership at ${path4} (existing owner=${marker.owner} scope=${scopeLabel(marker.scope)} vs planned owner=${stub?.owner ?? "?"} scope=${stub ? scopeLabel(stub.scope) : "?"})`,
@@ -6279,39 +6311,24 @@ async function assertNoRootRoutesWhenMountsEmpty(context, moduleName) {
6279
6311
  );
6280
6312
  }
6281
6313
  }
6282
- async function assertNoEnhancementRoutes(context, moduleName) {
6283
- const enhancementsDir = `${MODULES_SANDBOX_DIR}/${moduleName}/enhancements`;
6284
- const enhancementEntries = await listImmediateEntries(context.sandbox, enhancementsDir, {
6285
- optional: true,
6286
- label: `enhancements for ${moduleName}`
6287
- });
6288
- for (const entry of enhancementEntries) {
6289
- if (entry.kind !== "dir") continue;
6290
- const sliceRoutes = `${enhancementsDir}/${entry.name}/routes`;
6291
- const sliceProbe = await context.sandbox.exec(
6292
- [
6293
- `p=${shellQuote(sliceRoutes)}`,
6294
- `if test -L "$p" || test -e "$p"; then exit 2; else exit 0; fi`
6295
- ].join("\n"),
6296
- { raiseOnError: false }
6297
- );
6298
- if (sliceProbe.exitCode === 2) {
6314
+ async function assertNoEnhancementRoutePaths(context, paths) {
6315
+ const kinds = await classifySandboxPathPresenceKindMany(context, paths, "enhancement-route-kind");
6316
+ for (const sliceRoutes of paths) {
6317
+ const kind = kinds.get(sliceRoutes);
6318
+ if (kind === "absent") continue;
6319
+ if (kind !== "unreadable" && kind !== void 0) {
6299
6320
  throw new Error(
6300
6321
  `[ModuleRail] composition artifacts: enhancement slice routes/ are forbidden at ${sliceRoutes}`
6301
6322
  );
6302
6323
  }
6303
- if (sliceProbe.exitCode !== 0) {
6304
- const detail = typeof sliceProbe.error === "string" && sliceProbe.error.trim() || sliceProbe.output.trim() || `exit ${sliceProbe.exitCode}`;
6305
- throw new Error(
6306
- `[ModuleRail] composition artifacts: cannot probe ${sliceRoutes}: ${redactSecrets(detail)}`
6307
- );
6308
- }
6324
+ throw new Error(
6325
+ `[ModuleRail] composition artifacts: cannot probe ${sliceRoutes}: entry-kind:${kind ?? "missing"}`
6326
+ );
6309
6327
  }
6310
6328
  }
6311
- async function discoverModuleRouteEntries(context, moduleName) {
6329
+ async function discoverRootRouteEntries(context, moduleName) {
6312
6330
  const modulePath = `${MODULES_SANDBOX_DIR}/${moduleName}`;
6313
6331
  const routesDir = `${modulePath}/routes`;
6314
- await assertNoEnhancementRoutes(context, moduleName);
6315
6332
  const quotedRoutes = shellQuote(routesDir);
6316
6333
  const listResult = await context.sandbox.exec(
6317
6334
  [
@@ -6765,7 +6782,8 @@ async function classifySandboxPathPresenceKindMany(context, paths, probeToken) {
6765
6782
  `# Leaf is missing. A missing intermediate parent means the path is simply`,
6766
6783
  `# absent (new nested writes). Only an existing-but-inaccessible parent`,
6767
6784
  `# is unreadable \u2014 never conflate ENOENT parents with EACCES.`,
6768
- `parent=$(dirname -- "$p")`,
6785
+ // Probe paths are nested repo-relative paths; do not spawn dirname per missing file.
6786
+ `parent=\${p%/*}`,
6769
6787
  `if test ! -e "$parent" && test ! -L "$parent"; then`,
6770
6788
  ` printf 'entry-kind:absent\\t%s\\n' "$p"`,
6771
6789
  `elif ls -ld -- "$parent" >/dev/null 2>&1; then`,
@@ -6949,6 +6967,9 @@ async function readConventionalEntryIfRealFile(path4, kind, presences, files) {
6949
6967
  case "initializeEnhancement":
6950
6968
  assertInitializeEnhancementExport(source, path4);
6951
6969
  break;
6970
+ case "initializeModule":
6971
+ assertInitializeModuleExport(source, path4);
6972
+ break;
6952
6973
  }
6953
6974
  return source;
6954
6975
  }
@@ -6985,7 +7006,11 @@ async function collectCompositionEntryFacts(context, registry) {
6985
7006
  for (const mod of registry.modules) {
6986
7007
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6987
7008
  localeDirs.push(`${modulePath}/i18n`);
6988
- conventionalPaths.push(`${modulePath}/contributions.ts`, `${modulePath}/slots.ts`);
7009
+ conventionalPaths.push(
7010
+ `${modulePath}/contributions.ts`,
7011
+ `${modulePath}/slots.ts`,
7012
+ `${modulePath}/initialize.server.ts`
7013
+ );
6989
7014
  for (const enh of mod.enhancements) {
6990
7015
  const peerDir = `${modulePath}/enhancements/${enh.peer}`;
6991
7016
  localeDirs.push(`${peerDir}/i18n`);
@@ -7073,6 +7098,17 @@ async function collectCompositionEntryFacts(context, registry) {
7073
7098
  }
7074
7099
  }
7075
7100
  const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7101
+ const hasRootInitializeServer = await readConventionalEntryIfRealFile(
7102
+ `${modulePath}/initialize.server.ts`,
7103
+ "initializeModule",
7104
+ conventionalPresences,
7105
+ files
7106
+ ) != null;
7107
+ if (hasRootInitializeServer) {
7108
+ console.log(
7109
+ `[ModuleRail] runId=${context.runId} module=${mod.name} has root initialize.server.ts (initializeModule)`
7110
+ );
7111
+ }
7076
7112
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7077
7113
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7078
7114
  throw new Error(
@@ -7113,6 +7149,7 @@ async function collectCompositionEntryFacts(context, registry) {
7113
7149
  hasRootContributions,
7114
7150
  hasRootSlots,
7115
7151
  hasServerRegistrations,
7152
+ hasRootInitializeServer,
7116
7153
  rootLocales,
7117
7154
  enhancements
7118
7155
  });
@@ -7291,14 +7328,21 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7291
7328
  manifestsByName
7292
7329
  );
7293
7330
  const routeEntriesByOwner = {};
7331
+ await assertNoEnhancementRoutePaths(
7332
+ context,
7333
+ moduleRuntimeFacts.flatMap(
7334
+ ({ moduleName, onDiskEnhancementDirs }) => onDiskEnhancementDirs.map(
7335
+ (peer) => `${MODULES_SANDBOX_DIR}/${moduleName}/enhancements/${peer}/routes`
7336
+ )
7337
+ )
7338
+ );
7294
7339
  for (const moduleName of composedNames) {
7295
7340
  const manifest = manifestsByName[moduleName];
7296
- await assertNoEnhancementRoutes(context, moduleName);
7297
7341
  if (manifest.mounts.length === 0) {
7298
7342
  await assertNoRootRoutesWhenMountsEmpty(context, moduleName);
7299
7343
  routeEntriesByOwner[moduleName] = [];
7300
7344
  } else {
7301
- routeEntriesByOwner[moduleName] = await discoverModuleRouteEntries(context, moduleName);
7345
+ routeEntriesByOwner[moduleName] = await discoverRootRouteEntries(context, moduleName);
7302
7346
  }
7303
7347
  }
7304
7348
  const existingGeneratedPaths = await listExistingGeneratedStubPaths(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/compose",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Regenerates a Stardeck app's Module-rail artifacts — the six src/*.gen.ts registries and every route/endpoint stub — from src/modules/*/module.json",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -34,7 +34,7 @@
34
34
  "build": "rm -rf dist && tsup",
35
35
  "dev": "tsup --watch",
36
36
  "format": "prettier --write . && oxlint . --fix",
37
- "typecheck": "tsgo -p tsconfig.test.json",
37
+ "typecheck": "node ../../scripts/typecheck.mjs -p tsconfig.test.json",
38
38
  "lint": "oxlint src/",
39
39
  "test": "vitest run",
40
40
  "test:watch": "vitest"