@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/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
22
|
-
|
|
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…>]]
|
|
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
|
-
|
|
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 : ['
|
|
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);
|