@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/cli.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env bun
2
- import { activateScript, checkPrerequisites, doctorWorld, downWorld, formatPrerequisiteChecks, listWorlds, runWithWorldEnv, runWorld, shareWorldServices, shellWorld, statusWorld, unshareWorld, upWorld, urlsWorld } from './index.ts';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve, resolve as resolvePath } from 'node:path';
6
+ import { activateScript, approveWorldChangeset, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, planWorldActions, readReflectRoutes, reflectRoutesPath, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, CA_TRUST_ENV, ATTACHED_CA_PATH, replayWorldChangeset, resolveWorldRef, reviewWorldActions, runWithWorldEnv, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, upWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes } from './index.ts';
7
+ import { formatChangeset, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, parseVerifierExpression } from '@volter/twin';
8
+ import { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer } from './serve.ts';
9
+ import { clockFile, instanceDir } from './runtime.ts';
10
+ import { appUrlUnsetMessage, detectAppUrl, readAppUrl, setAppUrl } from './app-url.ts';
3
11
  import type { PrerequisiteId } from './index.ts';
4
12
 
5
13
  function optionValue(args: string[], name: string, fallback = ''): string {
@@ -7,21 +15,120 @@ function optionValue(args: string[], name: string, fallback = ''): string {
7
15
  return index >= 0 && index + 1 < args.length ? args[index + 1]! : fallback;
8
16
  }
9
17
 
18
+ /** The non-flag args, skipping each value-taking flag's value (so `--root <path>` never
19
+ * masquerades as a positional service id). */
20
+ function positionalArgs(args: string[], valueFlags: string[] = ['--root']): string[] {
21
+ const positionals: string[] = [];
22
+ for (let index = 0; index < args.length; index += 1) {
23
+ const arg = args[index]!;
24
+ if (arg.startsWith('--')) {
25
+ if (valueFlags.includes(arg)) index += 1;
26
+ continue;
27
+ }
28
+ positionals.push(arg);
29
+ }
30
+ return positionals;
31
+ }
32
+
33
+ /** `--acknowledge vendor=reason` (repeatable) — the shared parse for `covers` and `init`. */
34
+ function acknowledgeOptions(args: string[], command: string): Record<string, string> {
35
+ const acknowledge: Record<string, string> = {};
36
+ for (let index = 0; index < args.length; index += 1) {
37
+ if (args[index] !== '--acknowledge') continue;
38
+ const pair = args[index + 1] ?? '';
39
+ const eq = pair.indexOf('=');
40
+ if (eq <= 0) throw new Error(`volter-world ${command}: --acknowledge takes vendor=reason (e.g. --acknowledge "svix=verifier-only, no egress")`);
41
+ acknowledge[pair.slice(0, eq)] = pair.slice(eq + 1);
42
+ }
43
+ return acknowledge;
44
+ }
45
+
10
46
  function printHelp(): void {
11
47
  process.stdout.write(`Usage:
48
+ volter-world init <name> --repo <path> [--out <dir>] [--force] [--allow-unknown] [--acknowledge vendor=reason]... [--json] [--root <repo>]
49
+ # the FRONT DOOR: detect the repo's vendors, EMIT a world config +
50
+ # env file OUTSIDE the repo (default <repo>/../<name>-pilot/, the
51
+ # repo is never written to), then run the coverage proof over the
52
+ # emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
53
+ # Deterministic: same repo + same pack catalog => identical bytes.
54
+ # init DIAGNOSES; it never boots — it prints the exact next commands
12
55
  volter-world up <config> --env-file <path> [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--root <repo>]
13
56
  volter-world down <name> [--grace-ms <ms>] [--purge] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
14
57
  # --purge: after teardown, delete the instance dir (and per-service twin data) too — see docs/DATA_AT_REST.md
15
58
  volter-world status <name> [--json] [--root <repo>]
59
+ volter-world plan <name> --service <id> [--root <repo>]
60
+ volter-world review <name> --service <id> --plan-id <exact-id> --decision approved|rejected --actor-id <id> [--reason <text>] [--root <repo>]
16
61
  volter-world doctor <name> [--verify-public] [--json] [--root <repo>]
17
- volter-world doctor-prereqs --require docker [--json] [--quiet]
62
+ volter-world doctor-prereqs --require local-execution [--json] [--quiet]
63
+ volter-world inspect-project [path] [--json] # read-only adoption discovery
64
+ volter-world fake-env <NAME...> [--json] # structurally valid fake env values for a world config
65
+ # (Google service-account JSON gets a real throwaway RSA key;
66
+ # opaque bearer keys get a twin-fake scalar)
67
+ volter-world covers <world> --repo <path> [--allow-unknown] [--acknowledge vendor=reason]... [--json] [--root <repo>]
68
+ # room-setup PROOF: every external vendor the repo talks to has a twin
69
+ # in the world. Exit 0 only when fully covered; missing twins and
70
+ # unmapped external-service-shaped deps exit 1 (--allow-unknown
71
+ # downgrades the latter to a warning)
72
+ volter-world clock <world> show | set <iso> | advance <N s|m|h|d> # THE WORLD CLOCK: frozen instant every twin stamps from; physics door
18
73
  volter-world urls <name> [--json] [--root <repo>]
74
+ volter-world url <name> [service] [--root <repo>] # the clean base URL of one running service (no world.env parsing);
75
+ # without a service: every service, one \`<id> <url>\` per line
76
+ volter-world app-url <world> [--set <url>|--detect <pid|port>] [--json] [--root <repo>]
77
+ # the recorded APP endpoint of the instance — the attach pattern's
78
+ # missing output. The BOOTER records it as the last boot step
79
+ # (--set, or --detect an already-listening pid/port); consumers
80
+ # read it back (bare URL, or --json). Reading an UNSET record
81
+ # fails loudly with the registration recipe. A world that boots
82
+ # the app itself declares an "app" service (type "process") —
83
+ # its URL answers without any registration
84
+ volter-world tail <name> [service...] [--no-follow] [--json] [--requests] [--root <repo>]
85
+ # merged, occurredAt-ordered feed of the twins' action ledgers — one
86
+ # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
87
+ # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
88
+ # --requests: also merge the opt-in request journals — reads/404s,
89
+ # plus WHICH credential arrived in WHICH header/query param, named +
90
+ # fingerprinted, never the value — written by twins under
91
+ # VOLTER_TWIN_REQUEST_JOURNAL=1)
92
+ volter-world mark <world> [--id <marker>] [--json] [--root <repo>]
93
+ # capture a cross-service BASE MARKER (each twin ledger's offset +
94
+ # last action id) — the position \`diff\` measures from
95
+ volter-world diff <world> [--base <marker>] [--json] [--root <repo>]
96
+ # the ledger DELTA since a marker (default: the last mark, else
97
+ # world-boot), grouped by vendor, ordered by occurredAt
98
+ volter-world changeset create <world> <name> [--base <marker>] [--verifier "<service> <type>:<id> <field> <op> [value]"]... [--force] [--json] [--root <repo>]
99
+ volter-world changeset show <name> [--world <world>] [--json] [--root <repo>]
100
+ volter-world changeset list [--world <world>] [--json] [--root <repo>]
101
+ volter-world changeset replay <name> --into <world> [--world <world>] [--json] [--root <repo>]
102
+ # freeze a delta into a named, content-addressed changeset and replay
103
+ # it into another world's twins (the CI primitive; idempotent)
104
+ volter-world changeset verify <name> (--into <world> | --ephemeral) [--world <world>] [--json] [--root <repo>]
105
+ # replay into a clean target, run the changeset's verifiers against
106
+ # post-replay state, record the outcome ON the object (hash untouched)
107
+ volter-world changeset approve <name> --as <principal> [--note <text>] [--world <world>] [--json] [--root <repo>]
108
+ # append an approval bound to the current body hash (drift refuses)
109
+ volter-world changeset status <name> [--world <world>] [--json] [--root <repo>]
110
+ # one honest line: hash, verified?, approvals, ready|not-ready + why
111
+ # (exit 0 when ready; no pushing here — that is v2)
19
112
  volter-world list [--root <repo>]
20
- volter-world env <name> -- <command...>
21
- volter-world activate <name> # eval "$(volter-world activate <name>)" a virtualenv for vendor APIs
22
- volter-world shell <name> # drop into a subshell with the world active (any CLI hits the twins)
113
+ volter-world env <name> [--root <repo>] -- <command...>
114
+ volter-world attach [<world-ref>] [--via env|direct] [--root <repo>] [-- <command...>]
115
+ # ref: explicit $VOLTER_WORLD nearest .volter-world file (docs/ATTACH.md)
116
+ # --via env (default): run the command attached; --via direct: print the manifest JSON
117
+ volter-world manifest <name> [--root <repo>] # the world manifest (vendors map, CA, traffic proxy, suggested env) as JSON
118
+ volter-world reflect <name> --target-ip <ip> [--resolver-ip <ip>] [--port <p>] [--resolver-port <p>] [--upstream <dns-ip>] [--root <repo>]
119
+ # run the reflect front (SNI TLS door) + resolver in the foreground (docs/ATTACH.md);
120
+ # --port 443 --resolver-port 53 is what containers can reach (attach --via reflect);
121
+ # --resolver-ip when the Docker host's gateway address answers DNS itself (colima)
122
+ volter-world route <name> <add|rm|ls> [host] # attachment-scoped reflect routes (which hosts the resolver intercepts)
123
+ volter-world serve <name> --advertise <https-origin> [--port <front>] [--manifest-port <p>] [--token <t>] [--root <repo>]
124
+ # serve the world remotely: manifest endpoint + ONE advertised TLS door (docs/ATTACH.md)
125
+ # attachers: volter-world attach <http://host:manifest-port> -- <command...>
126
+ volter-world activate <name> [--root <repo>] # eval "$(volter-world activate <name>)" — a virtualenv for vendor APIs
127
+ volter-world shell <name> [--root <repo>] # drop into a subshell with the world active (any CLI hits the twins)
23
128
  volter-world run <config> --env-file <path> [--name <name>] [--mode local|share|sealed] [--keep] -- <command...>
24
- volter-world share <name> [--service app] [--verify /health|--no-verify] [--command <cmd> [-- <args…>]] # {url} in args = the service's local URL
129
+ volter-world share <name> [--service app] [--verify /health|--no-verify] [--provider cloudflare-quick|command] [--command <cmd> [-- <args…>]]
130
+ # {url} in command args = the service's local URL;
131
+ # --provider cloudflare-quick selects raw cloudflared and clears a configured custom command
25
132
  volter-world unshare <name> [--service app]
26
133
 
27
134
  Configs are stable JSON files, resolved from worlds/configs/<config>.json unless a path is passed.
@@ -32,7 +139,15 @@ function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
32
139
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
33
140
 
34
141
  Services:
35
- ${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url}`).join('\n')}
142
+ ${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
143
+
144
+ This instance started CLEAN (a world's state lives only while it runs; the world dir is the
145
+ reproducible story) — set the clock and run your seed now, after every up.
146
+
147
+ The two moves: if the twin stores it, create it through the vendor's own API (seed as the
148
+ person, move the clock first for history). Everything else — judgment, lookups, faults — is
149
+ a handler in the world dir's handlers/<vendor>.json. EVERY twin explains itself at
150
+ GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misses.
36
151
 
37
152
  Env:
38
153
  ${instance.envFile}
@@ -52,6 +167,10 @@ async function main(): Promise<void> {
52
167
  printHelp();
53
168
  return;
54
169
  }
170
+ if (subject === '--help' || subject === '-h') {
171
+ printHelp();
172
+ return;
173
+ }
55
174
 
56
175
  if (cmd === 'up') {
57
176
  if (!subject) throw new Error('volter-world up: missing config');
@@ -100,11 +219,55 @@ async function main(): Promise<void> {
100
219
  if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
101
220
  else {
102
221
  process.stdout.write(`World ${status.name}: ${status.running ? 'running' : 'stopped'} (${status.config})\n`);
103
- for (const service of Object.values(status.services)) process.stdout.write(` ${service.id}: ${service.url}\n`);
222
+ if (status.lastRun) {
223
+ if (status.lastRun.state === 'running') {
224
+ process.stdout.write(` foreground consumer: running (runner pid ${status.lastRun.runnerPid})\n log: ${status.lastRun.log}\n`);
225
+ } else if (status.lastRun.state === 'abrupt') {
226
+ process.stdout.write(` foreground consumer: failed abruptly (${status.lastRun.error}; observed ${status.lastRun.observedAt})\n log: ${status.lastRun.log}\n`);
227
+ } else {
228
+ const detail = status.lastRun.error ? `spawn error: ${status.lastRun.error}`
229
+ : status.lastRun.signal ? `terminated by ${status.lastRun.signal}`
230
+ : `exited ${status.lastRun.exitCode}`;
231
+ process.stdout.write(` foreground consumer: ${status.lastRun.exitCode === 0 ? 'succeeded' : 'failed'} (${detail})\n log: ${status.lastRun.log}\n`);
232
+ }
233
+ }
234
+ if (status.resources) {
235
+ process.stdout.write(` resources: memory=${status.resources.memoryMiB}MiB writable-storage=${status.resources.writableStorageMiB}MiB\n log: ${status.resources.log}\n`);
236
+ }
237
+ for (const service of Object.values(status.services)) {
238
+ const lifecycle = service.type === 'external' ? 'externally managed'
239
+ : status.livePids.includes(service.pid) ? 'running' : 'stopped';
240
+ process.stdout.write(` ${service.id}: ${service.url ?? '(no World URL)'} (${lifecycle})\n log: ${service.log}\n`);
241
+ }
104
242
  }
105
243
  return;
106
244
  }
107
245
 
246
+ if (cmd === 'plan') {
247
+ if (!subject) throw new Error('volter-world plan: missing world name');
248
+ const service = optionValue(rest, '--service');
249
+ if (!service) throw new Error('volter-world plan: --service <id> is required');
250
+ process.stdout.write(`${JSON.stringify(planWorldActions(subject, service, root), null, 2)}\n`);
251
+ return;
252
+ }
253
+
254
+ if (cmd === 'review') {
255
+ if (!subject) throw new Error('volter-world review: missing world name');
256
+ const service = optionValue(rest, '--service');
257
+ if (!service) throw new Error('volter-world review: --service <id> is required');
258
+ const expectedTransactionSetId = optionValue(rest, '--plan-id');
259
+ if (!expectedTransactionSetId) throw new Error('volter-world review: --plan-id <exact-id> is required');
260
+ const decision = optionValue(rest, '--decision');
261
+ if (decision !== 'approved' && decision !== 'rejected') throw new Error('volter-world review: --decision must be approved or rejected');
262
+ const actorId = optionValue(rest, '--actor-id');
263
+ if (!actorId) throw new Error('volter-world review: --actor-id is required');
264
+ const actorKind = optionValue(rest, '--actor-kind', 'human');
265
+ if (actorKind !== 'human' && actorKind !== 'agent') throw new Error('volter-world review: --actor-kind must be human or agent');
266
+ const review = reviewWorldActions(subject, service, { expectedTransactionSetId, decision, actor: { kind: actorKind, id: actorId }, reason: optionValue(rest, '--reason') || undefined, root });
267
+ process.stdout.write(`${JSON.stringify(review, null, 2)}\n`);
268
+ return;
269
+ }
270
+
108
271
  if (cmd === 'doctor') {
109
272
  if (!subject) throw new Error('volter-world doctor: missing world name');
110
273
  const report = await doctorWorld(subject, { root, verifyPublic: rest.includes('--verify-public') });
@@ -121,7 +284,7 @@ async function main(): Promise<void> {
121
284
  if (cmd === 'doctor-prereqs') {
122
285
  const required = rest.flatMap((arg, index) => arg === '--require' ? [rest[index + 1]] : [])
123
286
  .filter(Boolean) as PrerequisiteId[];
124
- const checks = checkPrerequisites(required.length ? required : ['docker']);
287
+ const checks = checkPrerequisites(required.length ? required : ['local-execution']);
125
288
  const ok = checks.every((check) => check.ok);
126
289
  if (rest.includes('--json')) {
127
290
  process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}\n`);
@@ -133,6 +296,101 @@ async function main(): Promise<void> {
133
296
  return;
134
297
  }
135
298
 
299
+ if (cmd === 'inspect-project') {
300
+ const args = [...(subject ? [subject] : []), ...rest];
301
+ const path = args.find((arg) => !arg.startsWith('--')) || process.cwd();
302
+ const report = inspectProject(path);
303
+ process.stdout.write(args.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatProjectInspection(report));
304
+ return;
305
+ }
306
+
307
+ if (cmd === 'fake-env') {
308
+ // Structurally valid fake credentials for a world config's env block. Most vendor keys
309
+ // are opaque bearer tokens (any `twin-fake-…` scalar works against a twin), but a value
310
+ // the CLIENT SDK parses/signs with (a Google service-account JSON) must be structurally
311
+ // real — full field set + a genuine throwaway RSA key — or the app throws before its
312
+ // first request. See fixture-env.ts for the pattern. Pure generator, no lifecycle.
313
+ const { fakeEnvValue } = await import('./fixture-env.ts');
314
+ const names = [subject, ...rest].filter((a): a is string => typeof a === 'string' && !a.startsWith('--'));
315
+ if (names.length === 0) throw new Error('volter-world fake-env: pass one or more env var NAMES (e.g. `volter-world fake-env GOOGLE_VERTEX_JSON STRIPE_SECRET_KEY`)');
316
+ if (rest.includes('--json') || subject === '--json') {
317
+ process.stdout.write(`${JSON.stringify(Object.fromEntries(names.map((n) => [n, fakeEnvValue(n)])), null, 2)}\n`);
318
+ } else {
319
+ for (const n of names) {
320
+ const v = fakeEnvValue(n);
321
+ // multiline values (SA JSON) print as NAME=<value> with the value JSON-escaped so the
322
+ // output stays one line per name and pastes into a world config env block directly.
323
+ process.stdout.write(`${n}=${v.includes('\n') ? JSON.stringify(v) : v}\n`);
324
+ }
325
+ }
326
+ return;
327
+ }
328
+
329
+ if (cmd === 'init') {
330
+ if (!subject) throw new Error('volter-world init: missing world name (usage: volter-world init <name> --repo <path>)');
331
+ const repo = optionValue(rest, '--repo');
332
+ if (!repo) throw new Error('volter-world init: --repo <path> is required (the application repo to build the world for)');
333
+ const out = optionValue(rest, '--out');
334
+ const result = initWorld(subject, repo, {
335
+ root,
336
+ ...(out ? { out } : {}),
337
+ force: rest.includes('--force'),
338
+ allowUnknown: rest.includes('--allow-unknown'),
339
+ acknowledge: acknowledgeOptions(rest, 'init'),
340
+ });
341
+ process.stdout.write(rest.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : formatInitReport(result));
342
+ // Vendor coverage IS the exit code. App boot risks remain a separate, prominent report field.
343
+ if (!result.ok) process.exit(1);
344
+ return;
345
+ }
346
+
347
+ if (cmd === 'covers') {
348
+ if (!subject) throw new Error('volter-world covers: missing world name (usage: volter-world covers <world> --repo <path>)');
349
+ const repo = optionValue(rest, '--repo');
350
+ if (!repo) throw new Error('volter-world covers: --repo <path> is required (the application repo to prove coverage for)');
351
+ const report = coverWorld(subject, repo, { root, allowUnknown: rest.includes('--allow-unknown'), acknowledge: acknowledgeOptions(rest, 'covers') });
352
+ process.stdout.write(rest.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatCoverageReport(report));
353
+ if (!report.ok) process.exit(1);
354
+ return;
355
+ }
356
+
357
+ if (cmd === 'clock') {
358
+ // THE operator door for world time (physics): show / set <iso> / advance <duration>.
359
+ // The clock is a frozen instant every twin reads per request (kernel worldNow()); setting
360
+ // or advancing takes effect live, no restarts. `advance` requires a set clock (advancing
361
+ // wall-clock would silently freeze time as a side effect).
362
+ // argv shape here: cmd='clock', subject=<world>, rest=[action, value?, flags...]
363
+ const name = subject;
364
+ const [action, value] = rest;
365
+ if (!name || !action || (action !== 'show' && action !== 'set' && action !== 'advance')) {
366
+ console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> [--root <repo>]');
367
+ process.exit(2);
368
+ }
369
+ const rootDir = resolve(optionValue(rest, '--root') ?? process.cwd());
370
+ const file = clockFile(rootDir, name);
371
+ if (action === 'show') {
372
+ console.log(existsSync(file) ? `${readFileSync(file, 'utf8').trim()} (frozen)` : `${new Date().toISOString()} (wall clock — no world clock set)`);
373
+ return;
374
+ }
375
+ if (action === 'set') {
376
+ const parsed = Date.parse(value ?? '');
377
+ if (Number.isNaN(parsed)) { console.error(`clock set: ${JSON.stringify(value)} is not an ISO-8601 instant`); process.exit(2); }
378
+ mkdirSync(dirname(file), { recursive: true });
379
+ writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
380
+ console.log(new Date(parsed).toISOString());
381
+ return;
382
+ }
383
+ const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec((value ?? '').trim());
384
+ if (!m) { console.error(`clock advance: ${JSON.stringify(value)} is not <N>(s|m|h|d)`); process.exit(2); }
385
+ if (!existsSync(file)) { console.error('clock advance: no world clock is set (advance from wall-clock would freeze time as a side effect) — `clock set <iso>` first'); process.exit(2); }
386
+ const base = Date.parse(readFileSync(file, 'utf8').trim());
387
+ const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
388
+ const next = new Date(base + Number(m[1]) * unit).toISOString();
389
+ writeFileSync(file, `${next}\n`);
390
+ console.log(next);
391
+ return;
392
+ }
393
+
136
394
  if (cmd === 'urls') {
137
395
  if (!subject) throw new Error('volter-world urls: missing world name');
138
396
  const urls = urlsWorld(subject, root);
@@ -147,6 +405,148 @@ async function main(): Promise<void> {
147
405
  return;
148
406
  }
149
407
 
408
+ if (cmd === 'url') {
409
+ if (!subject) throw new Error('volter-world url: missing world name (usage: volter-world url <world> [service])');
410
+ const [service] = positionalArgs(rest);
411
+ const urls = urlWorld(subject, { root, ...(service === undefined ? {} : { service }) });
412
+ if (service !== undefined) process.stdout.write(`${urls[0]!.url}\n`);
413
+ else for (const entry of urls) process.stdout.write(`${entry.id} ${entry.url}\n`);
414
+ return;
415
+ }
416
+
417
+ if (cmd === 'app-url') {
418
+ if (!subject) throw new Error('volter-world app-url: missing world name (usage: volter-world app-url <world> [--set <url>|--detect <pid|port>])');
419
+ const set = optionValue(rest, '--set');
420
+ const detect = optionValue(rest, '--detect');
421
+ if (set && detect) throw new Error('volter-world app-url: pass --set OR --detect, not both');
422
+ const json = rest.includes('--json');
423
+ if (set || detect) {
424
+ const record = set ? setAppUrl(subject, set, { root }) : await detectAppUrl(subject, detect, { root });
425
+ if (json) process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
426
+ else process.stdout.write(`Recorded app URL for world ${subject}: ${record.url}${record.detail ? ` (${record.detail})` : ''}\n`);
427
+ return;
428
+ }
429
+ const record = readAppUrl(subject, { root });
430
+ if (record === null) {
431
+ // LOUD when unset — a consumer must never mistake "nobody registered it" for an endpoint.
432
+ process.stderr.write(`${appUrlUnsetMessage(subject)}\n`);
433
+ if (json) process.stdout.write(`${JSON.stringify({ world: subject, url: null }, null, 2)}\n`);
434
+ process.exit(1);
435
+ }
436
+ // bare URL on stdout: ready for `curl "$(volter-world app-url <world>)/health"` interpolation
437
+ process.stdout.write(json ? `${JSON.stringify(record, null, 2)}\n` : `${record.url}\n`);
438
+ return;
439
+ }
440
+
441
+ if (cmd === 'tail') {
442
+ if (!subject) throw new Error('volter-world tail: missing world name (usage: volter-world tail <world> [service...])');
443
+ const services = positionalArgs(rest);
444
+ const controller = new AbortController();
445
+ process.once('SIGINT', () => controller.abort());
446
+ process.once('SIGTERM', () => controller.abort());
447
+ await tailWorldActions(subject, {
448
+ root,
449
+ ...(services.length ? { services } : {}),
450
+ follow: !rest.includes('--no-follow'),
451
+ json: rest.includes('--json'),
452
+ requests: rest.includes('--requests'),
453
+ signal: controller.signal,
454
+ });
455
+ return;
456
+ }
457
+
458
+ if (cmd === 'mark') {
459
+ if (!subject) throw new Error('volter-world mark: missing world name (usage: volter-world mark <world> [--id <marker>])');
460
+ const id = optionValue(rest, '--id');
461
+ const marker = markWorld(subject, { root, ...(id ? { id } : {}) });
462
+ if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(marker, null, 2)}\n`);
463
+ else process.stdout.write(`Marked world ${marker.world} at ${marker.id} (${marker.createdAt})\n ${marker.ledgers.length} ledger(s): ${marker.ledgers.map((ledger) => `${ledger.service}=${ledger.count}`).join(', ') || 'none yet'}\n`);
464
+ return;
465
+ }
466
+
467
+ if (cmd === 'diff') {
468
+ if (!subject) throw new Error('volter-world diff: missing world name (usage: volter-world diff <world> [--base <marker>])');
469
+ const base = optionValue(rest, '--base');
470
+ const delta = diffWorld(subject, { root, ...(base ? { base } : {}) });
471
+ process.stdout.write(rest.includes('--json') ? `${JSON.stringify(delta, null, 2)}\n` : formatLedgerDelta(delta));
472
+ return;
473
+ }
474
+
475
+ if (cmd === 'changeset') {
476
+ const CHANGESET_VALUE_FLAGS = ['--root', '--base', '--into', '--world', '--verifier', '--as', '--note'];
477
+ const args = positionalArgs(rest, CHANGESET_VALUE_FLAGS);
478
+ const world = optionValue(rest, '--world');
479
+ const json = rest.includes('--json');
480
+ if (subject === 'create') {
481
+ const [worldName, name] = args;
482
+ if (!worldName || !name) throw new Error('volter-world changeset create: usage: volter-world changeset create <world> <name> [--base <marker>] [--verifier "<service> <type>:<id> <field> <op> [value]"]...');
483
+ const base = optionValue(rest, '--base');
484
+ const verifiers = rest
485
+ .flatMap((flag, index) => (flag === '--verifier' && rest[index + 1] ? [rest[index + 1]!] : []))
486
+ .map((expression, index) => parseVerifierExpression(expression, `v${index + 1}`));
487
+ const changeset = createWorldChangeset(worldName, name, { root, overwrite: rest.includes('--force'), ...(base ? { base } : {}), ...(verifiers.length ? { verifiers } : {}) });
488
+ process.stdout.write(json ? `${JSON.stringify(changeset, null, 2)}\n` : formatChangeset(changeset));
489
+ return;
490
+ }
491
+ if (subject === 'show') {
492
+ const [name] = args;
493
+ if (!name) throw new Error('volter-world changeset show: usage: volter-world changeset show <name> [--world <world>]');
494
+ const located = findWorldChangeset(name, { root, ...(world ? { world } : {}) });
495
+ process.stdout.write(json ? `${JSON.stringify(located.changeset, null, 2)}\n` : formatChangeset(located.changeset));
496
+ return;
497
+ }
498
+ if (subject === 'list') {
499
+ const found = listWorldChangesets({ root, ...(world ? { world } : {}) });
500
+ if (json) {
501
+ process.stdout.write(`${JSON.stringify(found.map((entry) => ({ ...entry.changeset, path: entry.path })), null, 2)}\n`);
502
+ return;
503
+ }
504
+ if (!found.length) process.stdout.write('No changesets yet — create one with `volter-world changeset create <world> <name>`\n');
505
+ for (const entry of found) {
506
+ process.stdout.write(`${entry.changeset.name}\t${entry.world}\t${entry.changeset.actions.length} action(s)\tbase=${entry.changeset.base}\t${entry.changeset.contentHash}\n`);
507
+ }
508
+ return;
509
+ }
510
+ if (subject === 'replay') {
511
+ const [name] = args;
512
+ if (!name) throw new Error('volter-world changeset replay: usage: volter-world changeset replay <name> --into <world>');
513
+ const into = optionValue(rest, '--into');
514
+ if (!into) throw new Error('volter-world changeset replay: --into <world> is required (the world to replay the changeset into)');
515
+ const report = await replayWorldChangeset(name, { root, into, ...(world ? { world } : {}) });
516
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : formatReplayReport(report));
517
+ return;
518
+ }
519
+ if (subject === 'verify') {
520
+ const [name] = args;
521
+ if (!name) throw new Error('volter-world changeset verify: usage: volter-world changeset verify <name> (--into <world> | --ephemeral)');
522
+ const into = optionValue(rest, '--into');
523
+ const outcome = await verifyWorldChangeset(name, { root, ...(into ? { into } : {}), ephemeral: rest.includes('--ephemeral'), ...(world ? { world } : {}) });
524
+ process.stdout.write(json ? `${JSON.stringify(outcome.verification, null, 2)}\n` : formatVerification(name, outcome.verification, outcome.report));
525
+ if (!outcome.verification.passed) process.exitCode = 1;
526
+ return;
527
+ }
528
+ if (subject === 'approve') {
529
+ const [name] = args;
530
+ if (!name) throw new Error('volter-world changeset approve: usage: volter-world changeset approve <name> --as <principal> [--note <text>]');
531
+ const principal = optionValue(rest, '--as');
532
+ if (!principal) throw new Error('volter-world changeset approve: --as <principal> is required (who is signing)');
533
+ const note = optionValue(rest, '--note');
534
+ const outcome = approveWorldChangeset(name, { root, principal, ...(note ? { note } : {}), ...(world ? { world } : {}) });
535
+ if (json) process.stdout.write(`${JSON.stringify(outcome.approval, null, 2)}\n`);
536
+ else process.stdout.write(`Approved changeset ${name} as ${outcome.approval.principal} at ${outcome.approval.at}\n bound to ${outcome.approval.contentHash}\n`);
537
+ return;
538
+ }
539
+ if (subject === 'status') {
540
+ const [name] = args;
541
+ if (!name) throw new Error('volter-world changeset status: usage: volter-world changeset status <name> [--world <world>]');
542
+ const readiness = statusWorldChangeset(name, { root, ...(world ? { world } : {}) });
543
+ process.stdout.write(json ? `${JSON.stringify(readiness, null, 2)}\n` : formatChangesetStatus(readiness));
544
+ if (!readiness.ready) process.exitCode = 1;
545
+ return;
546
+ }
547
+ throw new Error(`volter-world changeset: unknown subcommand ${JSON.stringify(subject ?? '')} (want create|show|list|replay|verify|approve|status)`);
548
+ }
549
+
150
550
  if (cmd === 'list') {
151
551
  for (const world of listWorlds(root)) {
152
552
  process.stdout.write(`${world.name}\t${world.running ? 'running' : 'stopped'}\t${world.config ?? ''}\n`);
@@ -162,6 +562,171 @@ async function main(): Promise<void> {
162
562
  process.exit(exitCode);
163
563
  }
164
564
 
565
+ if (cmd === 'attach') {
566
+ // `subject` may be the ref, a flag, or the `--` separator — reassemble and split.
567
+ const args = [subject, ...rest].filter((arg): arg is string => arg !== undefined);
568
+ const split = args.indexOf('--');
569
+ const head = split >= 0 ? args.slice(0, split) : args;
570
+ const command = split >= 0 ? args.slice(split + 1) : [];
571
+ const explicit = head[0] !== undefined && !head[0].startsWith('-') ? head[0] : undefined;
572
+ const via = optionValue(head, '--via', 'env');
573
+ // when the ref is omitted, `subject` was a flag — the global root parse missed its value
574
+ const attachRoot = optionValue(head, '--root', process.cwd());
575
+ const resolved = resolveWorldRef(explicit, { cwd: process.cwd(), env: process.env });
576
+ if (isRemoteWorldRef(resolved.ref)) {
577
+ // remote world: fetch the manifest, synthesize the attach env, exec
578
+ const attachToken = optionValue(head, '--token') || process.env.VOLTER_WORLD_TOKEN || undefined;
579
+ const manifest = await fetchRemoteManifest(resolved.ref, attachToken === undefined ? {} : { token: attachToken });
580
+ if (via === 'direct') {
581
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
582
+ return;
583
+ }
584
+ if (via !== 'env') throw new Error(`volter-world attach: --via ${via} is not available for a remote world (env|direct)`);
585
+ if (command.length === 0) throw new Error('volter-world attach: pass the command after --');
586
+ let caFile: string | undefined;
587
+ if (manifest.ca !== null) {
588
+ caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
589
+ writeFileSync(caFile, manifest.ca);
590
+ }
591
+ const injectPath = resolvePath(import.meta.dir, '../../control-plane/inject.cjs');
592
+ const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
593
+ const result = spawnSync(command[0]!, command.slice(1), { env: { ...process.env, ...env }, stdio: 'inherit' });
594
+ process.exit(result.status ?? 1);
595
+ }
596
+ if (via === 'direct') {
597
+ process.stdout.write(`${JSON.stringify(worldManifest(resolved.ref, attachRoot), null, 2)}\n`);
598
+ return;
599
+ }
600
+ if (via === 'reflect') {
601
+ // The reflect attachment (docs/ATTACH.md): the consumer is unmodified; its DNS is the world's resolver
602
+ // and its TLS trust is the session CA. A `docker compose` command is composed here — every service
603
+ // gets the override — and everything else runs with the trust env and VOLTER_WORLD set, its DNS
604
+ // being the caller's to point (a container's --dns, a resolver on the host).
605
+ if (command.length === 0) throw new Error('volter-world attach: pass the command after --');
606
+ const manifest = readReflectManifest(attachRoot, resolved.ref);
607
+ if (!manifest) throw new Error(`volter-world attach --via reflect: no reflect front is running for world ${resolved.ref} — start one: volter-world reflect ${resolved.ref} --target-ip <ip> --port 443 --resolver-port 53`);
608
+ const env: Record<string, string> = { ...process.env as Record<string, string>, VOLTER_WORLD: resolved.ref, VOLTER_REFLECT_DNS: manifest.targetIp, VOLTER_WORLD_CA: manifest.caCertPath };
609
+ for (const key of CA_TRUST_ENV) env[key] = manifest.caCertPath;
610
+ let toRun = command;
611
+ const compose = splitDockerComposeArgs(command);
612
+ if (compose) {
613
+ // the consumer's own service names, from its own files, through its own docker
614
+ const listed = spawnSync(compose.head[0]!, [...compose.head.slice(1), ...compose.composeFlags, 'config', '--services'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
615
+ if (listed.status !== 0) throw new Error(`volter-world attach --via reflect: docker compose config --services failed:\n${listed.stderr.toString().trim()}`);
616
+ const services = listed.stdout.toString().split('\n').map((s) => s.trim()).filter(Boolean);
617
+ const overridePath = join(instanceDir(attachRoot, resolved.ref), 'reflect-compose.override.yml');
618
+ writeFileSync(overridePath, composeOverrideForReflect(manifest, services, resolved.ref));
619
+ toRun = dockerComposeWithOverride(command, overridePath);
620
+ process.stderr.write(`volter-world attach --via reflect: ${services.length} service(s) attached — dns ${manifest.resolverIp}:53, front ${manifest.targetIp}:443, CA ${ATTACHED_CA_PATH}\n`);
621
+ }
622
+ const result = spawnSync(toRun[0]!, toRun.slice(1), { env, stdio: 'inherit' });
623
+ process.exit(result.status ?? 1);
624
+ }
625
+ if (via !== 'env') {
626
+ throw new Error(`volter-world attach: --via ${via} is not available (env|reflect|direct)`);
627
+ }
628
+ if (command.length === 0) throw new Error('volter-world attach: pass the command after -- (or use --via direct for the manifest)');
629
+ // the world resolves at --root; the COMMAND runs where the caller stands —
630
+ // an attached repo's `pnpm start` must run in the repo, not the twin root
631
+ // (ponder blind-adoption finding)
632
+ process.exit(runWithWorldEnv(resolved.ref, command, attachRoot, { cwd: process.cwd() }));
633
+ }
634
+
635
+ if (cmd === 'manifest') {
636
+ if (!subject) throw new Error('volter-world manifest: missing world name');
637
+ process.stdout.write(`${JSON.stringify(worldManifest(subject, root), null, 2)}\n`);
638
+ return;
639
+ }
640
+
641
+ if (cmd === 'reflect') {
642
+ if (!subject) throw new Error('volter-world reflect: missing world name');
643
+ const targetIp = optionValue(rest, '--target-ip');
644
+ if (!targetIp) throw new Error('volter-world reflect: --target-ip <ip> is required (the address attachers reach the front at)');
645
+ const routesPath = reflectRoutesPath(root, subject);
646
+ const front = await startReflectFront({
647
+ envLoader: () => statusWorld(subject, root).env,
648
+ tlsDir: join(instanceDir(root, subject), 'tls'),
649
+ port: Number(optionValue(rest, '--port', '0')) || 0,
650
+ });
651
+ const resolver = await startReflectResolver({
652
+ routesLoader: () => readReflectRoutes(routesPath),
653
+ targetIp,
654
+ upstream: optionValue(rest, '--upstream') || undefined,
655
+ port: Number(optionValue(rest, '--resolver-port', '0')) || 0,
656
+ });
657
+ const resolverIp = optionValue(rest, '--resolver-ip') || targetIp;
658
+ writeReflectManifest(root, subject, { targetIp, resolverIp, frontPort: front.port, resolverPort: resolver.port, caCertPath: front.caCertPath });
659
+ process.stdout.write(`Reflect attachment for world ${subject}:\n`);
660
+ process.stdout.write(` front: ${targetIp}:${front.port} (SNI TLS door — session CA: ${front.caCertPath})\n`);
661
+ process.stdout.write(` resolver: ${resolverIp}:${resolver.port} (point the attacher's DNS here, e.g. docker run --dns)\n`);
662
+ process.stdout.write(` routes: ${routesPath} (volter-world route ${subject} add <host>)\n`);
663
+ process.stdout.write('Foreground — Ctrl+C to stop. The runtime supervises nothing (see docs/ATTACH.md).\n');
664
+ await new Promise<void>((resolveSignal) => {
665
+ process.once('SIGINT', () => resolveSignal());
666
+ process.once('SIGTERM', () => resolveSignal());
667
+ });
668
+ clearReflectManifest(root, subject);
669
+ await resolver.close();
670
+ await front.close();
671
+ return;
672
+ }
673
+
674
+ if (cmd === 'serve') {
675
+ if (!subject) throw new Error('volter-world serve: missing world name');
676
+ const advertise = optionValue(rest, '--advertise');
677
+ if (!advertise) throw new Error('volter-world serve: --advertise <https-origin> is required (how attachers reach the door, e.g. https://worlds.corp:8443)');
678
+ let advertisedUrl: URL;
679
+ try {
680
+ advertisedUrl = new URL(advertise);
681
+ } catch {
682
+ throw new Error(`volter-world serve: --advertise must be an origin URL, got "${advertise}"`);
683
+ }
684
+ if (advertisedUrl.protocol !== 'https:') throw new Error('volter-world serve: the advertised origin must be https (the door terminates TLS with the session CA)');
685
+ const frontPort = Number(advertisedUrl.port) || 443;
686
+ const front = await startReflectFront({
687
+ envLoader: () => statusWorld(subject, root).env,
688
+ tlsDir: join(instanceDir(root, subject), 'tls'),
689
+ port: Number(optionValue(rest, '--port', String(frontPort))) || frontPort,
690
+ extraHosts: [advertisedUrl.hostname],
691
+ });
692
+ const serveToken = optionValue(rest, '--token') || undefined;
693
+ const manifestServer = await startManifestServer({
694
+ manifest: () => advertiseWorldManifest(subject, root, advertisedUrl.origin),
695
+ port: Number(optionValue(rest, '--manifest-port', '0')) || 0,
696
+ ...(serveToken === undefined ? {} : { token: serveToken }),
697
+ });
698
+ process.stdout.write(`Serving world ${subject}:\n`);
699
+ process.stdout.write(` door: ${advertisedUrl.origin} (SNI TLS — vendors + the advertised name; session CA: ${front.caCertPath})\n`);
700
+ process.stdout.write(` manifest: http://0.0.0.0:${manifestServer.port}${MANIFEST_PATH}\n`);
701
+ process.stdout.write(` attach: volter-world attach http://<this-host>:${manifestServer.port} -- <command...>\n`);
702
+ process.stdout.write('Foreground — Ctrl+C to stop. Read-only publication; the runtime supervises nothing.\n');
703
+ await new Promise<void>((resolveSignal) => {
704
+ process.once('SIGINT', () => resolveSignal());
705
+ process.once('SIGTERM', () => resolveSignal());
706
+ });
707
+ await manifestServer.close();
708
+ await front.close();
709
+ return;
710
+ }
711
+
712
+ if (cmd === 'route') {
713
+ if (!subject) throw new Error('volter-world route: missing world name');
714
+ const [verb, host] = rest.filter((arg) => !arg.startsWith('--'));
715
+ const routesPath = reflectRoutesPath(root, subject);
716
+ const routes = readReflectRoutes(routesPath);
717
+ if (verb === 'ls' || verb === undefined) {
718
+ for (const entry of [...routes].sort()) process.stdout.write(`${entry}\n`);
719
+ return;
720
+ }
721
+ if (!host) throw new Error(`volter-world route: ${verb} needs a host`);
722
+ if (verb === 'add') routes.add(host.toLowerCase());
723
+ else if (verb === 'rm') routes.delete(host.toLowerCase());
724
+ else throw new Error(`volter-world route: unknown verb "${verb}" (add|rm|ls)`);
725
+ writeReflectRoutes(routesPath, routes);
726
+ process.stdout.write(`${[...routes].sort().join('\n')}${routes.size > 0 ? '\n' : ''}`);
727
+ return;
728
+ }
729
+
165
730
  if (cmd === 'activate') {
166
731
  if (!subject) throw new Error('volter-world activate: missing world name');
167
732
  process.stdout.write(activateScript(subject, root));
@@ -182,6 +747,12 @@ async function main(): Promise<void> {
182
747
  const name = optionValue(rest, '--name') || undefined;
183
748
  const mode = optionValue(rest, '--mode', 'local') as 'local' | 'share' | 'sealed';
184
749
  const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, keep: rest.includes('--keep') });
750
+ if (result.exitCode !== 0) {
751
+ const detail = result.outcome.error ? `could not start (${result.outcome.error})`
752
+ : result.outcome.signal ? `was terminated by ${result.outcome.signal}`
753
+ : `exited with code ${result.outcome.exitCode}`;
754
+ process.stderr.write(`World ${result.instance.name}: foreground consumer failed: ${detail}\nLog: ${result.outcome.log}\n`);
755
+ }
185
756
  process.exit(result.exitCode);
186
757
  }
187
758
 
@@ -195,9 +766,17 @@ async function main(): Promise<void> {
195
766
  : undefined;
196
767
  const split = rest.indexOf('--');
197
768
  const command = optionValue(rest, '--command') || undefined;
769
+ const providerValue = optionValue(rest, '--provider') || undefined;
770
+ if (providerValue !== undefined && providerValue !== 'cloudflare-quick' && providerValue !== 'command') {
771
+ throw new Error('volter-world share: --provider must be cloudflare-quick or command');
772
+ }
773
+ const provider = providerValue as 'cloudflare-quick' | 'command' | undefined;
774
+ if (provider === 'cloudflare-quick' && command) {
775
+ throw new Error('volter-world share: --provider cloudflare-quick uses the built-in cloudflared command and cannot be combined with --command');
776
+ }
198
777
  if (split >= 0 && !command) throw new Error('volter-world share: -- <args> requires --command <cmd>');
199
778
  const args = command && split >= 0 ? rest.slice(split + 1) : undefined;
200
- const instance = await shareWorldServices(subject, { root, service, verifyPath, command, args });
779
+ const instance = await shareWorldServices(subject, { root, service, verifyPath, provider, command, args, ephemeral: rest.includes('--ephemeral') || undefined });
201
780
  const serviceIds = service ? [service] : Object.values(instance.services)
202
781
  .filter((candidate) => candidate.publicUrl)
203
782
  .map((candidate) => candidate.id);