@volter/twin-world 0.1.0 → 0.1.2

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/src/init.ts ADDED
@@ -0,0 +1,1142 @@
1
+ // `volter-world init <name> --repo <path>` — the DETERMINISTIC FRONT DOOR of world adoption.
2
+ //
3
+ // Everything it needs already existed, in three pieces that no one command composed:
4
+ // • `inspect-project` / `covers` know WHICH vendors a repo talks to (dependencies across every
5
+ // workspace member, vendor-shaped env names in committed .env files, SMTP/raw-protocol signals,
6
+ // and literal fetch destinations in production source);
7
+ // • `@volter/twin/inject`'s VENDOR_HOSTS knows WHICH of those can be intercepted zero-edit, and
8
+ // under exactly which `*_TWIN_URL` name (the LibreChat `AWS_TWIN_URL` trap is what happens when
9
+ // a config guesses instead of asking);
10
+ // • `fixture-env` knows how to mint a fake credential that a client SDK will actually accept
11
+ // (structurally valid where the SDK parses it, opaque where it does not).
12
+ // `init` composes them into the two files an operator otherwise hand-writes — a world config and an
13
+ // env file — and then RUNS THE PROOF over its own output, so the command's exit code is the answer
14
+ // to "can I `up` this?" rather than a claim that it wrote something.
15
+ //
16
+ // THE FOUR RULES THIS COMMAND IS BUILT ON
17
+ //
18
+ // 1. THE REPO STAYS PRISTINE. Output goes OUTSIDE `--repo` (default `<repo>/../<name>-pilot/`), and
19
+ // an `--out` that resolves inside the repo is refused. Adoption must never be something an app
20
+ // repo has to accept a commit for; a pilot you can `rm -rf` is the whole point.
21
+ // 2. INJECT-ENV IS DERIVED, NEVER GUESSED. Every `*_TWIN_URL` this command emits comes from
22
+ // `injectorVendorKeysFor()` reading the injector's own table. A vendor the injector cannot
23
+ // intercept gets its REAL app-read endpoint env (QSTASH_URL, INNGEST_BASE_URL, LIVEKIT_URL, …)
24
+ // from APP_READ_ENDPOINT_ENV below, or — when the vendor's SDK is wired only by explicit client
25
+ // config — NO env var at all and a note saying so. Emitting a plausible-looking var that
26
+ // nothing reads would turn the proof green over a world whose traffic still leaves the machine.
27
+ // 3. A COMMITTED EXAMPLE VALUE IS NEVER A SAFE VALUE. Every credential-shaped name is replaced by a
28
+ // fake, whether or not its stem maps to a vendor: `.env.example` files really do carry live keys
29
+ // (the ponder blind-adoption run found some). Only non-credential app config is copied verbatim.
30
+ // 4. THE EXIT CODE IS THE VENDOR-COVERAGE PROOF'S. 0 means every detected vendor is covered. 1
31
+ // means the honest vendor worklist — missing packs, unknown SDKs, twins nothing can reach —
32
+ // with a remediation line each. App-boot risks are a separate, prominent signal: an empty
33
+ // example value read by production source can block before listen even when vendor coverage is
34
+ // green. `init` never boots the world: it diagnoses, the operator (or the room) boots.
35
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
36
+ import { spawnSync } from 'node:child_process';
37
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
38
+ import {
39
+ coverWorld,
40
+ detectRepoVendors,
41
+ envNameVendor,
42
+ formatCoverageReport,
43
+ injectorEnvNameForKey,
44
+ injectorVendorKeysFor,
45
+ isCredentialShapedEnvName,
46
+ registryAcknowledgedReason,
47
+ type CoverageReport,
48
+ } from './covers.ts';
49
+ import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
50
+ import { projectEnvReads, projectManifestDirs } from './project-inspect.ts';
51
+ import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
52
+ import type { WorldConfig, WorldServiceConfig } from './schema.ts';
53
+
54
+ /** How a twin service in the emitted world is reached by the app's traffic. */
55
+ export type InitWiring =
56
+ /** the injector redirects the vendor's real hosts; `injectEnv` is a var it genuinely reads */
57
+ | 'injector'
58
+ /** no injector entry, but the SDK reads a documented endpoint env var (QSTASH_URL, …) */
59
+ | 'app-read'
60
+ /** no injector entry and no endpoint env: the SDK is pointed at the twin by explicit client config */
61
+ | 'explicit-config'
62
+ /** detected vendor with no `packages/twin/<vendor>` pack in the catalog — nothing to emit */
63
+ | 'no-pack';
64
+
65
+ export type InitVendorPlan = {
66
+ vendor: string;
67
+ /** the catalog pack backing it, or null for 'no-pack' */
68
+ pack: string | null;
69
+ /** how the repo betrayed it, verbatim from the coverage proof's detection */
70
+ detectedVia: string[];
71
+ wiring: InitWiring;
72
+ /** the emitted service, or null when nothing could be emitted */
73
+ service: WorldServiceConfig | null;
74
+ /** why this wiring — printed in the report and embedded in the service's `//` note */
75
+ note: string;
76
+ };
77
+
78
+ /** What `init` did with one env NAME found in the repo's example env files. */
79
+ export type EnvDisposition =
80
+ /** credential-shaped → replaced by a structurally valid fake (never the committed value) */
81
+ | 'faked'
82
+ /** non-credential app config → the example value copied verbatim */
83
+ | 'kept'
84
+ /** infra connection string → points at a World-managed infrastructure service */
85
+ | 'compose'
86
+ /** infra connection string → a documented placeholder the operator fills in */
87
+ | 'placeholder'
88
+ /** neither credential-shaped nor valued: the operator has to decide */
89
+ | 'unknown';
90
+
91
+ export type InitEnvRow = {
92
+ name: string;
93
+ disposition: EnvDisposition;
94
+ /** repo-relative env file the name came from */
95
+ source: string;
96
+ reason: string;
97
+ };
98
+
99
+ /** An example env name that is EMPTY yet read directly by production source. This does not pretend
100
+ * to know the app's validator; it is ranked separately because it may stop the app before listen. */
101
+ export type InitBootRisk = {
102
+ name: string;
103
+ /** example env file carrying the empty value */
104
+ source: string;
105
+ /** production source reads (`path:line`) */
106
+ readAt: string[];
107
+ };
108
+
109
+ /** Infra the repo signals through its env (Postgres, MySQL, Redis, …) but that no twin models:
110
+ * emitted as a DOCUMENTED `external`-service stub the operator fills in and moves into `services`. */
111
+ export type InitInfraStub = {
112
+ kind: string;
113
+ /** the env names that betrayed it */
114
+ signals: string[];
115
+ /** the stub object written under the config's `//infra` key */
116
+ stub: Record<string, unknown>;
117
+ };
118
+
119
+ /** One service in the private managed-infrastructure definition — detected infra `init` can
120
+ * declare under the World lifecycle instead of leaving a placeholder for an operator. */
121
+ export type InitComposeService = {
122
+ kind: string;
123
+ image: string;
124
+ /** deterministic host port, derived from the world name (see composePort) */
125
+ hostPort: number;
126
+ containerPort: number;
127
+ memoryMiB: number;
128
+ /** compose volume key, or null (the on-disk docker volume is `<project>_<key>` — the compose
129
+ * project name carries the world name, so volumes are named per world) */
130
+ volume: string | null;
131
+ /** the connection URL emitted for every env name that signalled this kind */
132
+ url: string;
133
+ /** the env names that betrayed it */
134
+ signals: string[];
135
+ };
136
+
137
+ export type InitPlan = {
138
+ name: string;
139
+ repo: string;
140
+ out: string;
141
+ configPath: string;
142
+ envPath: string;
143
+ config: WorldConfig;
144
+ /** the env block, identical to the config's `env` — `app.env` is rendered from this same map */
145
+ env: Record<string, string>;
146
+ vendors: InitVendorPlan[];
147
+ envRows: InitEnvRow[];
148
+ envSources: string[];
149
+ /** empty example values read by production source — prominent warning, not vendor-coverage fact */
150
+ bootRisks: InitBootRisk[];
151
+ infra: InitInfraStub[];
152
+ /** the services emitted into the private managed-infrastructure definition */
153
+ compose: InitComposeService[];
154
+ /** private managed-infrastructure definition, or null when no detected infra has a recipe */
155
+ composePath: string | null;
156
+ /** external-service-shaped signals with no vendor mapping AND no standing registry
157
+ * acknowledgment — the real worklist (registry-acknowledged signals are partitioned into
158
+ * `acknowledgedExternal` below, never silently dropped). */
159
+ unknown: string[];
160
+ /** signals the packless-vendor registry carries a STANDING acknowledgment for (census-
161
+ * adjudicated acceptable) — separated from `unknown` so the worklist stays real, but
162
+ * never silently dropped: each carries its recorded reason. */
163
+ acknowledgedExternal: Array<{ name: string; reason: string }>;
164
+ /** pack `defaults/` files copied into the world dir (TWIN-PROGRAMMING-MODEL: default
165
+ * reasonable — visible starter seeds/handlers, ownership transfers on copy). */
166
+ defaultsCopies: Array<{ vendor: string; kind: 'handlers' | 'seed'; from: string; to: string }>;
167
+ /** emitted when any pack ships a default seed: the one explicit entry point + story skeleton */
168
+ seedEntry: { entryPath: string; storyPath: string } | null;
169
+ /**
170
+ * THE ONE DOCUMENTED EXCEPTION TO BYTE-IDENTICAL OUTPUT. Every other byte `init` emits is a pure
171
+ * function of (repo, catalog, world name). These names hold FRESHLY MINTED cryptographic material
172
+ * — today, the real throwaway RSA key inside a Google service-account JSON, which google-auth-
173
+ * library signs its assertion with locally before the twin ever sees a request (see fixture-env.ts).
174
+ * It is non-deterministic BY CONSTRUCTION: a throwaway key that never changes is a committed key,
175
+ * and fixture-env's whole rule is that keys are minted per process and never committed. So init
176
+ * names them here (and in the report, and in a comment in the env file) instead of quietly
177
+ * breaking the determinism claim — an operator diffing two pilots knows exactly which lines may
178
+ * legitimately differ, and every other line differing is a real change.
179
+ *
180
+ * Scope of the exception, exactly: fixture-env mints ONCE PER PROCESS and reuses it, so two inits
181
+ * in the SAME process (a room initing several repos in one run) are byte-identical even here.
182
+ * Only separate invocations differ. Both halves are pinned in init.test.ts.
183
+ */
184
+ mintedEnv: string[];
185
+ };
186
+
187
+ export type InitResult = {
188
+ plan: InitPlan;
189
+ coverage: CoverageReport;
190
+ /** the vendor-coverage proof's verdict — `init`'s exit code IS this; bootRisks stay separate */
191
+ ok: boolean;
192
+ /** the exact commands to run next; `init` never runs them itself */
193
+ next: string[];
194
+ };
195
+
196
+ export type InitOptions = {
197
+ /** the WORLD root: where the twin pack catalog lives and where the emitted config will boot from */
198
+ root?: string;
199
+ out?: string;
200
+ force?: boolean;
201
+ allowUnknown?: boolean;
202
+ acknowledge?: Record<string, string>;
203
+ };
204
+
205
+ // ---------------------------------------------------------------------------------------------
206
+ // The pack catalog
207
+ // ---------------------------------------------------------------------------------------------
208
+
209
+ /** The twin packs available at `root` — a pack is a `packages/twin/<name>/` dir with a `src/cli.ts`
210
+ * (the `serve` entrypoint every pack's `world-<name>` bin points at). Read from disk rather than a
211
+ * hard-coded list so a newly added pack is emittable the day it lands, and sorted so the catalog is
212
+ * a deterministic input. */
213
+ export function packCatalog(root: string): string[] {
214
+ const packsDir = resolve(root, 'packages', 'twin');
215
+ if (!existsSync(packsDir)) {
216
+ throw new Error(
217
+ `volter-world init: no twin pack catalog at ${packsDir}. init emits services that run the packs from the world root, `
218
+ + `so --root must be a twin checkout (it defaults to the current directory).`,
219
+ );
220
+ }
221
+ return readdirSync(packsDir, { withFileTypes: true })
222
+ .filter((entry) => entry.isDirectory() && existsSync(resolve(packsDir, entry.name, 'src', 'cli.ts')))
223
+ .map((entry) => entry.name)
224
+ .sort();
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------------------------
228
+ // Wiring knowledge
229
+ // ---------------------------------------------------------------------------------------------
230
+
231
+ /**
232
+ * The REAL endpoint env names for packs the injector cannot intercept — the other half of
233
+ * `scripts/vendor-hosts.test.ts`'s descriptor hostsNone ruling. That allowlist states WHY each pack
234
+ * has no VENDOR_HOSTS entry; this table states WHAT to wire instead, using the env var the vendor's
235
+ * own SDK documents. Every entry is grounded in the SDK, not invented:
236
+ *
237
+ * qstash / upstashworkflow `QSTASH_URL` — `@upstash/qstash`'s documented base-URL override, and
238
+ * `@upstash/workflow`'s Client is literally `new QStashClient(config)`,
239
+ * so it reads the very same var (pack READMEs; both verified live).
240
+ * inngest `INNGEST_BASE_URL` (+ `INNGEST_EVENT_API_BASE_URL` for the event API)
241
+ * — inngest@4's own apiBaseUrl/eventBaseUrl getters.
242
+ * ai-gateway `AI_GATEWAY_BASE_URL` — `@ai-sdk/gateway`'s baseURL env.
243
+ * supabase `SUPABASE_MGMT_TWIN_URL` — the MANAGEMENT (control-plane) twin only,
244
+ * which is what this pack is; the DATA plane is the real local Supabase
245
+ * stack and is emitted as an infra stub, not faked.
246
+ * livekit `LIVEKIT_URL` as a ws:// template — the media plane is a WebSocket
247
+ * URL the SDK takes as its first constructor argument.
248
+ *
249
+ * A pack that is base-URL-configurable but has NO conventional env var (algolia, pinecone,
250
+ * replicate, fal, twilio, sendblue, svix, figma, notion) is deliberately ABSENT: it gets a service
251
+ * with no endpoint env at all, and the proof reports it UNINTERCEPTABLE. Inventing a
252
+ * `<VENDOR>_BASE_URL` for it would make `covers` call the world covered (any app-read env counts)
253
+ * while the app, which reads no such var, still talks to the real vendor — the exact class of lie
254
+ * the proof exists to catch.
255
+ */
256
+ export const APP_READ_ENDPOINT_ENV: Record<string, { injectEnv?: string; injectEnvTemplates?: Record<string, string>; note: string }> = {
257
+ tunnel: {
258
+ injectEnv: 'TUNNEL_SERVER_URL',
259
+ note: 'no injector entry: the native volter-tunnel CLI and @volter/tunnel WebSocket client are endpoint-configured through TUNNEL_SERVER_URL / the SDK host option; native cloudflared is outside Node HTTP injection.',
260
+ },
261
+ rh2: {
262
+ injectEnv: 'RH2_BASE_URL',
263
+ note: 'no injector entry and none possible: an RH2 control plane has no fixed vendor host — it is wherever the operator deployed it. RH2\'s own first-party clients are base-URL-configured and read RH2_BASE_URL (@runhuman/sdk client.ts, @runhuman/cli), so pointing that one var at the twin is the whole interception for an unmodified SDK or CLI.',
264
+ },
265
+ qstash: {
266
+ injectEnv: 'QSTASH_URL',
267
+ note: 'no injector entry: @upstash/qstash is base-URL-configured — the SDK reads QSTASH_URL (verified live, pack README).',
268
+ },
269
+ upstashworkflow: {
270
+ injectEnv: 'QSTASH_URL',
271
+ note: 'no injector entry: @upstash/workflow\'s Client IS a QStash client, so it reads the same QSTASH_URL (pack README).',
272
+ },
273
+ // (inngest moved to its pack descriptor's `endpointEnv` — the TWIN-PACK-CONTRACT exemplar.)
274
+ 'ai-gateway': {
275
+ injectEnv: 'AI_GATEWAY_BASE_URL',
276
+ note: 'no injector entry: @ai-sdk/gateway is base-URL-configured through AI_GATEWAY_BASE_URL.',
277
+ },
278
+ supabase: {
279
+ injectEnv: 'SUPABASE_MGMT_TWIN_URL',
280
+ note: 'CONTROL PLANE ONLY — this twin is the Supabase MANAGEMENT API. The DATA plane (Postgres/PostgREST/Storage) '
281
+ + 'is the REAL local Supabase stack: see the `//infra` supabase stub, fill in its `supabase start` lifecycle, and '
282
+ + 'move it into "services". Until then the proof reports supabase UNINTERCEPTABLE, which is the truth.',
283
+ },
284
+ livekit: {
285
+ injectEnvTemplates: { LIVEKIT_URL: 'ws://${host}:${port}' },
286
+ note: 'no injector entry: LIVEKIT_URL is a ws:// endpoint the SDK takes directly — WebSocket media is outside the HTTP injector\'s reach.',
287
+ },
288
+ // RAW PROTOCOL. Not "no injector entry yet" — there is no host to map at all: SMTP is a line
289
+ // protocol over a raw TCP socket, so the injector's http/fetch patches never see it, and the
290
+ // relay hostname is whatever the operator configured rather than a fixed vendor host. The env
291
+ // vars ARE the interception, and every one below is a name a real client documents, not an
292
+ // invention: SMTP_HOST/SMTP_PORT is nodemailer's own transport config and the classic pair every
293
+ // framework reads; EMAIL_SERVER_HOST/EMAIL_SERVER_PORT is Cal.com's split form; EMAIL_SERVER is
294
+ // NextAuth's connection-URL form (its Email provider takes `server` as a `smtp://` URL).
295
+ // Emitting all three shapes is deliberate — an app reads one of them, and which one is not
296
+ // knowable from the outside.
297
+ smtp: {
298
+ injectEnvTemplates: {
299
+ SMTP_HOST: '${host}',
300
+ SMTP_PORT: '${port}',
301
+ EMAIL_SERVER_HOST: '${host}',
302
+ EMAIL_SERVER_PORT: '${port}',
303
+ EMAIL_SERVER: 'smtp://${host}:${port}',
304
+ },
305
+ note: 'no injector entry and none possible: SMTP is a RAW TCP protocol, invisible to the http/fetch injector, '
306
+ + 'with no fixed vendor host. Interception is the app-read env itself — point SMTP_HOST/SMTP_PORT (or Cal.com\'s '
307
+ + 'EMAIL_SERVER_HOST/PORT, or NextAuth\'s EMAIL_SERVER url) at the twin\'s listener and an unmodified nodemailer '
308
+ + 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
309
+ + 'GET http://127.0.0.1:N/twin/messages/latest.',
310
+ },
311
+ };
312
+ // TWIN-PACK-CONTRACT migration: packs now declare their endpoint-env wiring (with its grounding
313
+ // note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
314
+ // entries move. The overlay throws on a vendor declared in both homes.
315
+ overlayEndpointEnv(APP_READ_ENDPOINT_ENV);
316
+
317
+ /** The canonical wiring for one vendor's twin service, derived from the injector where possible. */
318
+ function wiringFor(vendor: string): {
319
+ wiring: Exclude<InitWiring, 'no-pack'>;
320
+ injectEnv?: string;
321
+ injectEnvTemplates?: Record<string, string>;
322
+ cliRedirect?: Record<string, string>;
323
+ serviceEnv?: Record<string, string>;
324
+ note: string;
325
+ } {
326
+ // RULE 2: ask the injector, never guess. `aws` yields s3/dynamodb/timestream here — the very keys
327
+ // the inert `AWS_TWIN_URL` does not.
328
+ const keys = injectorVendorKeysFor(vendor);
329
+ const twinUrlNames = keys.map(injectorEnvNameForKey);
330
+ if (twinUrlNames.length > 0) {
331
+ const [primary, ...rest] = twinUrlNames as [string, ...string[]];
332
+ return {
333
+ wiring: 'injector',
334
+ injectEnv: primary,
335
+ ...(rest.length ? { injectEnvTemplates: Object.fromEntries(rest.map((name) => [name, '${url}'])) } : {}),
336
+ ...(vendor === 'xai' ? {
337
+ cliRedirect: {
338
+ GROK_CLI_CHAT_PROXY_BASE_URL: '${url}/v1',
339
+ XAI_TWIN_AUTH_BASE_URL: '${url}',
340
+ },
341
+ serviceEnv: { TWIN_XAI_AUTH_SEAM: 'sealed' },
342
+ } : {}),
343
+ note: rest.length
344
+ ? `the injector redirects this vendor under ${twinUrlNames.join(' / ')} — one consolidated twin answers for all of them.`
345
+ : vendor === 'xai'
346
+ ? 'zero-edit for SDK traffic through the injector; the real Grok CLI documents GROK_CLI_CHAT_PROXY_BASE_URL for its chat transport.'
347
+ : 'zero-edit: the injector redirects this vendor\'s hosts to the twin.',
348
+ };
349
+ }
350
+ const appRead = APP_READ_ENDPOINT_ENV[vendor];
351
+ if (appRead) {
352
+ return {
353
+ wiring: 'app-read',
354
+ ...(appRead.injectEnv ? { injectEnv: appRead.injectEnv } : {}),
355
+ ...(appRead.injectEnvTemplates ? { injectEnvTemplates: appRead.injectEnvTemplates } : {}),
356
+ note: appRead.note,
357
+ };
358
+ }
359
+ return {
360
+ wiring: 'explicit-config',
361
+ note: 'no injector entry and no conventional endpoint env — point the SDK at this twin through its own client option '
362
+ + `(baseUrl / serverUrl / host). Get the URL with \`volter-world url <world> ${vendor}\`. The proof reports this vendor `
363
+ + 'UNINTERCEPTABLE until you do; --acknowledge it once the client is wired.',
364
+ };
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------------------------
368
+ // Env-file emission
369
+ // ---------------------------------------------------------------------------------------------
370
+
371
+ /** Example/dev env files, in the order a repo's own conventions put them. The first file that
372
+ * defines a NAME wins, so an explicit `.env.example` beats a stray `.env.development`. */
373
+ const ENV_SOURCE_FILES = [
374
+ '.env.example',
375
+ // wasp splits its example env by tier; `covers` already detects the names inside them, so
376
+ // omitting them left app.env empty for a repo the scanner could see perfectly well (F5).
377
+ '.env.server.example',
378
+ '.env.client.example',
379
+ '.env.sample',
380
+ '.env.template',
381
+ '.env.dev.example',
382
+ '.env.development.example',
383
+ '.env.local.example',
384
+ '.env.dev',
385
+ '.env.development',
386
+ '.env',
387
+ ];
388
+
389
+ /** Infra connection strings — the local dependencies a twin never models. Matched on an infra
390
+ * KEYWORD plus a connection-string suffix, so `REDIS_URL` and `SHADOW_DATABASE_URL` land here while
391
+ * `UPSTASH_REDIS_REST_URL` (a hosted vendor, `_REST_URL`) is classified as a credential first. */
392
+ const INFRA_ENV = /(^|_)(DATABASE|POSTGRES|POSTGRESQL|PG|MYSQL|MARIADB|MONGO|MONGODB|REDIS|VALKEY|RABBITMQ|AMQP|KAFKA|NATS|CLICKHOUSE|ELASTICSEARCH|OPENSEARCH|MEMCACHED)[A-Z0-9_]*_(URL|URI|DSN|CONNECTION_STRING)$/;
393
+
394
+ /** A connection string's own scheme → the infra kind it names, or null. The VALUE is better
395
+ * evidence than the name: `DATABASE_URL` is generic (dub's is `mysql://`), so a name-only reading
396
+ * emits a postgres service for a MySQL app and nothing works. */
397
+ const SCHEME_KIND: Record<string, string> = {
398
+ postgres: 'postgres', postgresql: 'postgres', pg: 'postgres',
399
+ mysql: 'mysql', mariadb: 'mysql',
400
+ mongodb: 'mongodb', 'mongodb+srv': 'mongodb',
401
+ redis: 'redis', rediss: 'redis', valkey: 'redis',
402
+ amqp: 'rabbitmq', amqps: 'rabbitmq',
403
+ kafka: 'kafka', nats: 'nats', clickhouse: 'clickhouse',
404
+ };
405
+ function schemeKindFor(value: string): string | null {
406
+ const scheme = value.trim().match(/^([a-z][a-z0-9+.-]*):\/\//i);
407
+ return scheme === null ? null : SCHEME_KIND[scheme[1]!.toLowerCase()] ?? null;
408
+ }
409
+
410
+ /** name (+ its value, when the example file carries a real connection string) → the infra kind it
411
+ * signals, or null. */
412
+ function infraKindFor(name: string, value = ''): string | null {
413
+ if (!INFRA_ENV.test(name)) return null;
414
+ const fromScheme = schemeKindFor(value);
415
+ if (fromScheme !== null) return fromScheme;
416
+ if (/(^|_)(POSTGRES|POSTGRESQL|PG|DATABASE)/.test(name)) return 'postgres';
417
+ if (/(^|_)(MYSQL|MARIADB)/.test(name)) return 'mysql';
418
+ if (/(^|_)(MONGO|MONGODB)/.test(name)) return 'mongodb';
419
+ if (/(^|_)(REDIS|VALKEY)/.test(name)) return 'redis';
420
+ if (/(^|_)(RABBITMQ|AMQP)/.test(name)) return 'rabbitmq';
421
+ if (/(^|_)KAFKA/.test(name)) return 'kafka';
422
+ if (/(^|_)NATS/.test(name)) return 'nats';
423
+ if (/(^|_)CLICKHOUSE/.test(name)) return 'clickhouse';
424
+ if (/(^|_)(ELASTICSEARCH|OPENSEARCH)/.test(name)) return 'search';
425
+ return 'memcached';
426
+ }
427
+
428
+ /** A documented, unmistakably-not-real placeholder for an infra URL: the right SHAPE (so an app that
429
+ * parses the URL at import time still starts) with `REPLACE_ME` where the operator must decide. */
430
+ const INFRA_PLACEHOLDER: Record<string, string> = {
431
+ postgres: 'postgres://REPLACE_ME:REPLACE_ME@127.0.0.1:5432/REPLACE_ME',
432
+ mysql: 'mysql://REPLACE_ME:REPLACE_ME@127.0.0.1:3306/REPLACE_ME',
433
+ mongodb: 'mongodb://127.0.0.1:27017/REPLACE_ME',
434
+ redis: 'redis://127.0.0.1:6379',
435
+ rabbitmq: 'amqp://REPLACE_ME:REPLACE_ME@127.0.0.1:5672',
436
+ kafka: '127.0.0.1:9092',
437
+ nats: 'nats://127.0.0.1:4222',
438
+ clickhouse: 'http://127.0.0.1:8123',
439
+ search: 'http://127.0.0.1:9200',
440
+ memcached: '127.0.0.1:11211',
441
+ };
442
+
443
+ // ---------------------------------------------------------------------------------------------
444
+ // Infra compose emission
445
+ // ---------------------------------------------------------------------------------------------
446
+ //
447
+ // The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
448
+ // minutes go to the app side — most avoidably, to hand-writing infrastructure for the Postgres/
449
+ // MySQL/Redis the repo signalled. For the kinds below, `init` upgrades the placeholder to a
450
+ // World-managed service and private definition with env URLs already pointing at it. The helper
451
+ // owns its implementation behind the declared service boundary. Kinds without a recipe keep the
452
+ // placeholder + `//infra` stub.
453
+
454
+ /** The compose recipes. Versions are CONSERVATIVE major pins (current LTS-grade, not latest):
455
+ * a pilot wants "boots everywhere", not "newest features" — bump per pilot if the app demands. */
456
+ const COMPOSE_INFRA: Record<string, {
457
+ image: string;
458
+ containerPort: number;
459
+ memoryMiB: number;
460
+ /** the container path a named volume persists, or null for none */
461
+ volumePath: string | null;
462
+ /** healthcheck exec-form argv (makes `docker compose up -d --wait` meaningful) */
463
+ healthcheck: (user: string) => string[];
464
+ }> = {
465
+ postgres: {
466
+ image: 'postgres:16',
467
+ containerPort: 5432,
468
+ memoryMiB: 1024,
469
+ volumePath: '/var/lib/postgresql/data',
470
+ healthcheck: (user) => ['CMD-SHELL', `pg_isready -U ${user}`],
471
+ },
472
+ mysql: {
473
+ image: 'mysql:8.0',
474
+ containerPort: 3306,
475
+ memoryMiB: 1024,
476
+ volumePath: '/var/lib/mysql',
477
+ healthcheck: () => ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --silent'],
478
+ },
479
+ redis: {
480
+ image: 'redis:7',
481
+ containerPort: 6379,
482
+ memoryMiB: 256,
483
+ volumePath: '/data',
484
+ healthcheck: () => ['CMD', 'redis-cli', 'ping'],
485
+ },
486
+ };
487
+
488
+ /** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
489
+ function fnv1a(text: string): number {
490
+ let hash = 0x811c9dc5;
491
+ for (let index = 0; index < text.length; index += 1) {
492
+ hash ^= text.charCodeAt(index);
493
+ hash = Math.imul(hash, 0x01000193) >>> 0;
494
+ }
495
+ return hash >>> 0;
496
+ }
497
+
498
+ /** 'auto' ports, deterministically: hashed from `<world>:<kind>` into 20000–39999 (above the
499
+ * well-known infra defaults, below the common ephemeral range), probing upward on an in-file
500
+ * collision. Same world name ⇒ same ports (the byte-identity claim holds); two pilots with
501
+ * different names get different ports and run side by side. NOT a liveness check — init never
502
+ * binds sockets; a real clash surfaces during World-managed infrastructure startup. */
503
+ function composePort(name: string, kind: string, taken: Set<number>): number {
504
+ let port = 20000 + (fnv1a(`${name}:${kind}`) % 20000);
505
+ while (taken.has(port)) port = port === 39999 ? 20000 : port + 1;
506
+ taken.add(port);
507
+ return port;
508
+ }
509
+
510
+ /** The world name as a conservative identifier for db/user/password names inside the compose
511
+ * services (world names allow `.`/`-`, SQL identifiers and URL userinfo are happier without). */
512
+ function composeIdent(name: string): string {
513
+ return name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
514
+ }
515
+
516
+ /** The db user the compose service creates. mysql refuses MYSQL_USER=root, so that one world
517
+ * name gets a suffix — the URL and the compose env both come from here, so they cannot drift. */
518
+ function composeUser(name: string, kind: string): string {
519
+ const ident = composeIdent(name);
520
+ return kind === 'mysql' && ident === 'root' ? 'root_app' : ident;
521
+ }
522
+
523
+ /** Compose project names must be `[a-z0-9][a-z0-9_-]*`; world names also allow `.` and uppercase. */
524
+ function composeProject(name: string): string {
525
+ return `${name.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}-infra`;
526
+ }
527
+
528
+ function composeService(name: string, kind: string, signals: string[], taken: Set<number>): InitComposeService {
529
+ const recipe = COMPOSE_INFRA[kind]!;
530
+ const hostPort = composePort(name, kind, taken);
531
+ const ident = composeIdent(name);
532
+ const url = kind === 'redis'
533
+ ? `redis://127.0.0.1:${hostPort}`
534
+ : `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
535
+ return {
536
+ kind,
537
+ image: recipe.image,
538
+ hostPort,
539
+ containerPort: recipe.containerPort,
540
+ memoryMiB: recipe.memoryMiB,
541
+ volume: recipe.volumePath === null ? null : `${kind}-data`,
542
+ url,
543
+ signals,
544
+ };
545
+ }
546
+
547
+ /** The declared env of one compose service (empty for redis). */
548
+ function composeEnvironment(name: string, kind: string): Array<[string, string]> {
549
+ const ident = composeIdent(name);
550
+ if (kind === 'postgres') {
551
+ return [['POSTGRES_USER', ident], ['POSTGRES_PASSWORD', ident], ['POSTGRES_DB', ident]];
552
+ }
553
+ if (kind === 'mysql') {
554
+ return [['MYSQL_ROOT_PASSWORD', ident], ['MYSQL_DATABASE', ident], ['MYSQL_USER', composeUser(name, kind)], ['MYSQL_PASSWORD', ident]];
555
+ }
556
+ return [];
557
+ }
558
+
559
+ /** Render the private managed-infrastructure definition — deterministic, with no absolute paths.
560
+ * Only meaningful when `plan.compose` is non-empty (writeWorldInit skips it otherwise). */
561
+ export function renderComposeFile(plan: InitPlan): string {
562
+ const lines: string[] = [
563
+ `# ${plan.name} — private managed infrastructure for this World.`,
564
+ '# Do not operate this file directly; volter-world owns its complete lifecycle.',
565
+ '# Persistent bytes live under VOLTER_WORLD_DATA; runtime resources are reclaimed on down.',
566
+ `name: ${composeProject(plan.name)}`,
567
+ 'services:',
568
+ ];
569
+ for (const service of plan.compose) {
570
+ const recipe = COMPOSE_INFRA[service.kind]!;
571
+ lines.push(` ${service.kind}:`);
572
+ lines.push(` image: ${service.image}`);
573
+ lines.push(` mem_limit: ${service.memoryMiB}m`);
574
+ lines.push(` memswap_limit: ${service.memoryMiB}m`);
575
+ lines.push(' cpus: 1');
576
+ lines.push(' ports:');
577
+ lines.push(` - "127.0.0.1:${service.hostPort}:${service.containerPort}"`);
578
+ const environment = composeEnvironment(plan.name, service.kind);
579
+ if (environment.length > 0) {
580
+ lines.push(' environment:');
581
+ for (const [key, value] of environment) lines.push(` ${key}: ${value}`);
582
+ }
583
+ // a JSON array is a valid YAML flow sequence — exec-form healthchecks, no quoting surprises
584
+ lines.push(' healthcheck:');
585
+ lines.push(` test: ${JSON.stringify(recipe.healthcheck(composeUser(plan.name, service.kind)))}`);
586
+ lines.push(' interval: 2s');
587
+ lines.push(' timeout: 5s');
588
+ lines.push(' retries: 30');
589
+ if (service.volume !== null) {
590
+ lines.push(' volumes:');
591
+ lines.push(` - "\${VOLTER_WORLD_DATA:?}/${service.volume}:${recipe.volumePath}"`);
592
+ }
593
+ }
594
+ return `${lines.join('\n')}\n`;
595
+ }
596
+
597
+ type ParsedEnvEntry = { name: string; value: string; source: string };
598
+
599
+ /** Parse one dotenv-ish file into ordered NAME/value pairs. Deliberately conservative: the same
600
+ * `NAME=` line shape `projectEnvNames` detects, plus quote stripping, so the two views of a repo's
601
+ * env agree on which names exist. */
602
+ function parseEnvFile(text: string, source: string): ParsedEnvEntry[] {
603
+ const entries: ParsedEnvEntry[] = [];
604
+ for (const line of text.split(/\r?\n/)) {
605
+ const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$/);
606
+ if (!match) continue;
607
+ let value = match[2]!.trim();
608
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2)
609
+ || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
610
+ value = value.slice(1, -1);
611
+ } else {
612
+ // Unquoted: a `#` after whitespace opens a trailing comment (dotenv semantics). Without this
613
+ // a line like `NEXTAUTH_URL=http://localhost:3000 # dev` yields a value with the comment
614
+ // still attached, and every consumer that URL-parses it fails at runtime.
615
+ const comment = value.search(/\s#/);
616
+ if (comment !== -1) value = value.slice(0, comment).trimEnd();
617
+ }
618
+ entries.push({ name: match[1]!, value, source });
619
+ }
620
+ return entries;
621
+ }
622
+
623
+ /** Every example-env entry in the repo, first definition winning, in a deterministic order:
624
+ * root before workspace members (sorted), and within a dir the ENV_SOURCE_FILES priority. */
625
+ function repoEnvEntries(repo: string): ParsedEnvEntry[] {
626
+ const seen = new Set<string>();
627
+ const entries: ParsedEnvEntry[] = [];
628
+ for (const dir of projectManifestDirs(repo)) {
629
+ for (const file of ENV_SOURCE_FILES) {
630
+ const path = resolve(dir, file);
631
+ if (!existsSync(path)) continue;
632
+ const source = relative(repo, path) || file;
633
+ for (const entry of parseEnvFile(readFileSync(path, 'utf8'), source)) {
634
+ if (seen.has(entry.name)) continue;
635
+ seen.add(entry.name);
636
+ entries.push(entry);
637
+ }
638
+ }
639
+ }
640
+ return entries;
641
+ }
642
+
643
+ // ---------------------------------------------------------------------------------------------
644
+ // Planning
645
+ // ---------------------------------------------------------------------------------------------
646
+
647
+ function assertSafeName(name: string): void {
648
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
649
+ throw new Error(`volter-world init: invalid world name "${name}" (letters, digits, then . _ -)`);
650
+ }
651
+ }
652
+
653
+ /** Is `candidate` inside `parent` (or the same dir)? The pristine-repo guard. */
654
+ function isInside(parent: string, candidate: string): boolean {
655
+ const rel = relative(resolve(parent), resolve(candidate));
656
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
657
+ }
658
+
659
+ export function planWorldInit(name: string, repoPath: string, options: InitOptions = {}): InitPlan {
660
+ assertSafeName(name);
661
+ const root = resolve(options.root ?? process.cwd());
662
+ const repo = resolve(repoPath);
663
+ if (!existsSync(repo)) throw new Error(`volter-world init: --repo path does not exist: ${repo}`);
664
+ const out = resolve(options.out ?? resolve(repo, '..', `${name}-pilot`));
665
+ // RULE 1: the repo stays pristine. Emitting into it would make adoption a commit the app repo has
666
+ // to accept — and would put fake credentials inside a tree someone will `git add -A`.
667
+ if (isInside(repo, out)) {
668
+ throw new Error(
669
+ `volter-world init: --out ${out} is inside --repo ${repo}. init never writes into the application repo `
670
+ + `(the pilot is disposable and the repo stays pristine) — choose a directory outside it.`,
671
+ );
672
+ }
673
+
674
+ const catalog = new Set(packCatalog(root));
675
+ // Pack defaults (defaults/handlers.json, defaults/seed.ts): copied VISIBLY into the world
676
+ // dir — the author's to edit or delete (copy = ownership; never baked into the pack).
677
+ const defaultsCopies: Array<{ vendor: string; kind: 'handlers' | 'seed'; from: string; to: string }> = [];
678
+ const { detected, unknown } = detectRepoVendors(repo);
679
+
680
+ const vendors: InitVendorPlan[] = [];
681
+ const services: WorldServiceConfig[] = [];
682
+ /** endpoint env name → the service that already claims it (QSTASH_URL is wanted by two packs) */
683
+ const claimedEnv = new Map<string, string>();
684
+ for (const vendor of [...detected.keys()].sort()) {
685
+ const detectedVia = [...(detected.get(vendor) ?? [])].sort();
686
+ if (!catalog.has(vendor)) {
687
+ vendors.push({
688
+ vendor,
689
+ pack: null,
690
+ detectedVia,
691
+ wiring: 'no-pack',
692
+ service: null,
693
+ note: `no packages/twin/${vendor} pack in this catalog — the proof reports it MISSING until one exists.`,
694
+ });
695
+ continue;
696
+ }
697
+ const wiring = wiringFor(vendor);
698
+ // Two packs can want the SAME endpoint env (qstash and upstashworkflow both read QSTASH_URL).
699
+ // One world cannot point that var at two twins, so the first claimant keeps it and the second
700
+ // is emitted WITHOUT it — which the proof then reports UNINTERCEPTABLE. Silently letting the
701
+ // later service overwrite the var would send one vendor's traffic to the other's twin.
702
+ const conflict = wiring.injectEnv !== undefined ? claimedEnv.get(wiring.injectEnv) : undefined;
703
+ const injectEnv = conflict === undefined ? wiring.injectEnv : undefined;
704
+ if (injectEnv !== undefined) claimedEnv.set(injectEnv, vendor);
705
+ const note = conflict === undefined
706
+ ? wiring.note
707
+ : `${wiring.note} CONFLICT: ${wiring.injectEnv} is already claimed by the "${conflict}" service, so this twin is emitted `
708
+ + `without it — one world cannot point ${wiring.injectEnv} at two twins. Decide which vendor that var serves, or run them in separate worlds.`;
709
+ const defaultsHandlersPath = join(root, 'packages', 'twin', vendor, 'defaults', 'handlers.json');
710
+ const handlersTo = existsSync(defaultsHandlersPath) ? resolve(out, 'handlers', `${vendor}.json`) : null;
711
+ if (handlersTo !== null) defaultsCopies.push({ vendor, kind: 'handlers', from: defaultsHandlersPath, to: handlersTo });
712
+ const defaultsSeedPath = join(root, 'packages', 'twin', vendor, 'defaults', 'seed.ts');
713
+ if (existsSync(defaultsSeedPath)) defaultsCopies.push({ vendor, kind: 'seed', from: defaultsSeedPath, to: resolve(out, 'seeds', 'defaults', `${vendor}.ts`) });
714
+ // COLOCATION (runtime contract R2a): a pack whose serve factory is known (derived into the
715
+ // pack-facts artifact) is emitted with `colocate`, so `up` boots it inside the ONE
716
+ // volter-world-host process instead of spawning a process per twin. The spawn `command`
717
+ // stays alongside as the process-isolation path. A service carrying a --scenario file
718
+ // keeps the spawn path for now: the colocate factory contract has no scenarioPath yet,
719
+ // and silently dropping the world's handlers would be a fake success.
720
+ const serveExport = handlersTo === null ? packFacts()[vendor]?.serveExport : undefined;
721
+ const service: WorldServiceConfig = {
722
+ '//': note,
723
+ id: vendor,
724
+ type: 'twin',
725
+ command: 'bun',
726
+ args: [`packages/twin/${vendor}/src/cli.ts`, 'serve', ...(handlersTo !== null ? ['--scenario', handlersTo] : [])],
727
+ ...(serveExport !== undefined ? { colocate: { module: `./packages/twin/${vendor}/src/index.ts`, export: serveExport } } : {}),
728
+ port: 'auto',
729
+ ...(injectEnv === undefined ? {} : { injectEnv }),
730
+ ...(conflict === undefined && wiring.injectEnvTemplates ? { injectEnvTemplates: wiring.injectEnvTemplates } : {}),
731
+ ...(conflict === undefined && wiring.cliRedirect ? { cliRedirect: wiring.cliRedirect } : {}),
732
+ ...(wiring.serviceEnv ? { env: wiring.serviceEnv } : {}),
733
+ };
734
+ services.push(service);
735
+ vendors.push({
736
+ vendor,
737
+ pack: vendor,
738
+ detectedVia,
739
+ wiring: conflict === undefined ? wiring.wiring : 'explicit-config',
740
+ service,
741
+ note,
742
+ });
743
+ }
744
+
745
+ // --- env ------------------------------------------------------------------------------------
746
+ const env: Record<string, string> = {};
747
+ const envRows: InitEnvRow[] = [];
748
+ const envSources: string[] = [];
749
+ const bootRisks: InitBootRisk[] = [];
750
+ const envReads = projectEnvReads(repo);
751
+ const mintedEnv: string[] = [];
752
+ const infraSignals = new Map<string, string[]>();
753
+ const infraEntries: Array<{ name: string; source: string; kind: string }> = [];
754
+ for (const entry of repoEnvEntries(repo)) {
755
+ if (!envSources.includes(entry.source)) envSources.push(entry.source);
756
+ const { name, value, source } = entry;
757
+ // World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
758
+ // stale copy would shadow the live one.
759
+ if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name)) {
760
+ envRows.push({ name, disposition: 'unknown', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
761
+ continue;
762
+ }
763
+ // STRUCTURED credential shapes first: `GOOGLE_VERTEX_JSON` / `GOOGLE_API_CREDENTIALS` carry a
764
+ // whole JSON document the client SDK PARSES (and signs with) before any request leaves the
765
+ // process, and neither name ends in a credential SUFFIX — so the suffix heuristics below are
766
+ // structurally blind to exactly the credentials that must be faked most carefully. `fakeEnvValue`
767
+ // already knows both shapes; this asks it the same question it answers.
768
+ if (isGoogleServiceAccountEnvName(name) || isGoogleOAuthClientEnvName(name)) {
769
+ env[name] = fakeEnvValue(name);
770
+ const minted = isGoogleServiceAccountEnvName(name);
771
+ if (minted) mintedEnv.push(name);
772
+ envRows.push({
773
+ name,
774
+ disposition: 'faked',
775
+ source,
776
+ reason: minted
777
+ ? 'Google service-account JSON — structurally valid fake with a freshly MINTED throwaway RSA key the SDK can genuinely sign with (the one value that differs between runs)'
778
+ : 'Google OAuth client JSON — structurally valid fake (the {"web":{…}} shape the consumer parses)',
779
+ });
780
+ continue;
781
+ }
782
+ const vendor = envNameVendor(name);
783
+ if (vendor !== null) {
784
+ env[name] = fakeEnvValue(name);
785
+ envRows.push({ name, disposition: 'faked', source, reason: `${vendor} credential` });
786
+ continue;
787
+ }
788
+ const infraKind = infraKindFor(name, value);
789
+ if (infraKind !== null) {
790
+ // DEFERRED: the value depends on whether this kind gets a compose service (a URL pointing
791
+ // at it) or stays a documented placeholder — decided below, once every signal is in.
792
+ infraSignals.set(infraKind, [...(infraSignals.get(infraKind) ?? []), name]);
793
+ infraEntries.push({ name, source, kind: infraKind });
794
+ continue;
795
+ }
796
+ // RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
797
+ // app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
798
+ // reason a live key committed to `.env.example` can never reach the emitted world.
799
+ if (isCredentialShapedEnvName(name)) {
800
+ env[name] = fakeEnvValue(name);
801
+ envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
802
+ continue;
803
+ }
804
+ if (value !== '') {
805
+ env[name] = value;
806
+ envRows.push({ name, disposition: 'kept', source, reason: 'non-secret app config — copied verbatim' });
807
+ continue;
808
+ }
809
+ env[name] = '';
810
+ const readAt = envReads.get(name) ?? [];
811
+ if (readAt.length > 0) {
812
+ bootRisks.push({ name, source, readAt });
813
+ envRows.push({
814
+ name,
815
+ disposition: 'unknown',
816
+ source,
817
+ reason: `BOOT RISK — empty in the example but read by production source at ${readAt.join(', ')}; the app may reject it before listening`,
818
+ });
819
+ } else {
820
+ envRows.push({ name, disposition: 'unknown', source, reason: 'empty in the example and not credential-shaped — you decide' });
821
+ }
822
+ }
823
+
824
+ // --- infra ----------------------------------------------------------------------------------
825
+ // Kinds with a managed recipe get a private definition next to the config, a declared external
826
+ // service whose lifecycle belongs to World, and env URLs pointing at it. Recipe-less kinds keep
827
+ // the documented placeholder + `//infra` stub.
828
+ const takenPorts = new Set<number>();
829
+ const compose: InitComposeService[] = [...infraSignals.entries()]
830
+ .filter(([kind]) => COMPOSE_INFRA[kind] !== undefined)
831
+ .sort(([a], [b]) => a.localeCompare(b))
832
+ .map(([kind, signals]) => composeService(name, kind, [...signals].sort(), takenPorts));
833
+ const composeByKind = new Map(compose.map((service) => [service.kind, service]));
834
+ for (const entry of infraEntries) {
835
+ const composed = composeByKind.get(entry.kind);
836
+ if (composed !== undefined) {
837
+ env[entry.name] = composed.url;
838
+ envRows.push({
839
+ name: entry.name,
840
+ disposition: 'compose',
841
+ source: entry.source,
842
+ reason: `${entry.kind} connection string — points at the World-managed infrastructure service`,
843
+ });
844
+ } else {
845
+ env[entry.name] = INFRA_PLACEHOLDER[entry.kind] ?? 'REPLACE_ME';
846
+ envRows.push({ name: entry.name, disposition: 'placeholder', source: entry.source, reason: `${entry.kind} connection string — no twin models it; point it at your own local instance` });
847
+ }
848
+ }
849
+
850
+ const infra: InitInfraStub[] = [...infraSignals.entries()]
851
+ .filter(([kind]) => !composeByKind.has(kind))
852
+ .sort(([a], [b]) => a.localeCompare(b))
853
+ .map(([kind, signals]) => ({
854
+ kind,
855
+ signals: [...signals].sort(),
856
+ stub: {
857
+ '//': `${signals.sort().join(', ')} signal${signals.length > 1 ? '' : 's'} ${kind}, which no twin models. `
858
+ + `Fill in the up/status/down commands for YOUR local ${kind} service, `
859
+ + 'then MOVE this object into "services" so `volter-world up`/`down` own its lifecycle. '
860
+ + 'Add `external.discover` entries if the tool prints the connection URL; otherwise keep the placeholder in the env file. '
861
+ + 'See WORLD.md § the external service type.',
862
+ id: kind,
863
+ type: 'external',
864
+ external: {
865
+ up: ['REPLACE_ME'],
866
+ status: ['REPLACE_ME'],
867
+ down: ['REPLACE_ME'],
868
+ },
869
+ },
870
+ }));
871
+
872
+ // `//infra` is a documented, runtime-ignored sidecar rather than a `services` entry ON PURPOSE:
873
+ // an `external` service with placeholder commands passes no schema check worth having and would
874
+ // make `up` fail on a config init just told the operator to boot. Stubs sit outside `services`
875
+ // until they are real.
876
+ if (compose.length > 0) {
877
+ services.push({
878
+ '//': `World-managed infrastructure (${compose.map((service) => service.kind).join(', ')}); lifecycle and diagnostics stay behind this declared service boundary.`,
879
+ id: 'infrastructure',
880
+ type: 'external',
881
+ controlPlane: true,
882
+ external: {
883
+ up: ['volter-world-managed-infra', 'up'],
884
+ status: ['volter-world-managed-infra', 'status'],
885
+ down: ['volter-world-managed-infra', 'down'],
886
+ readyWhen: { command: 'volter-world-managed-infra', args: ['status'], timeoutMs: 120000, intervalMs: 500 },
887
+ },
888
+ });
889
+ }
890
+
891
+ const config: WorldConfig & { '//infra'?: unknown[]; catalog?: { sha: string } } = {
892
+ id: name,
893
+ // THE BIRTH STAMP (architecture gap #2, ratified): the catalog version this world was
894
+ // created under. Replay/upgrade decisions are impossible to make honestly without it,
895
+ // and retrofitting provenance later is the expensive version. 'unknown' only when the
896
+ // checkout is not a git tree (an installed package) — still a fact, recorded.
897
+ // (sha only — init's output is BYTE-DETERMINISTIC by contract, so no wall-clock here;
898
+ // the serverless provision door, an operator act, stamps createdAt on its side.)
899
+ catalog: { sha: catalogSha(root) },
900
+ description: `Pilot world generated by \`volter-world init\`: one twin per external vendor the application repo talks to, `
901
+ + `fake credentials, and documented stubs for the infra it signals. Regenerate with \`volter-world init\` — do not hand-edit `
902
+ + `what init can re-derive; DO fill in the //infra stubs and move them into "services".`,
903
+ resources: { memoryMiB: 2048, writableStorageMiB: 8192 },
904
+ // One twin process per WORLD, not per vendor (runtime contract R2a): every colocatable
905
+ // service boots inside the volter-world-host. Local-mode preference only — share/sealed
906
+ // boots downgrade to per-process isolation (their TWIN-67 contract) in runtime.ts.
907
+ ...(services.some((s) => s.colocate !== undefined) ? { isolation: 'colocated' as const } : {}),
908
+ env: Object.fromEntries(Object.keys(env).sort().map((key) => [key, env[key]!])),
909
+ services,
910
+ ...(infra.length ? { '//infra': infra.map((entry) => entry.stub) } : {}),
911
+ };
912
+
913
+ return {
914
+ name,
915
+ repo,
916
+ out,
917
+ configPath: resolve(out, 'world.config.json'),
918
+ envPath: resolve(out, 'app.env'),
919
+ defaultsCopies,
920
+ seedEntry: defaultsCopies.some((c) => c.kind === 'seed')
921
+ ? { entryPath: resolve(out, 'seed.ts'), storyPath: resolve(out, 'seeds', 'story.ts') }
922
+ : null,
923
+ config,
924
+ env: config.env as Record<string, string>,
925
+ vendors,
926
+ envRows,
927
+ envSources,
928
+ bootRisks: [...bootRisks].sort((a, b) => a.name.localeCompare(b.name)),
929
+ infra,
930
+ compose,
931
+ composePath: compose.length > 0 ? resolve(out, 'world.infrastructure.yml') : null,
932
+ // Standing registry acknowledgments leave the worklist but never vanish (each keeps its
933
+ // recorded reason) — the same partition coverWorld's proof applies to the same signals.
934
+ unknown: [...unknown.keys()].filter((name) => registryAcknowledgedReason(name) === null).sort(),
935
+ acknowledgedExternal: [...unknown.keys()]
936
+ .map((name) => ({ name, reason: registryAcknowledgedReason(name) }))
937
+ .filter((row): row is { name: string; reason: string } => row.reason !== null)
938
+ .sort((a, b) => a.name.localeCompare(b.name)),
939
+ mintedEnv: [...mintedEnv].sort(),
940
+ };
941
+ }
942
+
943
+ // ---------------------------------------------------------------------------------------------
944
+ // Emission
945
+ // ---------------------------------------------------------------------------------------------
946
+
947
+ /** The `app.env` rendering of the SAME map the config's `env` block holds — a dotenv for anything
948
+ * attached to the World (a shell, app process, or CI step), annotated with the
949
+ * disposition of every name so the file explains itself. Both artifacts are rendered from one map
950
+ * in one call, so they cannot drift. */
951
+ export function renderEnvFile(plan: InitPlan): string {
952
+ const byName = new Map(plan.envRows.map((row) => [row.name, row]));
953
+ const lines = [
954
+ `# ${plan.name} — env for the pilot world, generated by \`volter-world init\`.`,
955
+ '# Credential-shaped names hold FAKE values (a twin accepts any bearer token; names an SDK parses',
956
+ '# get a structurally valid fake). Values copied from the repo\'s example env are non-secret app',
957
+ '# config only. Infra URLs are placeholders — point them at your own local services.',
958
+ '# This file mirrors the "env" block of world.config.json, which is what `volter-world up` injects.',
959
+ ...(plan.mintedEnv.length
960
+ ? [
961
+ '#',
962
+ `# NOT REPRODUCIBLE (by design): ${plan.mintedEnv.join(', ')} hold${plan.mintedEnv.length === 1 ? 's' : ''} a freshly minted`,
963
+ '# throwaway private key, so re-running init produces a different value for these names and only these.',
964
+ '# Every other byte of this file is a pure function of the repo + the twin pack catalog.',
965
+ ]
966
+ : []),
967
+ '',
968
+ ];
969
+ for (const name of Object.keys(plan.env).sort()) {
970
+ const row = byName.get(name);
971
+ if (row) lines.push(`# ${row.disposition}: ${row.reason}`);
972
+ const value = plan.env[name]!;
973
+ // A value with a newline (a service-account JSON) or a leading/trailing space must be quoted to
974
+ // survive any dotenv reader; JSON.stringify gives exactly the escaping every one of them accepts.
975
+ lines.push(`${name}=${/[\n"']/.test(value) || value !== value.trim() ? JSON.stringify(value) : value}`);
976
+ }
977
+ return `${lines.join('\n')}\n`;
978
+ }
979
+
980
+ /** Write the plan's two artifacts. Byte-identical for the same repo + catalog: sorted keys, no
981
+ * timestamps, no absolute paths inside the files. */
982
+ /** The twin catalog's git SHA at `root`, or 'unknown' for a non-git install. */
983
+ function catalogSha(root: string): string {
984
+ const proc = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
985
+ const sha = proc.status === 0 ? proc.stdout.trim() : '';
986
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : 'unknown';
987
+ }
988
+
989
+ export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}): void {
990
+ const existing = existsSync(plan.out) ? readdirSync(plan.out) : [];
991
+ if (existing.length > 0 && options.force !== true) {
992
+ throw new Error(
993
+ `volter-world init: ${plan.out} already exists and is not empty (${existing.length} entr${existing.length === 1 ? 'y' : 'ies'}). `
994
+ + 'Pass --force to overwrite it, or --out <dir> to emit elsewhere.',
995
+ );
996
+ }
997
+ mkdirSync(plan.out, { recursive: true });
998
+ writeFileSync(plan.configPath, `${JSON.stringify(plan.config, null, 2)}\n`);
999
+ writeFileSync(plan.envPath, renderEnvFile(plan));
1000
+ if (plan.composePath !== null) writeFileSync(plan.composePath, renderComposeFile(plan));
1001
+ // Pack defaults, copied byte-for-byte into the world dir. Ownership transfers on copy: these
1002
+ // are the author's files now (edit or delete; regenerate a fresh world for fresh defaults).
1003
+ for (const copy of plan.defaultsCopies) {
1004
+ mkdirSync(dirname(copy.to), { recursive: true });
1005
+ writeFileSync(copy.to, readFileSync(copy.from));
1006
+ }
1007
+ if (plan.seedEntry !== null) {
1008
+ mkdirSync(dirname(plan.seedEntry.storyPath), { recursive: true });
1009
+ writeFileSync(plan.seedEntry.storyPath,
1010
+ `// Your world's STORY — the cross-vendor narrative (the only seed you write).\n`
1011
+ + `// Seed state through each vendor's ORDINARY SDK/API pointed at its *_TWIN_URL.\n`
1012
+ + `export async function story(): Promise<void> {\n // e.g. post the opening support thread, create the incident, ...\n}\n`);
1013
+ const seedImports = plan.defaultsCopies.filter((c) => c.kind === 'seed');
1014
+ writeFileSync(plan.seedEntry.entryPath,
1015
+ `// THE seed entry point — composes order VISIBLY (no magic globbing): defaults, then story.\n`
1016
+ + `// Run it AFTER EVERY \`volter-world up\` (the runtime has no startup hooks, by rule, and\n`
1017
+ + `// an instance's state lives only while the world runs — up starts clean; the world dir is\n`
1018
+ + `// the reproducible story). From your APP repo's directory, so its node_modules resolve\n`
1019
+ + `// any vendor SDK a seed imports: volter-world attach <world> -- bun <this file>\n`
1020
+ + seedImports.map((c) => `import { seed as ${c.vendor.replace(/[^a-zA-Z0-9]/g, '_')}Defaults } from './seeds/defaults/${c.vendor}.ts';`).join('\n')
1021
+ + `\nimport { story } from './seeds/story.ts';\n\n`
1022
+ + seedImports.map((c) => `await ${c.vendor.replace(/[^a-zA-Z0-9]/g, '_')}Defaults();`).join('\n')
1023
+ + `\nawait story();\n`);
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Plan, emit, and PROVE. The coverage proof runs against the just-written config (by path, so it
1029
+ * reads exactly the bytes on disk rather than the in-memory plan) — `ok` is the proof's verdict and
1030
+ * the caller's exit code. Deliberately does NOT boot the world.
1031
+ */
1032
+ export function initWorld(name: string, repoPath: string, options: InitOptions = {}): InitResult {
1033
+ const plan = planWorldInit(name, repoPath, options);
1034
+ writeWorldInit(plan, { force: options.force ?? false });
1035
+ const proof = coverWorld(plan.configPath, plan.repo, {
1036
+ root: resolve(options.root ?? process.cwd()),
1037
+ allowUnknown: options.allowUnknown ?? false,
1038
+ acknowledge: options.acknowledge ?? {},
1039
+ });
1040
+ // The proof was addressed BY PATH (so it reads the bytes just written, not the in-memory plan);
1041
+ // report it under the world's id, which is what every other verb takes.
1042
+ const coverage: CoverageReport = { ...proof, world: plan.name };
1043
+ const next = [
1044
+ `volter-world up ${plan.configPath} --name ${plan.name} --env-file ${resolve(plan.out, 'world.env')} # up WRITES the live env (twin URLs) there; app.env is init's static preview of the same map`,
1045
+ ...(plan.seedEntry === null ? [] : [`volter-world attach ${plan.name} -- bun ${plan.seedEntry.entryPath} # seed AFTER every up, from your app repo's directory (its node_modules resolve the vendor SDKs); a world's state lives only while it runs`]),
1046
+ `volter-world env ${plan.name} -- <your app's dev command> # run the app inside the world`,
1047
+ `volter-world down ${plan.name}`,
1048
+ ];
1049
+ return { plan, coverage, ok: coverage.ok, next };
1050
+ }
1051
+
1052
+ // ---------------------------------------------------------------------------------------------
1053
+ // Reporting
1054
+ // ---------------------------------------------------------------------------------------------
1055
+
1056
+ function table(header: string[], rows: string[][]): string[] {
1057
+ const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column]!.length)));
1058
+ const render = (row: string[]): string => row.map((cell, column) => cell.padEnd(widths[column]!)).join(' | ').trimEnd();
1059
+ return [render(header), widths.map((width) => '-'.repeat(width)).join('-|-'), ...rows.map(render)];
1060
+ }
1061
+
1062
+ export function formatInitReport(result: InitResult): string {
1063
+ const { plan } = result;
1064
+ const lines: string[] = [];
1065
+ lines.push(`World ${plan.name}`);
1066
+ lines.push(`Repo ${plan.repo} (unmodified — nothing was written here)`);
1067
+ lines.push(`Config ${plan.configPath}`);
1068
+ lines.push(`Env ${plan.envPath}`);
1069
+ if (plan.composePath !== null) lines.push(`Infra World-managed (${plan.compose.length} service${plan.compose.length === 1 ? '' : 's'})`);
1070
+ if (plan.infra.length > 0) {
1071
+ // detected infra with no managed recipe must be SAID here, not only left
1072
+ // as a stub the operator may never open — silence reads as provisioned
1073
+ lines.push(`Infra NOT World-managed: ${plan.infra.map((stub) => stub.kind).join(', ')} — fill in the //infra stub${plan.infra.length === 1 ? '' : 's'} in the config (env holds a placeholder until then)`);
1074
+ }
1075
+ lines.push('');
1076
+ lines.push(`Vendors (${plan.vendors.length} detected, ${plan.vendors.filter((v) => v.service !== null).length} emitted):`);
1077
+ if (plan.vendors.length === 0) lines.push(' (no external vendor dependencies detected in the repo)');
1078
+ else {
1079
+ lines.push(...table(
1080
+ ['vendor', 'wiring', 'inject-env', 'detected-via'],
1081
+ plan.vendors.map((vendor) => [
1082
+ vendor.vendor,
1083
+ vendor.wiring,
1084
+ [vendor.service?.injectEnv, ...Object.keys(vendor.service?.injectEnvTemplates ?? {})].filter(Boolean).join(', ') || '-',
1085
+ vendor.detectedVia.join('; '),
1086
+ ]),
1087
+ ).map((line) => ` ${line}`));
1088
+ for (const vendor of plan.vendors) {
1089
+ if (vendor.wiring === 'injector') continue; // the zero-edit default needs no explanation
1090
+ lines.push(` · ${vendor.vendor}: ${vendor.note}`);
1091
+ }
1092
+ }
1093
+ lines.push('');
1094
+ const counts = (disposition: EnvDisposition): InitEnvRow[] => plan.envRows.filter((row) => row.disposition === disposition);
1095
+ lines.push(`Env (${plan.envRows.length} name(s) from ${plan.envSources.join(', ') || 'no example env file'}):`);
1096
+ for (const disposition of ['faked', 'kept', 'compose', 'placeholder', 'unknown'] as EnvDisposition[]) {
1097
+ const rows = counts(disposition);
1098
+ if (rows.length === 0) continue;
1099
+ lines.push(` ${disposition.padEnd(11)} ${String(rows.length).padStart(3)} ${rows.map((row) => row.name).join(', ')}`);
1100
+ }
1101
+ if (plan.mintedEnv.length > 0) {
1102
+ lines.push(` (${plan.mintedEnv.join(', ')} hold${plan.mintedEnv.length === 1 ? 's' : ''} a freshly minted throwaway key — the only value(s) that change between runs)`);
1103
+ }
1104
+ if (plan.bootRisks.length > 0) {
1105
+ lines.push('');
1106
+ lines.push(`BOOT RISKS (${plan.bootRisks.length} empty example value(s) read by production source):`);
1107
+ for (const risk of plan.bootRisks) {
1108
+ lines.push(` ${risk.name} — empty in ${risk.source}; read at ${risk.readAt.join(', ')}`);
1109
+ }
1110
+ lines.push(' Resolve these before expecting the app to listen. Vendor coverage can be green while the app rejects its own env.');
1111
+ }
1112
+ if (plan.compose.length > 0) {
1113
+ lines.push('');
1114
+ lines.push('Infrastructure (owned by the World lifecycle):');
1115
+ for (const service of plan.compose) {
1116
+ lines.push(` ${service.kind}: ${service.image} on 127.0.0.1:${service.hostPort} — signalled by ${service.signals.join(', ')}; env URLs point at it`);
1117
+ }
1118
+ }
1119
+ if (plan.infra.length > 0) {
1120
+ lines.push('');
1121
+ lines.push(`Infra stubs (documented under the config's "//infra" key — fill in and move into "services"):`);
1122
+ for (const stub of plan.infra) lines.push(` ${stub.kind}: signalled by ${stub.signals.join(', ')}`);
1123
+ }
1124
+ lines.push('');
1125
+ lines.push('--- coverage proof (init\'s exit code IS this proof\'s) ---');
1126
+ lines.push(formatCoverageReport(result.coverage).trimEnd());
1127
+ lines.push('');
1128
+ if (result.ok && plan.bootRisks.length > 0) {
1129
+ lines.push('COVERAGE READY; APP BOOT AT RISK — resolve the empty values above, then boot it yourself:');
1130
+ } else if (result.ok) {
1131
+ lines.push('READY. Boot it yourself — init diagnoses, it does not boot:');
1132
+ } else {
1133
+ lines.push('NOT READY — the worklist above is what stands between this pilot and a world that covers the repo.');
1134
+ lines.push('Once it is green (or every remaining finding is --acknowledge\'d with a reason), boot it with:');
1135
+ }
1136
+ for (const command of result.next) lines.push(` ${command}`);
1137
+ lines.push('');
1138
+ lines.push('The two moves: if the twin stores it, create it through the vendor\'s own API (seed.ts,');
1139
+ lines.push('as the person, clock first for history). Everything else — judgment, lookups, faults —');
1140
+ lines.push('is a handler in handlers/<vendor>.json. Every twin explains itself at GET <url>/twin.');
1141
+ return `${lines.join('\n')}\n`;
1142
+ }