@volter/world-runtime 2.0.54 → 3.0.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.
Files changed (121) hide show
  1. package/dist/known-external-services.json +5 -4
  2. package/dist/src/actions-runner-cli.d.ts +2 -0
  3. package/dist/src/actions-runner-cli.js +25 -0
  4. package/dist/src/actions-runner.d.ts +18 -0
  5. package/dist/src/actions-runner.js +281 -0
  6. package/dist/src/ancestry.js +1 -1
  7. package/dist/src/app-url.js +1 -1
  8. package/dist/src/backings.d.ts +29 -0
  9. package/dist/src/backings.js +45 -0
  10. package/dist/src/branch.js +3 -2
  11. package/dist/src/browser-proxy-cli.js +1 -1
  12. package/dist/src/catalog-index.d.ts +38 -0
  13. package/dist/src/catalog-index.js +37 -0
  14. package/dist/src/catalog.d.ts +16 -3
  15. package/dist/src/catalog.js +69 -26
  16. package/dist/src/changeset.d.ts +1 -1
  17. package/dist/src/changeset.js +9 -11
  18. package/dist/src/cli-args.d.ts +4 -0
  19. package/dist/src/cli-args.js +18 -0
  20. package/dist/src/cli-commands.d.ts +1 -0
  21. package/dist/src/cli-commands.js +1216 -0
  22. package/dist/src/cli.js +8 -1300
  23. package/dist/src/configs.js +1 -1
  24. package/dist/src/console-apart.d.ts +2 -2
  25. package/dist/src/console-apart.js +5 -5
  26. package/dist/src/consumers.js +1 -1
  27. package/dist/src/covers.d.ts +1 -1
  28. package/dist/src/covers.js +94 -30
  29. package/dist/src/env-output.d.ts +1 -1
  30. package/dist/src/env-output.js +2 -2
  31. package/dist/src/fixture-env.js +18 -26
  32. package/dist/src/host-cli.js +4 -2
  33. package/dist/src/host-fault-fixture.js +6 -0
  34. package/dist/src/host-ready.d.ts +8 -2
  35. package/dist/src/host-ready.js +16 -4
  36. package/dist/src/host-worker.js +6 -1
  37. package/dist/src/host.d.ts +2 -0
  38. package/dist/src/host.js +9 -1
  39. package/dist/src/index.d.ts +2 -2
  40. package/dist/src/index.js +2 -2
  41. package/dist/src/init.d.ts +33 -23
  42. package/dist/src/init.js +228 -96
  43. package/dist/src/lifecycle-record.js +2 -2
  44. package/dist/src/local-branches.js +1 -1
  45. package/dist/src/origin.d.ts +1 -1
  46. package/dist/src/origin.js +12 -10
  47. package/dist/src/pack-facts.d.ts +30 -6
  48. package/dist/src/pack-facts.js +13 -3
  49. package/dist/src/pglite-backing.js +2 -1
  50. package/dist/src/placeholder.d.ts +1 -1
  51. package/dist/src/placeholder.js +12 -4
  52. package/dist/src/project-inspect.d.ts +1 -1
  53. package/dist/src/project-inspect.js +2 -2
  54. package/dist/src/redirect-proxy.d.ts +5 -0
  55. package/dist/src/redirect-proxy.js +8 -1
  56. package/dist/src/redis-backing.js +4 -2
  57. package/dist/src/reflect.js +1 -1
  58. package/dist/src/root.d.ts +11 -33
  59. package/dist/src/root.js +28 -59
  60. package/dist/src/runtime-test-support.js +1 -1
  61. package/dist/src/runtime.d.ts +7 -1
  62. package/dist/src/runtime.js +317 -53
  63. package/dist/src/schema.d.ts +27 -5
  64. package/dist/src/schema.js +13 -12
  65. package/dist/src/served-world.d.ts +11 -35
  66. package/dist/src/served-world.js +54 -141
  67. package/dist/src/tail.js +1 -1
  68. package/dist/src/up-cli.d.ts +3 -0
  69. package/dist/src/up-cli.js +55 -0
  70. package/dist/src/websocket-relay.d.ts +1 -1
  71. package/dist/src/world-origins.d.ts +1 -1
  72. package/dist/src/world-origins.js +1 -1
  73. package/dist/src/world-view.d.ts +1 -1
  74. package/dist/src/world-view.js +2 -2
  75. package/known-external-services.json +5 -4
  76. package/package.json +11 -11
  77. package/src/actions-runner-cli.ts +24 -0
  78. package/src/actions-runner.ts +244 -0
  79. package/src/ancestry.ts +1 -1
  80. package/src/app-url.ts +1 -1
  81. package/src/backings.ts +55 -0
  82. package/src/branch.ts +3 -2
  83. package/src/browser-proxy-cli.ts +1 -1
  84. package/src/catalog-index.ts +44 -0
  85. package/src/catalog.ts +67 -24
  86. package/src/changeset.ts +10 -13
  87. package/src/cli-args.ts +17 -0
  88. package/src/cli-commands.ts +1070 -0
  89. package/src/cli.ts +9 -1139
  90. package/src/configs.ts +1 -1
  91. package/src/console-apart.ts +5 -5
  92. package/src/consumers.ts +1 -1
  93. package/src/covers.ts +83 -30
  94. package/src/env-output.ts +2 -2
  95. package/src/fixture-env.ts +18 -26
  96. package/src/host-cli.ts +4 -2
  97. package/src/host-ready.ts +13 -4
  98. package/src/host-worker.ts +6 -2
  99. package/src/host.ts +11 -2
  100. package/src/index.ts +2 -2
  101. package/src/init.ts +222 -101
  102. package/src/lifecycle-record.ts +2 -2
  103. package/src/local-branches.ts +1 -1
  104. package/src/origin.ts +12 -10
  105. package/src/pack-facts.ts +22 -6
  106. package/src/pglite-backing.ts +2 -1
  107. package/src/placeholder.ts +13 -4
  108. package/src/project-inspect.ts +2 -2
  109. package/src/redirect-proxy.ts +9 -1
  110. package/src/redis-backing.ts +4 -2
  111. package/src/reflect.ts +1 -1
  112. package/src/root.ts +33 -65
  113. package/src/runtime-test-support.ts +1 -1
  114. package/src/runtime.ts +261 -55
  115. package/src/schema.ts +26 -13
  116. package/src/served-world.ts +50 -130
  117. package/src/tail.ts +1 -1
  118. package/src/up-cli.ts +55 -0
  119. package/src/websocket-relay.ts +1 -1
  120. package/src/world-origins.ts +1 -1
  121. package/src/world-view.ts +3 -3
package/src/configs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
- import { getActiveWorldStore, stateDirName } from '@volter/world-core';
3
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime';
4
4
  import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
5
5
 
6
6
  export function resolveConfigPath(config: string, root = process.cwd()): string {
@@ -1,11 +1,11 @@
1
1
  // THE CONSOLE ON AN ORIGIN OF ITS OWN. The console keeps the token a person types in its tab's
2
- // storage; the Worlds' origin serves what twins serve (a mirror bundle, a tunnel's visitor pages,
2
+ // storage; the Worlds' origin serves what twins serve (a vendor's screens, a tunnel's visitor pages,
3
3
  // an app's own HTML), and a script there could read any storage of that origin. So a host serves the
4
4
  // console on a listener of its own: the console's shell and bundle, and the host's `/-/` doors
5
5
  // forwarded to the host — never a twin's path, so no twin-served script ever runs on this origin.
6
- // The console opens a World's pages (a twin's mirror) on the Worlds' origin, which it is told.
6
+ // The console opens a World's pages (a vendor's screens) on the Worlds' origin, which it is told.
7
7
 
8
- import { serveHttp, type HttpServer } from '@volter/world-core';
8
+ import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
9
9
 
10
10
  /** What a console offers a host: its shell and assets under `base`, told where the Worlds are served. */
11
11
  export type ConsoleMount = { handle: (request: Request, base: string, opts?: { worlds?: string; names?: string[]; worldOrigin?: boolean }) => Promise<Response | null> };
@@ -40,7 +40,7 @@ export async function serveConsoleApart(mount: ConsoleMount, opts: { upstream: s
40
40
 
41
41
  /** The console for a World served elsewhere (a hosted World), on this machine. Two local listeners, as
42
42
  * `volter world serve` has: the console on one origin, its doors forwarded to the host; and the Worlds'
43
- * origin on the next port, forwarding everything (a twin's mirror, the session door) to the host, so
43
+ * origin on the next port, forwarding everything (a vendor's screens, the session door) to the host, so
44
44
  * the console and the pages twins serve are same-site as they are locally, and still two origins. A
45
45
  * host keeps its tenants' names to itself, so the World is named by the URL: `<origin>/<org>/<world>`. */
46
46
  export async function serveConsoleFor(target: string, opts: { hostname?: string; port?: number } = {}): Promise<{ url: string; worlds: string; servers: HttpServer[] }> {
@@ -71,7 +71,7 @@ export async function serveConsoleFor(target: string, opts: { hostname?: string;
71
71
  const out = new Headers(); for (const [k, v] of answer.headers) if (!['content-encoding', 'content-length', 'set-cookie'].includes(k)) out.append(k, v);
72
72
  // the World session's cookie, kept on this plain-http loopback origin (a Secure cookie is https-only)
73
73
  for (const c of answer.headers.getSetCookie()) out.append('set-cookie', c.replace(/;\s*Secure/i, ''));
74
- // the host's own redirects (the session door's, a twin root's to its mirror) stay on this origin
74
+ // the host's own redirects (the session door's) stay on this origin
75
75
  const location = out.get('location');
76
76
  if (location?.startsWith(origin)) out.set('location', `${url.origin}${location.slice(origin.length)}`);
77
77
  const csp = out.get('content-security-policy');
package/src/consumers.ts CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { hostname } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { stateDirName, withFileLock } from '@volter/world-core';
5
+ import { stateDirName, withFileLock } from '@volter/world-core/runtime';
6
6
 
7
7
  export type WorldConsumer = {
8
8
  id: string;
package/src/covers.ts CHANGED
@@ -22,15 +22,16 @@
22
22
  // unknown-sdk → exit 1 by default (`--allow-unknown` downgrades to a warning:
23
23
  // unknowns still print, but stop failing the proof).
24
24
 
25
- import { readFileSync } from 'node:fs';
26
- import { dirname, join, resolve } from 'node:path';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join, relative, resolve } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import { vendorOfPackModule } from '@volter/world-core/runtime';
28
29
  import { loadWorldConfig } from './configs.ts';
29
30
  import { injectableVendorKeys, loadInject, twinUrlVendorFor } from './inject-map.ts';
30
- import { connectionProtocol, projectConnections, projectDependencies, projectEnvNames, projectFetchUrls, projectNpmRegistries, PYPI_TWINS, SDK_TWINS } from './project-inspect.ts';
31
+ import { connectionProtocol, projectConnections, projectDependencies, projectManifestDirs, projectEnvNames, projectFetchUrls, projectNpmRegistries, PYPI_TWINS, SDK_TWINS } from './project-inspect.ts';
31
32
  import { overlayCoversMaps, packFacts } from './pack-facts.ts';
32
33
  import { statusWorld } from './runtime.ts';
33
- import { worldSelectionIncludes, type WorldSelection, type WorldServiceConfig, type WorldServiceInstance, type WorldUsage } from './schema.ts';
34
+ import { ISSUE_ENV_PREFIX, worldSelectionIncludes, type WorldSelection, type WorldServiceConfig, type WorldServiceInstance, type WorldUsage } from './schema.ts';
34
35
 
35
36
  export type CoverageStatus = 'covered' | 'missing' | 'uninterceptable' | 'unknown-sdk' | 'acknowledged' | 'excluded';
36
37
 
@@ -114,7 +115,7 @@ export function injectorEnvNameForKey(key: string): string {
114
115
  /** Vendor-shaped env var detection (the secondary signal). The var must end in a
115
116
  * credential/endpoint suffix; the remaining stem (framework prefixes stripped,
116
117
  * normalized) is looked up here. */
117
- // Exported for scripts/packless-claims.test.ts, which checks the packless registry against
118
+ // Exported for scripts/packless-claims.ts, which checks the packless registry against
118
119
  // the OVERLAID map (legacy entries + descriptor adoption.envStems in one view) — importing
119
120
  // the real map is unbreakable by formatting, unlike the source-parsing it replaced (§9
120
121
  // skeptic M3, 2026-08-31: a reflow or quote-style change silently emptied the parsed set).
@@ -163,6 +164,22 @@ function envVendorStem(name: string, suffix: RegExp): string | null {
163
164
  return normalizeId(stripped.slice(0, match.index));
164
165
  }
165
166
 
167
+ /** The vendor a credential-shaped env name's stem names: the stem left once the suffix is stripped, or else the longest
168
+ * declared stem the name begins with (googleoauth's GOOGLE_CLIENT for GOOGLE_CLIENT_ID, whose `_CLIENT_ID` suffix
169
+ * leaves only GOOGLE). */
170
+ function envStemVendor(name: string, suffix: RegExp): string | undefined {
171
+ const stem = envVendorStem(name, suffix);
172
+ if (stem === null) return undefined;
173
+ if (ENV_STEM_VENDORS[stem]) return ENV_STEM_VENDORS[stem];
174
+ const words = name.replace(ENV_FRAMEWORK_PREFIX, '').split('_');
175
+ // a declared stem of more than one word: a one-word stem (X's `X`) would claim every name it begins
176
+ for (let k = words.length - 1; k >= 2; k--) {
177
+ const vendor = ENV_STEM_VENDORS[normalizeId(words.slice(0, k).join('_'))];
178
+ if (vendor) return vendor;
179
+ }
180
+ return undefined;
181
+ }
182
+
166
183
  /** Is this env NAME shaped like a CREDENTIAL/endpoint at all (any broad vendor suffix)? The
167
184
  * question `init` asks before deciding whether a value in a repo's `.env.example` may be copied
168
185
  * verbatim: `APP_NAME` may, `ANYTHING_SECRET` may not — a committed example file is exactly where
@@ -170,7 +187,7 @@ function envVendorStem(name: string, suffix: RegExp): string | null {
170
187
  * ALWAYS replaced by a fake, whether or not its stem maps to a vendor this repo twins. Exported
171
188
  * from here so the emission path and the proof read ONE definition of "credential-shaped". */
172
189
  export function isCredentialShapedEnvName(name: string): boolean {
173
- return ENV_SUFFIX_BROAD.test(name.replace(ENV_FRAMEWORK_PREFIX, ''));
190
+ return name === 'GOOGLE_APPLICATION_CREDENTIALS' || ENV_SUFFIX_BROAD.test(name.replace(ENV_FRAMEWORK_PREFIX, ''));
174
191
  }
175
192
 
176
193
  /** The vendor a credential-shaped env NAME betrays, or null. Same broad-suffix route the
@@ -188,8 +205,7 @@ export function envNameVendor(name: string): string | null {
188
205
  // So: this asks "is this name a VENDOR CREDENTIAL that must be faked?" (a hostname is not), while
189
206
  // `detectRepoVendors` asks "which vendors does this repo talk to?" (the mail path certainly is
190
207
  // one). Same string, two honest answers.
191
- const stem = envVendorStem(name, ENV_SUFFIX_BROAD);
192
- return stem === null ? null : ENV_STEM_VENDORS[stem] ?? null;
208
+ return envStemVendor(name, ENV_SUFFIX_BROAD) ?? null;
193
209
  }
194
210
 
195
211
  /** SMTP-shaped env names — the raw-protocol mail path (nodemailer et al.) the HTTP-vendor
@@ -220,7 +236,7 @@ function isSmtpEnvSignal(name: string): boolean {
220
236
  const SDK_SCOPE_VENDORS: Record<string, string> = {
221
237
  // (empty — every entry has moved onto its pack descriptor. See the note below.)
222
238
  };
223
- // descriptor-first migration (adding-a-twin.md §3), COMPLETE for these three tables: every pack declares its own
239
+ // descriptor-first migration (architecture.md, "The descriptor"), COMPLETE for these three tables: every pack declares its own
224
240
  // scopes / env stems / world ids (`adoption` on its descriptor, compiled into the committed
225
241
  // pack-facts artifact), and all three literals above are now empty — they are populated entirely
226
242
  // by the overlay below. They are kept as the overlay's targets, not as homes: the object identity
@@ -258,7 +274,7 @@ export function isMappedScopeSibling(name: string): boolean {
258
274
  * `'acknowledged'` was adjudicated acceptable and auto-acknowledges with its recorded
259
275
  * reason); `notExternal` suppresses vendor-adjacent glue (protocol/type/UI helpers, auth
260
276
  * libraries); `patterns` are the external-shape heuristics. AN ENTRY DIES THE DAY ITS PACK
261
- * IS BORN — scripts/packless-claims.test.ts enforces disjointness with pack adoption and
277
+ * IS BORN — scripts/packless-claims.ts enforces disjointness with pack adoption and
262
278
  * SDK_TWINS (the `@planetscale/database`/twin#255 lesson, mechanized). Lazy + cached like
263
279
  * pack-facts.ts's loader — though note this module ALREADY does module-scope I/O via the
264
280
  * overlayCoversMaps call below, so laziness here is consistency, not a load-bearing
@@ -365,10 +381,11 @@ function servicePackNames(service: { package?: string; command?: string | string
365
381
  ...(service.colocate?.module ? [service.colocate.module] : []),
366
382
  ];
367
383
  return commandParts.flatMap((part) => {
368
- const fromPath = part.match(/packages\/twin\/([a-z0-9-]+)\//);
384
+ const fromPath = part.match(/(?:^|[/\\])twin-packs-p[23][/\\]([a-z0-9-]+)[/\\]/);
369
385
  const fromBin = part.match(/^world-([a-z0-9-]+)$/);
370
386
  const fromPkg = part.match(/(?:^|[/\\])@volter\/twin-([a-z0-9-]+)(?:[/\\]|$)/);
371
- return [fromPath?.[1], fromBin?.[1], fromPkg?.[1]].filter((name): name is string => name !== undefined);
387
+ const fromManifest = /\.(?:[cm]?js|tsx?)$/.test(part) ? vendorOfPackModule(part) : undefined;
388
+ return [fromManifest, fromPath?.[1], fromBin?.[1], fromPkg?.[1]].filter((name): name is string => name !== undefined);
372
389
  });
373
390
  }
374
391
 
@@ -389,14 +406,10 @@ function serviceConnections(
389
406
  const values = instance ? service.env : config?.injectEnvTemplates ?? {};
390
407
  for (const [name, value] of Object.entries(values)) {
391
408
  const protocol = connectionProtocol(value);
392
- const command = [...(Array.isArray(service.command) ? service.command : []), ...('args' in service ? service.args ?? [] : [])];
393
409
  // The pack-transport veto is about NATIVE schemes (a mysql:// template on an HTTP twin); an
394
410
  // http(s) endpoint template on any twin is that twin's own HTTP door.
395
- const incompatiblePack = protocol !== null && facts.some((fact) => {
396
- if (fact.nativeTransport?.protocol === protocol && command.includes(fact.nativeTransport.flag)) return false;
397
- return fact.transport !== 'raw-tcp' || !Object.values(fact.endpointEnv?.templates ?? {})
398
- .some((template) => connectionProtocol(template) === protocol && protocol !== null);
399
- });
411
+ const incompatiblePack = protocol !== null && facts.some((fact) => fact.transport !== 'raw-tcp' || !Object.values(fact.endpointEnv?.templates ?? {})
412
+ .some((template) => connectionProtocol(template) === protocol && protocol !== null));
400
413
  let routed = false;
401
414
  if (protocol !== null && !incompatiblePack) {
402
415
  if (instance) {
@@ -425,7 +438,7 @@ function serviceConnections(
425
438
  bindings.set(name, { protocol: template.includes('${url}') || /^https?:/.test(template) ? 'http' : null, label: `${service.id} (${name}; cliRedirect)` });
426
439
  }
427
440
  for (const mapping of config?.external?.discover ?? []) {
428
- bindings.set(mapping.as, { protocol: null, label: `${service.id} (${mapping.as}; discover after up)` });
441
+ bindings.set(mapping.as, { protocol: mapping.protocol ?? null, label: `${service.id} (${mapping.as}; discover after up)` });
429
442
  }
430
443
  }
431
444
  }
@@ -483,11 +496,7 @@ function addServiceIds(
483
496
  .some(([name, value]) => httpNames.has(name) && (/^(?:https?|wss?):/.test(value) || value === '${url}'));
484
497
  const nativeOnly = packNames.length === 0 && endpointValues.some((value) => connectionProtocol(value) !== null) &&
485
498
  !vendorHttpEndpoint;
486
- const nativeMode = packNames.some((name) => {
487
- const mode = packFacts()[name]?.nativeTransport;
488
- return mode && [...(Array.isArray(service.command) ? service.command : []), ...(service.args ?? [])].includes(mode.flag);
489
- });
490
- const interceptable = !nativeOnly && !nativeMode && (injectorReads || appConfigured || hostMatched);
499
+ const interceptable = !nativeOnly && (injectorReads || appConfigured || hostMatched);
491
500
  // twin + external services cover their vendor by identity; a plain 'process'
492
501
  // service only counts via an explicit twin marker (injectEnv/*_TWIN_URL, pack path).
493
502
  if (type === 'twin' || type === 'external') {
@@ -524,7 +533,13 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
524
533
  const { path, config } = loadWorldConfig(world, root);
525
534
  const ids = new Map<string, WorldTwinId>();
526
535
  for (const service of config.services as WorldServiceConfig[]) addServiceIds(ids, service);
527
- return { source: 'config', path, ids, connections: serviceConnections(config.services), selection: config.selection, declared: declaredTwin(config.services) };
536
+ const connections = serviceConnections(config.services);
537
+ // a name the World's env leaves to a twin's credential door (`$issue:<service>`) is that twin's, whatever scheme the
538
+ // issued value has: the twin that issues it serves it
539
+ for (const [name, value] of Object.entries(config.env ?? {})) {
540
+ if (value.startsWith(ISSUE_ENV_PREFIX)) connections.set(name, { protocol: 'issued', label: `${value.slice(ISSUE_ENV_PREFIX.length)} (${name}; issued)` });
541
+ }
542
+ return { source: 'config', path, ids, connections, selection: config.selection, declared: declaredTwin(config.services) };
528
543
  } catch (error) {
529
544
  const configError = error instanceof Error ? error.message : String(error);
530
545
  throw new Error(
@@ -645,8 +660,7 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
645
660
  detected.set('smtp', [...(detected.get('smtp') ?? []), `env: ${name}`]);
646
661
  continue;
647
662
  }
648
- const stem = envVendorStem(name, ENV_SUFFIX_BROAD);
649
- const vendor = stem === null ? undefined : ENV_STEM_VENDORS[stem];
663
+ const vendor = envStemVendor(name, ENV_SUFFIX_BROAD);
650
664
  if (vendor) detected.set(vendor, [...(detected.get(vendor) ?? []), `env: ${name}`]);
651
665
  }
652
666
 
@@ -693,6 +707,44 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
693
707
  }
694
708
  }
695
709
 
710
+ // a platform's config file that betrays the vendor (a vercel.json declaring crons: the platform calls the app's routes)
711
+ for (const [vendor, facts] of Object.entries(packFacts())) {
712
+ for (const config of facts.adoption?.configFiles ?? []) {
713
+ for (const dir of projectManifestDirs(repo)) {
714
+ const path = join(dir, config.file);
715
+ if (!existsSync(path)) continue;
716
+ let parsed: unknown;
717
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
718
+ if (config.when !== undefined && !(parsed && typeof parsed === 'object' && config.when in parsed)) continue;
719
+ const where = relative(repo, path) || config.file;
720
+ const evidence = config.usage === 'application' ? `config: ${where}${config.when ? ` (${config.when})` : ''}` : `${config.usage}-tool: config: ${where}`;
721
+ detected.set(vendor, [...(detected.get(vendor) ?? []), evidence]);
722
+ }
723
+ }
724
+ }
725
+
726
+ // Exact names a descriptor's credential door fills are evidence too (ADC names a file, not a _KEY scalar), when the
727
+ // name names the vendor: its first word is the first word of one of the vendor's env stems (GOOGLE_APPLICATION_CREDENTIALS
728
+ // for GOOGLE_OAUTH), or it holds one of the stems whole (SUPERCODE_TUNNEL_SERVER_URL for TUNNEL). A generic name a door
729
+ // also fills (DATABASE_URL, NODE_AUTH_TOKEN) is every vendor's and so no vendor's evidence; a name that already is the
730
+ // vendor's evidence is not counted twice.
731
+ for (const [vendor, facts] of Object.entries(packFacts())) {
732
+ const stems = facts.adoption?.envStems ?? [];
733
+ const words = new Set(stems.map((stem) => stem.split('_')[0]));
734
+ const names = (name: string): boolean => {
735
+ const bare = name.replace(ENV_FRAMEWORK_PREFIX, '');
736
+ return words.has(bare.split('_')[0]!) || stems.some((stem) => `_${bare}_`.includes(`_${stem}_`));
737
+ };
738
+ for (const name of Object.keys(facts.credentialDoor?.fill ?? {})) {
739
+ if (!envNames.includes(name) || !names(name)) continue;
740
+ if (!(detected.get(vendor) ?? []).includes(`env: ${name}`)) detected.set(vendor, [...(detected.get(vendor) ?? []), `env: ${name} (credential issuer)`]);
741
+ }
742
+ }
743
+ // Expand the declared issuer relation transitively, once per vendor; cycles cannot keep detection running.
744
+ for (const vendor of detected.keys()) {
745
+ const issuer = packFacts()[vendor]?.credentialIssuer;
746
+ if (issuer && !detected.has(issuer)) detected.set(issuer, [`credential issuer: ${vendor}`]);
747
+ }
696
748
  // unknown external-service-shaped signals (no vendor mapping). A dep from a scope with a
697
749
  // twin-mapped sibling counts even when its own name is not service-shaped — the Dub run's
698
750
  // @upstash/redis + @upstash/ratelimit disappeared exactly here while @upstash/qstash mapped.
@@ -713,7 +765,7 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
713
765
  if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name)) continue;
714
766
  if (isSmtpEnvSignal(name)) continue; // now a DETECTED vendor (above), no longer an unknown
715
767
  const stem = envVendorStem(name, ENV_SUFFIX_STRICT);
716
- if (stem === null || ENV_STEM_VENDORS[stem] || ENV_STEM_IGNORE.has(stem)) continue;
768
+ if (stem === null || envStemVendor(name, ENV_SUFFIX_STRICT) || ENV_STEM_IGNORE.has(stem)) continue;
717
769
  // The registry's notExternal suppressions apply to ENV STEMS too — the census adjudicated
718
770
  // 40 app-internal stems ("names no vendor") that this loop kept flagging because only the
719
771
  // dep-side checks consulted the registry (§9 round two H4, 2026-08-31: 17 of 18 formally
@@ -849,9 +901,10 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
849
901
  // An HTTP-valued endpoint variable the world injects from a twin (its endpoint env or a
850
902
  // template) is routed to that twin; only an HTTP URL nobody in the world supplies stays unknown.
851
903
  const injectedHttp = requirement.protocol === null && binding?.protocol === 'http';
852
- const unresolved = !injectedHttp && (requirement.protocol === null || requirement.env === undefined || (binding !== undefined && binding.protocol === null));
904
+ const issued = binding?.protocol === 'issued';
905
+ const unresolved = !injectedHttp && !issued && (requirement.protocol === null || requirement.env === undefined || (binding !== undefined && binding.protocol === null));
853
906
  let status: CoverageStatus = unresolved ? 'unknown-sdk'
854
- : injectedHttp || binding?.protocol === requirement.protocol ? 'covered' : 'missing';
907
+ : injectedHttp || issued || binding?.protocol === requirement.protocol ? 'covered' : 'missing';
855
908
  if (status !== 'covered' && acknowledge[vendor] !== undefined) {
856
909
  status = 'acknowledged';
857
910
  acknowledged.push({ vendor, reason: acknowledge[vendor]! });
package/src/env-output.ts CHANGED
@@ -17,9 +17,9 @@ function inspect(path: string): Buffer | undefined {
17
17
  }
18
18
 
19
19
  /** Refuse a custody/input file before any World service or lifecycle mutation starts. */
20
- export function claimEnvOutput(path: string, backupDir: string, prior?: PriorOutput, force = false): EnvOutputClaim {
20
+ export function claimEnvOutput(path: string, backupDir: string, priors: PriorOutput[] = [], force = false): EnvOutputClaim {
21
21
  const contents = inspect(path);
22
- if (contents !== undefined && !force && !(prior?.path === path && Buffer.from(prior.contents).equals(contents))) {
22
+ if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && Buffer.from(prior.contents).equals(contents))) {
23
23
  throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
24
24
  }
25
25
  if (force && contents !== undefined) {
@@ -149,28 +149,20 @@ export function isGoogleServiceAccountEnvName(name: string): boolean {
149
149
  * hostname and sends the request to the local twin, while the slug remains unmistakably fake.
150
150
  *
151
151
  * Like CREDENTIAL_SHAPES below, this is duplicated kernel data with an explicit canonical source:
152
- * world-runtime must not import a pack, and the tests pin every value against the injector's actual
153
- * VENDOR_HOSTS predicate so either side changing makes the contract fail loudly. */
152
+ * world-runtime must not import a pack, and the tests pin every value against the injector's host
153
+ * rules (the pack's facts) so either side changing makes the contract fail loudly. */
154
154
  const ENDPOINT_SHAPES: Array<{ match: RegExp; value: string; source: string }> = [
155
- // the vendor's own API URL, which the World routes to its twin: an app that reads a base-URL override (empty in its
156
- // example, the vendor's URL as its default) gets the vendor's URL, never an unreachable fake
157
- { match: /^DYNADOT_BASE_URL$/, value: 'https://api.dynadot.com/api3.json', source: 'packages/twin/dynadot/src/index.ts hosts (api.dynadot.com); dubinc/dub apps/web/lib/dynadot/client.ts default' },
158
155
  // Vercel KV's REST URL (the Upstash Redis REST API under Vercel's names): an upstash host the twin serves
159
- { match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'packages/world-core inject VENDOR_HOSTS.upstash; lukevella/rallly KV_REST_API_URL' },
156
+ { match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'the upstash pack\'s host rules (.upstash.io); lukevella/rallly KV_REST_API_URL' },
160
157
  {
161
158
  match: /(^|_)UPSTASH_REDIS_REST_URL$/,
162
159
  value: 'https://twin-fake.upstash.io',
163
- source: 'packages/world-core/inject.cjs VENDOR_HOSTS.upstash',
164
- },
165
- {
166
- match: /(^|_)UPSTASH_VECTOR_REST_URL$/,
167
- value: 'https://twin-fake-us1-vector.upstash.io',
168
- source: 'packages/world-core/inject.cjs VENDOR_HOSTS.upstashvector',
160
+ source: 'the upstash pack\'s host rules (.upstash.io)',
169
161
  },
170
162
  {
171
163
  match: /(^|_)TINYBIRD_API_URL$/,
172
164
  value: 'https://api.tinybird.co',
173
- source: 'packages/world-core/inject.cjs VENDOR_HOSTS.tinybird',
165
+ source: 'the tinybird pack\'s host rules (api.tinybird.co)',
174
166
  },
175
167
  ];
176
168
 
@@ -190,19 +182,20 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
190
182
  // application id, so a structurally wrong fake breaks before any call reaches the twin.
191
183
  { match: /(^|_)DISCORD_[A-Z0-9_]*(TOKEN|SECRET)$/, value: () => `${btoa('1'.repeat(18)).replace(/=+$/, '')}.Gtwinf.${'t'.repeat(38)}`, source: 'a Discord bot token: three dot-separated base64 segments, the first the application id' },
192
184
  // resend-twin.ts enforces bearer auth of the form `re_<...>` whenever a key is presented.
193
- { match: /(^|_)RESEND_[A-Z0-9_]*(KEY|TOKEN|SECRET)$/, value: (name) => `re_twinfake_${name.toLowerCase().replace(/_/g, '')}`, source: 'packages/twin/resend/src/resend-twin.ts' },
185
+ { match: /(^|_)RESEND_[A-Z0-9_]*(KEY|TOKEN|SECRET)$/, value: (name) => `re_twinfake_${name.toLowerCase().replace(/_/g, '')}`, source: 'earlier @volter/twin-resend resend-twin.ts' },
194
186
  // The vercel twin answers only a bearer it minted, its bootstrap token first (`vercel.platform.token_identity`); any
195
187
  // other value is Vercel's 403 "invalid token", so every call an app makes to its Vercel project fails.
196
- { match: /(^|_)VERCEL_[A-Z0-9_]*(TOKEN|API_KEY)$/, value: () => 'vercel_twin_bootstrap_token', source: 'packages/twin/vercel/src/vercel-runtime.ts VERCEL_TWIN_TOKEN' },
188
+ { match: /(^|_)VERCEL_[A-Z0-9_]*(TOKEN|API_KEY)$/, value: () => 'vercel_twin_bootstrap_token', source: 'earlier @volter/twin-vercel vercel-runtime.ts VERCEL_TWIN_TOKEN' },
197
189
  // The tremendous twin takes a key of the shape Tremendous issues, `TEST_` for the sandbox organization at
198
190
  // testflight.tremendous.com and `PROD_` for production (manifest.ts `auth.keyFormat`); any other value is Tremendous's
199
191
  // 401. The `TEST_` prefix is what selects the sandbox host in clients that branch on it (dub's
200
192
  // lib/tremendous/configuration.ts).
201
- { match: /(^|_)TREMENDOUS_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'TEST_twin_sandbox_api_key', source: 'packages/twin/tremendous/src/manifest.ts auth.keyFormat' },
202
- // Clerk: the SECRET key is opaque to the twin (any sk_ shape), but the PUBLISHABLE key is parsed by
193
+ { match: /(^|_)TREMENDOUS_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'TEST_twin_sandbox_api_key', source: 'earlier @volter/twin-tremendous manifest.ts auth.keyFormat' },
194
+ // Clerk: the SECRET key is the instance's own (the clerk twin takes this one and the keys its API Keys door makes,
195
+ // earlier @volter/twin-clerk server.ts CLERK_TWIN_SECRET_KEY), and the PUBLISHABLE key is parsed by
203
196
  // clerk-js — `pk_test_<base64(frontendApiHost$)>` names the frontend-API host the browser calls.
204
197
  // The world issues one naming the dev-instance family the clerk pack CLAIMS by suffix
205
- // (`.clerk.accounts.dev`, packages/twin/clerk/src/index.ts hosts), so the boundary — the injector
198
+ // (`.clerk.accounts.dev`, earlier @volter/twin-clerk index.ts hosts), so the boundary — the injector
206
199
  // server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
207
200
  // allowlists that host stays legal (peak drive 2026-08-26).
208
201
  // Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
@@ -210,15 +203,14 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
210
203
  { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
211
204
  { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
212
205
  { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
213
- // The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
214
- // acts as the workspace bot, and an app-level token is `xapp-`.
215
- { match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
216
- { match: /(^|_)SLACK_([A-Z0-9_]*_)?BOT_TOKEN$|(^|_)SLACK_TOKEN$/, value: () => 'xoxb-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/); GET /twin identity (any other xoxb-* is the workspace bot)' },
217
206
  // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
218
- { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
219
- { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
220
- { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'packages/twin/clerk/src/clerk-twin.ts' },
221
- { match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'packages/twin/clerk/src/index.ts' },
207
+ { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'earlier @volter/twin-tavily tavily-auth.ts' },
208
+ { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'earlier @volter/twin-firecrawl firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
209
+ // a Clerk webhook's signing secret is Svix's (`whsec_` and base64), which the app's verifier decodes; the World registers
210
+ // its endpoint with the same secret (the Clerk twin's webhooks door takes `signing_secret`)
211
+ { match: /(^|_)CLERK_WEBHOOK(_SIGNING)?_SECRET$/, value: () => `whsec_${Buffer.from('twin-fake-clerk-webhook-secret!!').toString('base64')}`, source: 'https://clerk.com/docs/webhooks/sync-data (CLERK_WEBHOOK_SIGNING_SECRET, whsec_); https://docs.svix.com/receiving/verifying-payloads/how-manual' },
212
+ { match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'earlier @volter/twin-clerk manifest.ts' },
213
+ { match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'earlier @volter/twin-clerk index.ts' },
222
214
  ];
223
215
 
224
216
  /** Keys the APPLICATION parses before it can use them: a fake that is not the shape its own code decodes throws at the
package/src/host-cli.ts CHANGED
@@ -33,11 +33,13 @@ for (let i = 0; i < argv.length; i++) {
33
33
  const scenarioPath = flags.find((f) => f.startsWith('scenario='))?.slice('scenario='.length);
34
34
  // the World's managed database a pack declaring `managedDatabase` is bound to (runtime.ts, managedDatabaseFor)
35
35
  const database = flags.find((f) => f.startsWith('database='))?.slice('database='.length);
36
+ // the twin's side ports the runtime allocated (TwinPack.sidePorts): `side.<factory option>=<port>`
37
+ const sideOptions = Object.fromEntries(flags.filter((f) => /^side\.[A-Za-z0-9]+=\d+$/.test(f)).map((f) => { const [k, v] = f.slice('side.'.length).split('='); return [k!, Number(v)]; }));
36
38
  if (!id || !module || !exportName || !port || !root) {
37
- process.stderr.write(`bad --spec "${raw}" (want id|module|export|port|root[|ro][|scenario=<handlers file>][|database=<url>])\n`);
39
+ process.stderr.write(`bad --spec "${raw}" (want id|module|export|port|root[|ro][|scenario=<handlers file>][|database=<url>][|side.<option>=<port>…])\n`);
38
40
  process.exit(2);
39
41
  }
40
- specs.push({ id, module, export: exportName, port: Number(port), root, readOnly: ro === 'ro', ...(scenarioPath ? { scenarioPath } : {}), ...(database ? { database } : {}) });
42
+ specs.push({ id, module, export: exportName, port: Number(port), root, readOnly: ro === 'ro', ...(scenarioPath ? { scenarioPath } : {}), ...(database ? { database } : {}), ...(Object.keys(sideOptions).length ? { sideOptions } : {}) });
41
43
  }
42
44
  if (specs.length === 0) { process.stderr.write('no --spec given\n'); process.exit(2); }
43
45
 
package/src/host-ready.ts CHANGED
@@ -10,9 +10,16 @@ export function publishHostReady(path: string, boot: string, ports: Record<strin
10
10
  renameSync(temporary, path);
11
11
  }
12
12
 
13
- /** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
14
- export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal): Promise<void> {
15
- const started = Date.now();
13
+ /**
14
+ * Receipt admission is cancellation-aware; stale or malformed state is never
15
+ * readiness. `timeoutMs` bounds a host that stops making progress, not a slow
16
+ * one: each change in `progress` (the host log's size, which grows with every
17
+ * twin it starts) begins the bound again, so a host still starting its twins
18
+ * on a busy machine is waited for, and one silent for `timeoutMs` is not.
19
+ */
20
+ export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<void> {
21
+ let started = Date.now();
22
+ let seen = progress();
16
23
  for (;;) {
17
24
  abort.throwIfAborted();
18
25
  let bytes: Buffer | undefined;
@@ -30,7 +37,9 @@ export async function waitForHostReady(path: string, boot: string, ports: Readon
30
37
  }
31
38
  return;
32
39
  }
33
- if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host did not complete every declared start within ${timeoutMs} ms.`);
40
+ const now = progress();
41
+ if (now !== seen) { seen = now; started = Date.now(); }
42
+ if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
34
43
  await new Promise<void>((resolve, reject) => {
35
44
  const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
36
45
  const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
@@ -1,3 +1,4 @@
1
+ import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
1
2
  // Worker entry for 'worker' isolation: one Worker thread per twin. It owns its event
2
3
  // loop + heap, so a CPU hog / leak / hard crash here is contained to this thread — the
3
4
  // host does NOT restart it: it gives up on the worker (fires a `gaveup` event → doctor
@@ -9,16 +10,19 @@ import { importModule } from './import-module.ts';
9
10
  const spec = workerData as ColocatedTwinSpec;
10
11
 
11
12
  type TwinServer = { port: number; stop: () => void | Promise<void> };
12
- type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; database?: string }) => TwinServer | Promise<TwinServer>;
13
+ type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
13
14
 
14
15
  const mod = await importModule(spec.module);
16
+ const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
17
+ if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
18
+ registerPack(descriptor);
15
19
  const factory = mod[spec.export] as TwinServerFactory | undefined;
16
20
  if (typeof factory !== 'function') {
17
21
  throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
18
22
  }
19
23
 
20
24
  // Awaited: an async factory (fly) must post `ready` only once its listener is bound.
21
- const server = await factory({ port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
25
+ const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
22
26
  parentPort?.postMessage({ type: 'ready', port: server.port });
23
27
 
24
28
  // The Bun.serve listener keeps this worker's event loop alive; nothing else to do.
package/src/host.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
1
2
  // Co-located twin host — run many twins in ONE process instead of one OS process
2
3
  // per twin. The twin core is a pure, transport-agnostic handler (see a pack's
3
4
  // `handle<Vendor>TwinRequest`), so nothing requires a process per twin; this host
@@ -41,6 +42,8 @@ export type ColocatedTwinSpec = {
41
42
  /** The World's managed database a pack declaring `managedDatabase` serves over (runtime.ts, managedDatabaseFor):
42
43
  * the colocated form of the spawn path's `serve <arg> <url>`. */
43
44
  database?: string;
45
+ /** The twin's side ports the runtime allocated (TwinPack.sidePorts), as the factory's options (`inspectPort`). */
46
+ sideOptions?: Record<string, number>;
44
47
  };
45
48
 
46
49
  export type HostIsolation = 'shared' | 'worker';
@@ -59,10 +62,13 @@ export type ColocatedHost = {
59
62
  // made host.stop() throw `s.stop is not a function`, turning every SIGTERM into downWorld's
60
63
  // SIGKILL escalation for the whole world).
61
64
  type TwinServer = { port: number; stop: () => void | Promise<void> };
62
- type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string }) => TwinServer | Promise<TwinServer>;
65
+ type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
63
66
 
64
67
  async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
65
68
  const mod = await importModule(spec.module);
69
+ const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
70
+ if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
71
+ registerPack(descriptor);
66
72
  const factory = mod[spec.export];
67
73
  if (typeof factory !== 'function') {
68
74
  throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
@@ -122,13 +128,16 @@ export async function startColocatedHost(
122
128
  try {
123
129
  await parallelStartup(specs, async (spec) => {
124
130
  const factory = await loadFactory(spec);
125
- const server = await factory({ port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
131
+ const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
126
132
  // Record the handle before validating it, so rollback also owns a bad factory result.
127
133
  servers.push(server);
128
134
  if (!Number.isInteger(server.port) || server.port < 1 || server.port > 65535 || (spec.port !== 0 && server.port !== spec.port)) {
129
135
  throw new Error(`Twin "${spec.id}" bound port ${server.port}, requested ${spec.port}.`);
130
136
  }
131
137
  ports[spec.id] = server.port;
138
+ // Each start is progress the runtime's readiness wait counts (host-ready.ts); the line says when,
139
+ // in milliseconds since the host process began, since the log's own lines carry no time.
140
+ onEvent({ twin: spec.id, kind: 'started', detail: `port ${server.port} at ${Math.round(performance.now())} ms` });
132
141
  });
133
142
  } catch (error) {
134
143
  const cleanup = await stop();
package/src/index.ts CHANGED
@@ -99,9 +99,9 @@ export type { CoverageReport, CoverageRow, CoverageStatus, CoverageOptions, Repo
99
99
  // world config + env file OUTSIDE the repo (fixture-env), and prove the emission with `covers`.
100
100
  export { formatInitReport, initWorld, packCatalog, planWorldInit, renderEnvFile, writeWorldInit } from './init.ts';
101
101
  export { STATE_GITIGNORE } from './init.ts';
102
- export { clockFile } from './runtime.ts';
102
+ export { clockFile, instanceDir } from './runtime.ts';
103
103
  export { checkoutCatalog, findInstalledPackage, installedCatalog, resolveCatalog, resolveServicePackage, twinPackageName, TWIN_PACKAGE_PREFIX, type Catalog, type CatalogKind, packCli, packEntry } from './catalog.ts';
104
- export { MINT_ENV } from './schema.ts';
104
+ export { ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
105
105
  export type {
106
106
  EnvDisposition,
107
107
  InitEnvRow,