@volter/world-runtime 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- package/src/websocket-relay.ts +32 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { sessionTrustEnv } from './ca-trust.ts';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve, resolve as resolvePath } from 'node:path';
|
|
8
|
+
import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, upWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from './index.ts';
|
|
9
|
+
import { formatApplication, formatChangeset, formatRebaseReport, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, parseVerifierExpression } from '@volter/world-core';
|
|
10
|
+
import { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer } from './serve.ts';
|
|
11
|
+
import type { WorldManifest } from './attach.ts';
|
|
12
|
+
import { clockFile, instanceDir, pruneWorlds } from './runtime.ts';
|
|
13
|
+
import { worldResourceReport } from './resources.ts';
|
|
14
|
+
import { worldConsumers, worldUsage } from './consumers.ts';
|
|
15
|
+
import { addAppHosts, appUrlUnsetMessage, detectAppUrl, readAppUrl, setAppUrl } from './app-url.ts';
|
|
16
|
+
import type { PrerequisiteId } from './index.ts';
|
|
17
|
+
import { describeServiceEnd, describeWorldEvent, readServiceEnd, readWorldEvents } from './service-exit.ts';
|
|
18
|
+
import { worldEventsPath } from './runtime.ts';
|
|
19
|
+
import { superviseWorldUp } from './up-task.ts';
|
|
20
|
+
|
|
21
|
+
/** An option's value, written `--name value` or `--name=value`. */
|
|
22
|
+
function optionValue(args: string[], name: string, fallback = ''): string {
|
|
23
|
+
const joined = args.find((arg) => arg.startsWith(`${name}=`));
|
|
24
|
+
if (joined !== undefined) return joined.slice(name.length + 1);
|
|
25
|
+
const index = args.indexOf(name);
|
|
26
|
+
return index >= 0 && index + 1 < args.length ? args[index + 1]! : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ownerOption(args: string[]): string | undefined {
|
|
30
|
+
const index = args.indexOf('--owner');
|
|
31
|
+
if (index < 0) return undefined;
|
|
32
|
+
const label = args[index + 1];
|
|
33
|
+
if (!label || label.startsWith('--')) throw new Error('--owner requires a task label');
|
|
34
|
+
return label;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The non-flag args, skipping each value-taking flag's value (so `--root <path>` never
|
|
38
|
+
* masquerades as a positional service id). */
|
|
39
|
+
function positionalArgs(args: string[], valueFlags: string[] = ['--root']): string[] {
|
|
40
|
+
const positionals: string[] = [];
|
|
41
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
42
|
+
const arg = args[index]!;
|
|
43
|
+
if (arg.startsWith('--')) {
|
|
44
|
+
if (valueFlags.includes(arg)) index += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
positionals.push(arg);
|
|
48
|
+
}
|
|
49
|
+
return positionals;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `--acknowledge vendor=reason` (repeatable) — the shared parse for `covers` and `init`. */
|
|
53
|
+
function acknowledgeOptions(args: string[], command: string): Record<string, string> {
|
|
54
|
+
const acknowledge: Record<string, string> = {};
|
|
55
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
56
|
+
if (args[index] !== '--acknowledge') continue;
|
|
57
|
+
const pair = args[index + 1] ?? '';
|
|
58
|
+
const eq = pair.indexOf('=');
|
|
59
|
+
if (eq <= 0) throw new Error(`volter-world ${command}: --acknowledge takes vendor=reason (e.g. --acknowledge "svix=verifier-only, no egress")`);
|
|
60
|
+
acknowledge[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
61
|
+
}
|
|
62
|
+
return acknowledge;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printHelp(): void {
|
|
66
|
+
process.stdout.write(`Usage:
|
|
67
|
+
volter-world init <name> --repo <path> [--out <dir>] [--force] [--allow-unknown] [--acknowledge vendor=reason]... [--json] [--root <repo>]
|
|
68
|
+
# the FRONT DOOR: detect the repo's vendors, EMIT a world config +
|
|
69
|
+
# env file OUTSIDE the repo (default <repo>/../<name>-pilot/, the
|
|
70
|
+
# repo is never written to), then run the coverage proof over the
|
|
71
|
+
# emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
|
|
72
|
+
# Deterministic: same repo + same pack catalog => identical bytes.
|
|
73
|
+
# init DIAGNOSES; it never boots — it prints the exact next commands
|
|
74
|
+
volter-world up <config> --env-file=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--root <repo>]
|
|
75
|
+
volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
|
|
76
|
+
volter-world audit <world> [--json] — the catalog's upkeep verdicts for the mounted twins: matrix debt, census (R17)
|
|
77
|
+
volter-world down <name> [--grace-ms <ms>] [--purge] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
|
|
78
|
+
# --purge: after teardown, delete the instance dir (and per-service twin data) too — see docs/concepts/data-and-keys.md
|
|
79
|
+
volter-world status <name> [--json] [--root <repo>]
|
|
80
|
+
volter-world consumers retire <world> [--consumer <id>] [--root <repo>] [--json] # resolve attachments whose runner, command and process group are gone
|
|
81
|
+
volter-world resources [--json] [--root <repo>] # actual filesystem capacity and lifecycle inventory; no reservations
|
|
82
|
+
# up/run/attach accept --owner <label> for task ownership
|
|
83
|
+
volter-world prune [name] [--apply] [--json] [--root <repo>]
|
|
84
|
+
# preview by default; --apply deletes only successfully stopped
|
|
85
|
+
# instance data/logs. Never stops live Worlds; sources survive
|
|
86
|
+
volter-world doctor <name> [--verify-public] [--json] [--root <repo>]
|
|
87
|
+
volter-world events <name> [--json] [--root <repo>] every service start and end, and the World's own stops
|
|
88
|
+
volter-world doctor-prereqs --require local-execution [--json] [--quiet]
|
|
89
|
+
volter-world inspect-project [path] [--json] # read-only adoption discovery
|
|
90
|
+
volter-world migrate-config <config> [--json] [--root <repo>] # backed-up, atomic format-1 → format-2 migration
|
|
91
|
+
volter-world fake-env <NAME...> [--json] # structurally valid fake env values for a world config
|
|
92
|
+
# (Google service-account JSON gets a real throwaway RSA key;
|
|
93
|
+
# opaque bearer keys get a twin-fake scalar)
|
|
94
|
+
volter-world covers <world> --repo <path> [--allow-unknown] [--acknowledge vendor=reason]... [--json] [--root <repo>]
|
|
95
|
+
# room-setup PROOF: every external vendor the repo talks to has a twin
|
|
96
|
+
# in the world. Exit 0 only when fully covered; missing twins and
|
|
97
|
+
# unmapped external-service-shaped deps exit 1 (--allow-unknown
|
|
98
|
+
# downgrades the latter to a warning)
|
|
99
|
+
volter-world clock <world> show | set <iso> | advance <N s|m|h|d> # THE WORLD CLOCK: frozen instant every twin stamps from; physics door
|
|
100
|
+
volter-world urls <name> [--json] [--root <repo>]
|
|
101
|
+
volter-world url <name> [service] [--root <repo>] # the clean base URL of one running service (no world.env parsing);
|
|
102
|
+
# without a service: every service, one \`<id> <url>\` per line
|
|
103
|
+
volter-world app-url <world> [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
|
|
104
|
+
# the recorded APP endpoint of the instance — the attach pattern's
|
|
105
|
+
# missing output. The BOOTER records it as the last boot step
|
|
106
|
+
# (--set, or --detect an already-listening pid/port); consumers
|
|
107
|
+
# read it back (bare URL, or --json). Reading an UNSET record
|
|
108
|
+
# fails loudly with the registration recipe. A world that boots
|
|
109
|
+
# the app itself declares an "app" service (type "process") —
|
|
110
|
+
# its URL answers without any registration
|
|
111
|
+
volter-world log <name> [service...] [--no-follow] [--json] [--requests] [--root <repo>] # the log of changes (alias: tail)
|
|
112
|
+
# merged, occurredAt-ordered feed of the twins' action ledgers — one
|
|
113
|
+
# \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
|
|
114
|
+
# follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
|
|
115
|
+
# --requests: also merge the opt-in request journals — reads/404s,
|
|
116
|
+
# plus WHICH credential arrived in WHICH header/query param, named +
|
|
117
|
+
# fingerprinted, never the value — written by twins under
|
|
118
|
+
# VOLTER_TWIN_REQUEST_JOURNAL=1)
|
|
119
|
+
volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
|
|
120
|
+
# pull from the placeholder remote: the seed entry runs with every twin's
|
|
121
|
+
# window open, so the defaults land as observed state (diff empty, nothing pending)
|
|
122
|
+
volter-world reset <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
|
|
123
|
+
# back to the defaults: down --purge, up, seed (refuses a referenced parent)
|
|
124
|
+
volter-world branch <world> <name> [--env-file=<path>] [--json] [--root <repo>]
|
|
125
|
+
# a new world over the base's mirrors (its own log; the base's unpushed changes stay)
|
|
126
|
+
volter-world checkout <name> [--json] [--root <repo>] # a stopped world back up, state intact
|
|
127
|
+
volter-world fetch <world> [--origin <twins url> --namespace <org>/<world>] --key <key> [--full] [--service <id>]... [--json] [--root <repo>]
|
|
128
|
+
# the origin is a hosted twin's canonical history: --full clones its whole tree
|
|
129
|
+
# (admin key), plain fetch appends what it observed since the last fetch (read key)
|
|
130
|
+
volter-world origin <world> [--json] [--root <repo>] # the origin this world clones from
|
|
131
|
+
volter-world mark <world> [--id <marker>] [--json] [--root <repo>]
|
|
132
|
+
# capture a cross-service BASE MARKER (each twin ledger's offset +
|
|
133
|
+
# last action id) — the position \`diff\` measures from
|
|
134
|
+
volter-world diff <world> [--base <marker>] [--json] [--root <repo>]
|
|
135
|
+
# the ledger DELTA since a marker (default: the last mark, else
|
|
136
|
+
# world-boot), grouped by vendor, ordered by occurredAt
|
|
137
|
+
volter-world changeset create <world> <name> [--base <marker>] [--verifier "<service> <type>:<id> <field> <op> [value]"]... [--force] [--json] [--root <repo>]
|
|
138
|
+
volter-world changeset show <name> [--world <world>] [--json] [--root <repo>]
|
|
139
|
+
volter-world changeset list [--world <world>] [--json] [--root <repo>]
|
|
140
|
+
volter-world changeset replay <name> --into <world> [--world <world>] [--json] [--root <repo>]
|
|
141
|
+
# freeze a delta into a named, content-addressed changeset and replay
|
|
142
|
+
# it into another world's twins (the CI primitive; idempotent)
|
|
143
|
+
volter-world changeset verify <name> (--into <world> | --ephemeral) [--world <world>] [--json] [--root <repo>]
|
|
144
|
+
# replay into a clean target, run the changeset's verifiers against
|
|
145
|
+
# post-replay state, record the outcome ON the object (hash untouched)
|
|
146
|
+
volter-world changeset approve <name> --as <principal> [--note <text>] [--world <world>] [--json] [--root <repo>]
|
|
147
|
+
# append an approval bound to the current body hash (drift refuses)
|
|
148
|
+
volter-world changeset status <name> [--world <world>] [--json] [--root <repo>]
|
|
149
|
+
volter-world changeset rebase <name> [--world <world>] [--json] [--root <repo>]
|
|
150
|
+
# the merge onto a moved mirror (v3): re-stamp what still holds, name conflicts, drop approvals
|
|
151
|
+
volter-world changeset push <name> [--to <twins url> --namespace <org>/<world>] --key <namespace key> [--force] [--world <world>] [--json] [--root <repo>]
|
|
152
|
+
# the governed push (alias: apply): to the REMOTE — the world's origin by default —
|
|
153
|
+
# which refreshes, deploys to the vendor with its own keys, and answers with receipts
|
|
154
|
+
# one honest line: hash, verified?, approvals, ready|not-ready + why
|
|
155
|
+
# (exit 0 when ready; no pushing here — that is v2)
|
|
156
|
+
volter-world list [--json] [--root <repo>]
|
|
157
|
+
volter-world env <name> [--root <repo>] -- <command...>
|
|
158
|
+
volter-world attach [<world-ref>] [--via env|direct] [--root <repo>] [--verbose] [-- <command...>]
|
|
159
|
+
# ref: explicit → $VOLTER_WORLD → nearest .volter-world file (docs/guides/route-a-cli-through-the-world.md)
|
|
160
|
+
# --via env (default): run the command attached; --via direct: print the manifest JSON
|
|
161
|
+
volter-world manifest <name> [--root <repo>] # the world manifest (vendors map, CA, traffic proxy, suggested env) as JSON
|
|
162
|
+
volter-world reflect <name> --target-ip <ip> [--resolver-ip <ip>] [--port <p>] [--resolver-port <p>] [--upstream <dns-ip>] [--root <repo>]
|
|
163
|
+
# run the reflect front (SNI TLS door) + resolver in the foreground (docs/ATTACH.md);
|
|
164
|
+
# --port 443 --resolver-port 53 is what containers can reach (attach --via reflect);
|
|
165
|
+
# --resolver-ip when the Docker host's gateway address answers DNS itself (colima)
|
|
166
|
+
volter-world route <name> <add|rm|ls> [host] # attachment-scoped reflect routes (which hosts the resolver intercepts)
|
|
167
|
+
volter-world serve <name> --advertise <https-origin> [--port <front>] [--manifest-port <p>] [--token <t>] [--root <repo>]
|
|
168
|
+
# serve the world remotely: manifest endpoint + ONE advertised TLS door (docs/guides/route-a-cli-through-the-world.md)
|
|
169
|
+
# attachers: volter-world attach <http://host:manifest-port> -- <command...>
|
|
170
|
+
volter-world activate <name> [--root <repo>] # eval "$(volter-world activate <name>)" — a virtualenv for vendor APIs
|
|
171
|
+
volter-world shell <name> [--root <repo>] # drop into a subshell with the world active (any CLI hits the twins)
|
|
172
|
+
volter-world run <config> --env-file=<path> [--name <name>] [--mode local|share|sealed] [--keep] [--verbose] -- <command...>
|
|
173
|
+
volter-world share <name> [--service app] [--verify /health|--no-verify] [--provider cloudflare-quick|command] [--command <cmd> [-- <args…>]]
|
|
174
|
+
# {url} in command args = the service's local URL;
|
|
175
|
+
# --provider cloudflare-quick selects raw cloudflared and clears a configured custom command
|
|
176
|
+
volter-world unshare <name> [--service app]
|
|
177
|
+
|
|
178
|
+
Configs are stable JSON files, resolved from worlds/configs/<config>.json unless a path is passed.
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
|
|
183
|
+
process.stdout.write(`World ${instance.name} is ready (${instance.config})
|
|
184
|
+
|
|
185
|
+
Services:
|
|
186
|
+
${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
|
|
187
|
+
|
|
188
|
+
This instance started CLEAN (a world's state lives only while it runs; the world dir is the
|
|
189
|
+
reproducible story) — set the clock and run your seed now, after every up.
|
|
190
|
+
|
|
191
|
+
The two moves: if the twin stores it, create it through the vendor's own API (seed as the
|
|
192
|
+
person, move the clock first for history). Everything else — judgment, lookups, faults — is
|
|
193
|
+
a handler in the world dir's handlers/<vendor>.json. EVERY twin explains itself at
|
|
194
|
+
GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misses.
|
|
195
|
+
|
|
196
|
+
Env:
|
|
197
|
+
${instance.envFile}
|
|
198
|
+
|
|
199
|
+
Instance:
|
|
200
|
+
${instance.dirs.instance}/instance.json
|
|
201
|
+
|
|
202
|
+
Stop:
|
|
203
|
+
volter-world down ${instance.name}
|
|
204
|
+
|
|
205
|
+
For a bounded job, use volter-world run ... -- <command> to tear down automatically on exit.
|
|
206
|
+
Persistent up does not expire or infer that your sessions are idle.
|
|
207
|
+
`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function main(): Promise<void> {
|
|
211
|
+
const [cmd, subject, ...rest] = process.argv.slice(2);
|
|
212
|
+
const root = optionValue(rest, '--root', process.cwd());
|
|
213
|
+
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
214
|
+
printHelp();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (subject === '--help' || subject === '-h') {
|
|
218
|
+
printHelp();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (cmd === 'consumers') {
|
|
223
|
+
// `volter-world consumers retire <world> [--consumer <id>] [--root <repo>] [--json]`: the owner's
|
|
224
|
+
// explicit resolution of attachments whose runner and command are provably gone.
|
|
225
|
+
const args = rest.filter((arg): arg is string => arg !== undefined);
|
|
226
|
+
if (subject !== 'retire') throw new Error('volter-world consumers: usage: volter-world consumers retire <world> [--consumer <id>] [--root <repo>] [--json]');
|
|
227
|
+
const world = positionalArgs(args)[0];
|
|
228
|
+
if (!world) throw new Error('volter-world consumers retire: missing world name');
|
|
229
|
+
for (let i = 0; i < args.length; i++) {
|
|
230
|
+
if (args[i] === '--root' || args[i] === '--consumer') {
|
|
231
|
+
if (!args[i + 1] || args[i + 1]!.startsWith('--')) throw new Error(`${args[i]} requires a value`);
|
|
232
|
+
i++;
|
|
233
|
+
} else if (args[i]!.startsWith('--') && args[i] !== '--json') throw new Error(`volter-world consumers retire: unknown option ${args[i]}`);
|
|
234
|
+
}
|
|
235
|
+
const consumer = args.includes('--consumer') ? optionValue(args, '--consumer', '') : undefined;
|
|
236
|
+
const results = retireWorldConsumers(world, optionValue(args, '--root', process.cwd()), consumer ? { consumer } : {});
|
|
237
|
+
if (args.includes('--json')) process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
|
|
238
|
+
else if (!results.length) process.stdout.write(`World ${world}: no uncertain consumers.\n`);
|
|
239
|
+
else for (const r of results) process.stdout.write(`${r.retired ? 'RETIRED' : 'KEPT'} ${r.id}${r.owner ? ` (owner ${r.owner})` : ''}: ${r.reason}\n`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (cmd === 'resources' || cmd === 'prune') {
|
|
244
|
+
const args = [subject, ...rest].filter((arg): arg is string => arg !== undefined);
|
|
245
|
+
const reportRoot = optionValue(args, '--root', process.cwd());
|
|
246
|
+
// Destructive commands must reject typos, not silently broaden a target to all Worlds.
|
|
247
|
+
for (let i = 0; i < args.length; i++) {
|
|
248
|
+
if (args[i] === '--root') {
|
|
249
|
+
if (!args[i + 1] || args[i + 1]!.startsWith('--')) throw new Error('--root requires a path');
|
|
250
|
+
i++;
|
|
251
|
+
} else if (args[i]!.startsWith('--') && args[i] !== '--json' && !(cmd === 'prune' && args[i] === '--apply')) {
|
|
252
|
+
throw new Error(`volter-world ${cmd}: unknown option ${args[i]}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const names = positionalArgs(args);
|
|
256
|
+
if (names.length > (cmd === 'prune' ? 1 : 0)) throw new Error(`volter-world ${cmd}: unexpected positional arguments`);
|
|
257
|
+
if (cmd === 'prune') {
|
|
258
|
+
const report = pruneWorlds({ root: reportRoot, name: names[0], apply: args.includes('--apply') });
|
|
259
|
+
if (args.includes('--json')) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
260
|
+
else {
|
|
261
|
+
process.stdout.write(`${report.apply ? 'Prune result' : 'Preview only (use --apply to delete eligible instance data/logs)'}; never stops Worlds.\n`);
|
|
262
|
+
for (const entry of report.entries) {
|
|
263
|
+
process.stdout.write(`${entry.removed ? 'REMOVED' : entry.eligible ? 'ELIGIBLE' : 'KEEP'} ${entry.name}: ${entry.reason}\n ${entry.path}${entry.logicalBytes !== undefined ? ` (${entry.logicalBytes} logical bytes; actual disk reclaim may differ)` : ''}\n`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (report.apply && names.length && !report.entries[0]?.removed) process.exitCode = 1;
|
|
267
|
+
} else {
|
|
268
|
+
const report = worldResourceReport(reportRoot);
|
|
269
|
+
const consumers = report.worlds.map((claim) => {
|
|
270
|
+
let foreground;
|
|
271
|
+
try {
|
|
272
|
+
const record = claim.root ? statusWorld(claim.world, claim.root, { readOnly: true }).lastRun : undefined;
|
|
273
|
+
if (record) foreground = { state: record.state, runnerPid: record.runnerPid, ...('consumerPid' in record ? { consumerPid: record.consumerPid } : {}), startedAt: record.startedAt };
|
|
274
|
+
} catch { /* unknown */ }
|
|
275
|
+
const attached = claim.root ? worldConsumers(claim.root, claim.world) : { consumers: [], warnings: ['World root is unknown'] };
|
|
276
|
+
const usage = claim.root ? worldUsage(claim.root, claim.world, attached)
|
|
277
|
+
: { createdAt: null, lastUsedAt: null, lastUseSource: null, coverage: 'partial', warnings: attached.warnings };
|
|
278
|
+
return { identity: claim.identity, owner: claim.owner ?? null, attachments: attached.consumers, usage, warnings: attached.warnings, coverage: 'partial', foreground, otherConsumers: 'unknown (untracked synchronous SDK commands, shell/activate, reflect, remote clients and descendants are not enumerated)' };
|
|
279
|
+
});
|
|
280
|
+
if (args.includes('--json')) process.stdout.write(`${JSON.stringify({ ...report, consumers }, null, 2)}\n`);
|
|
281
|
+
else {
|
|
282
|
+
const free = report.filesystem.availableBytes;
|
|
283
|
+
process.stdout.write(`Observed capacity at ${report.measuredAt}; no capacity reservations.\nFilesystem ${report.filesystem.path}: ${free === null ? 'unknown' : `${Math.floor(free / 1048576)} MiB available`}; pool=${report.filesystem.pool ?? 'unknown'}. Physical memory=${Math.floor(report.host.physicalMemoryBytes / 1048576)} MiB (not measured usage).\n`);
|
|
284
|
+
for (const claim of report.worlds) {
|
|
285
|
+
process.stdout.write(`${claim.phase} ${claim.world}: root=${claim.root}; owner=${claim.owner ?? 'unrecorded'}; instance=${claim.instanceCreatedAt}\n tracked service pids=${claim.pids.join(',') || 'none'}; log=${claim.log}\n`);
|
|
286
|
+
const attached = consumers.find((row) => row.identity === claim.identity);
|
|
287
|
+
process.stdout.write(` instance created: ${attached?.usage.createdAt ?? 'unknown'}; last observed command use: ${attached?.usage.lastUsedAt ?? 'unknown'} (${attached?.usage.lastUseSource ?? 'unrecorded'}; partial coverage)\n`);
|
|
288
|
+
for (const consumer of attached?.attachments ?? []) process.stdout.write(` ${consumer.state} attachment ${consumer.id}: owner=${consumer.owner ?? 'unrecorded'} runner=${consumer.runnerPid} command-pid=${consumer.consumerPid ?? 'starting'} heartbeat=${consumer.heartbeatAt}\n`);
|
|
289
|
+
for (const warning of attached?.warnings ?? []) process.stdout.write(` WARNING: ${warning}\n`);
|
|
290
|
+
for (const warning of attached?.usage.warnings ?? []) if (!attached?.warnings.includes(warning)) process.stdout.write(` WARNING: ${warning}\n`);
|
|
291
|
+
}
|
|
292
|
+
for (const legacy of report.legacyRecords) process.stdout.write(`LEGACY ${legacy.world ?? 'unknown'}: ${legacy.path}; use its pinned runtime for teardown; no capacity charged here\n`);
|
|
293
|
+
for (const warning of report.warnings) process.stdout.write(`WARNING: ${warning}\n`);
|
|
294
|
+
process.stdout.write('Lifecycle records identify ownership, not measured usage. Consumer coverage is partial: local env attachments are registered; run records are available with --json. Untracked synchronous SDK commands, shell/activate, reflect, remote clients and descendants are unknown, not zero.\nReview ownership before explicit down <name>. prune previews stopped disk leftovers only. For bounded jobs, run cleans up on exit unless --keep.\n');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (cmd === 'up') {
|
|
301
|
+
if (!subject) throw new Error('volter-world up: missing config');
|
|
302
|
+
const envFile = optionValue(rest, '--env-file');
|
|
303
|
+
if (!envFile) throw new Error('volter-world up: missing required --env-file=<path>');
|
|
304
|
+
const name = optionValue(rest, '--name') || undefined;
|
|
305
|
+
const mode = optionValue(rest, '--mode', 'local') as 'local' | 'share' | 'sealed';
|
|
306
|
+
const isolationArg = optionValue(rest, '--isolation');
|
|
307
|
+
if (isolationArg && isolationArg !== 'process' && isolationArg !== 'colocated' && isolationArg !== 'worker') {
|
|
308
|
+
throw new Error(`volter-world up: invalid --isolation "${isolationArg}" (want process|colocated|worker)`);
|
|
309
|
+
}
|
|
310
|
+
const isolation = (isolationArg || undefined) as 'process' | 'colocated' | 'worker' | undefined;
|
|
311
|
+
// The boot runs in its own owner process (./up-task.ts): a closed terminal does not leave it half-done.
|
|
312
|
+
const instance = await superviseWorldUp(subject, { name, root, mode, envFile, owner: ownerOption(rest), ...(isolation ? { isolation } : {}) });
|
|
313
|
+
printReady(instance);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (cmd === 'down') {
|
|
318
|
+
if (!subject) throw new Error('volter-world down: missing world name');
|
|
319
|
+
const graceArg = optionValue(rest, '--grace-ms');
|
|
320
|
+
if (graceArg && !(Number(graceArg) >= 0)) throw new Error(`volter-world down: invalid --grace-ms "${graceArg}" (want a non-negative number)`);
|
|
321
|
+
const purge = rest.includes('--purge');
|
|
322
|
+
const result = await downWorld(subject, root, {
|
|
323
|
+
...(graceArg ? { graceMs: Number(graceArg) } : {}),
|
|
324
|
+
...(purge ? { purge: true } : {}),
|
|
325
|
+
});
|
|
326
|
+
process.stdout.write(result.externalErrors.length ? `World ${result.name}: teardown incomplete; lifecycle evidence retained\n`
|
|
327
|
+
: `Stopped world ${result.name}${result.stopped.length ? `: ${result.stopped.join(', ')}` : ''}\n`);
|
|
328
|
+
if (result.escalated.length) process.stdout.write(`Escalated to SIGKILL (ignored SIGTERM past the grace): ${result.escalated.join(', ')}\n`);
|
|
329
|
+
for (const line of result.notSignalled ?? []) process.stdout.write(`Not signalled: ${line}\n`);
|
|
330
|
+
if (result.externalErrors.length) {
|
|
331
|
+
for (const message of result.externalErrors) process.stderr.write(`External teardown failed: ${message}\n`);
|
|
332
|
+
}
|
|
333
|
+
if (result.purged) {
|
|
334
|
+
process.stdout.write(`Purged instance data: ${result.purged}\n`);
|
|
335
|
+
} else if (purge && result.externalErrors.length) {
|
|
336
|
+
process.stdout.write(`Refused to purge instance data: an external service's teardown failed (see above) — its down command/discovered env must survive.\n`);
|
|
337
|
+
}
|
|
338
|
+
// Honest exit code (TWIN-61): a silent 0 here is exactly the gap a failed `supabase stop` +
|
|
339
|
+
// `--purge` exploited — a still-running external, a deleted stop recipe, and a green exit.
|
|
340
|
+
if (result.externalErrors.length) process.exitCode = 1;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (cmd === 'events') {
|
|
345
|
+
if (!subject) throw new Error('volter-world events: missing world name');
|
|
346
|
+
const status = statusWorld(subject, root, { readOnly: true, light: true });
|
|
347
|
+
const events = readWorldEvents(worldEventsPath(status.dirs.logs));
|
|
348
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(events, null, 2)}\n`);
|
|
349
|
+
else if (!events.length) process.stdout.write(`World ${subject}: no events recorded (${worldEventsPath(status.dirs.logs)})\n`);
|
|
350
|
+
else for (const event of events) process.stdout.write(`${describeWorldEvent(event)}\n`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (cmd === 'status') {
|
|
355
|
+
if (!subject) throw new Error('volter-world status: missing world name');
|
|
356
|
+
const status = statusWorld(subject, root);
|
|
357
|
+
const usage = worldUsage(root, subject);
|
|
358
|
+
// A stopped process service carries its own account of how it ended (./service-exit.ts).
|
|
359
|
+
const ends = Object.fromEntries(Object.values(status.services)
|
|
360
|
+
.filter((service) => status.serviceStates[service.id] !== 'running' && status.serviceStates[service.id] !== 'external')
|
|
361
|
+
.flatMap((service) => { const end = readServiceEnd(service.log); return end ? [[service.id, end] as const] : []; }));
|
|
362
|
+
if (rest.includes('--json')) {
|
|
363
|
+
const services = Object.fromEntries(Object.entries(status.services).map(([id, service]) => [id, ends[id] ? { ...service, end: ends[id] } : service]));
|
|
364
|
+
process.stdout.write(`${JSON.stringify({ ...status, services, usage }, null, 2)}\n`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const down = Object.entries(status.serviceStates).filter(([, state]) => state !== 'running' && state !== 'external').map(([id]) => id);
|
|
368
|
+
const headline = !status.running ? 'stopped' : status.degraded ? `degraded: ${down.join(', ')} not running` : 'running';
|
|
369
|
+
process.stdout.write(`World ${status.name}: ${headline} (${status.config})\n`);
|
|
370
|
+
process.stdout.write(` instance created: ${usage.createdAt ?? 'unknown'}\n last observed command use: ${usage.lastUsedAt ?? 'unknown'} (${usage.lastUseSource ?? 'unrecorded'}; partial coverage)\n`);
|
|
371
|
+
for (const warning of usage.warnings) process.stdout.write(` WARNING: ${warning}\n`);
|
|
372
|
+
if (status.lastRun) {
|
|
373
|
+
if (status.lastRun.state === 'running') {
|
|
374
|
+
process.stdout.write(` foreground consumer: running (runner pid ${status.lastRun.runnerPid})\n log: ${status.lastRun.log}\n`);
|
|
375
|
+
} else if (status.lastRun.state === 'abrupt') {
|
|
376
|
+
process.stdout.write(` foreground consumer: failed abruptly (${status.lastRun.error}; observed ${status.lastRun.observedAt})\n log: ${status.lastRun.log}\n`);
|
|
377
|
+
} else {
|
|
378
|
+
const detail = status.lastRun.error ? `spawn error: ${status.lastRun.error}`
|
|
379
|
+
: status.lastRun.signal ? `terminated by ${status.lastRun.signal}`
|
|
380
|
+
: `exited ${status.lastRun.exitCode}`;
|
|
381
|
+
process.stdout.write(` foreground consumer: ${status.lastRun.exitCode === 0 ? 'succeeded' : 'failed'} (${detail})\n log: ${status.lastRun.log}\n`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (status.lifecycle) process.stdout.write(` lifecycle: ownership recorded, no capacity reserved\n log: ${status.lifecycle.log}\n`);
|
|
385
|
+
else if (status.resources) process.stdout.write(` legacy runtime ownership; use its pinned runtime for teardown\n log: ${status.resources.log}\n`);
|
|
386
|
+
for (const service of Object.values(status.services)) {
|
|
387
|
+
const state = status.serviceStates[service.id];
|
|
388
|
+
const lifecycle = state === 'external' ? 'externally managed'
|
|
389
|
+
: state === 'lingering' ? 'stopped; processes of its group are still alive, which `down` stops'
|
|
390
|
+
: state === 'gave-up' ? `worker gave up: ${service.workerGaveUp?.detail ?? 'exited'}, at ${service.workerGaveUp?.at ?? 'unknown'}`
|
|
391
|
+
: state;
|
|
392
|
+
const end = ends[service.id];
|
|
393
|
+
const how = end ? `\n it ${describeServiceEnd(end)}${end.lastOutput.length ? `; last output: ${end.lastOutput[end.lastOutput.length - 1]}` : ''}` : '';
|
|
394
|
+
process.stdout.write(` ${service.id}: ${service.url ?? '(no World URL)'} (${lifecycle})${how}\n log: ${service.log}\n`);
|
|
395
|
+
const own = service.log ? ownLogs(service.log) : [];
|
|
396
|
+
if (own.length) process.stdout.write(` its own logs: ${own.join(', ')}\n`);
|
|
397
|
+
}
|
|
398
|
+
// The World's events: every service start and end, and the World's own stops.
|
|
399
|
+
const events = readWorldEvents(worldEventsPath(status.dirs.logs));
|
|
400
|
+
if (events.length) {
|
|
401
|
+
process.stdout.write(` recent events (${worldEventsPath(status.dirs.logs)}):\n`);
|
|
402
|
+
for (const event of events.slice(-8)) process.stdout.write(` ${describeWorldEvent(event)}\n`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// `plan` and `review` retired 2026-09-05: the changeset verbs are the one review path (verify, approve, status, push).
|
|
409
|
+
|
|
410
|
+
if (cmd === 'doctor') {
|
|
411
|
+
if (!subject) throw new Error('volter-world doctor: missing world name');
|
|
412
|
+
const report = await doctorWorld(subject, { root, verifyPublic: rest.includes('--verify-public') });
|
|
413
|
+
if (rest.includes('--json')) {
|
|
414
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
415
|
+
} else {
|
|
416
|
+
process.stdout.write(`World ${report.name}: ${report.ok ? 'ok' : 'failed'}\n`);
|
|
417
|
+
for (const check of report.checks) process.stdout.write(` ${check.ok ? 'ok' : 'fail'} ${check.id}: ${check.message}\n`);
|
|
418
|
+
}
|
|
419
|
+
if (!report.ok) process.exit(1);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (cmd === 'doctor-prereqs') {
|
|
424
|
+
const required = rest.flatMap((arg, index) => arg === '--require' ? [rest[index + 1]] : [])
|
|
425
|
+
.filter(Boolean) as PrerequisiteId[];
|
|
426
|
+
const checks = checkPrerequisites(required.length ? required : ['local-execution']);
|
|
427
|
+
const ok = checks.every((check) => check.ok);
|
|
428
|
+
if (rest.includes('--json')) {
|
|
429
|
+
process.stdout.write(`${JSON.stringify({ ok, checks }, null, 2)}\n`);
|
|
430
|
+
} else if (!rest.includes('--quiet')) {
|
|
431
|
+
const output = formatPrerequisiteChecks(checks);
|
|
432
|
+
process[ok ? 'stdout' : 'stderr'].write(`${output}\n`);
|
|
433
|
+
}
|
|
434
|
+
if (!ok) process.exit(1);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (cmd === 'inspect-project') {
|
|
439
|
+
const args = [...(subject ? [subject] : []), ...rest];
|
|
440
|
+
const path = args.find((arg) => !arg.startsWith('--')) || process.cwd();
|
|
441
|
+
const report = inspectProject(path);
|
|
442
|
+
process.stdout.write(args.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatProjectInspection(report));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (cmd === 'migrate-config') {
|
|
447
|
+
if (!subject) throw new Error('volter-world migrate-config: missing config path or name');
|
|
448
|
+
const migration = migrateWorldConfig(subject, root);
|
|
449
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(migration, null, 2)}\n`);
|
|
450
|
+
else process.stdout.write(`Migrated ${migration.path}: format ${migration.from} → ${migration.to}\nBackup ${migration.backup}\n${migration.dropped.length ? `Dropped ${migration.dropped.join(', ')}\n` : ''}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (cmd === 'fake-env') {
|
|
455
|
+
// Structurally valid fake credentials for a world config's env block. Most vendor keys
|
|
456
|
+
// are opaque bearer tokens (any `twin-fake-…` scalar works against a twin), but a value
|
|
457
|
+
// the CLIENT SDK parses/signs with (a Google service-account JSON) must be structurally
|
|
458
|
+
// real — full field set + a genuine throwaway RSA key — or the app throws before its
|
|
459
|
+
// first request. See fixture-env.ts for the pattern. Pure generator, no lifecycle.
|
|
460
|
+
const { fakeEnvValue } = await import('./fixture-env.ts');
|
|
461
|
+
const names = [subject, ...rest].filter((a): a is string => typeof a === 'string' && !a.startsWith('--'));
|
|
462
|
+
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`)');
|
|
463
|
+
if (rest.includes('--json') || subject === '--json') {
|
|
464
|
+
process.stdout.write(`${JSON.stringify(Object.fromEntries(names.map((n) => [n, fakeEnvValue(n)])), null, 2)}\n`);
|
|
465
|
+
} else {
|
|
466
|
+
for (const n of names) {
|
|
467
|
+
const v = fakeEnvValue(n);
|
|
468
|
+
// multiline values (SA JSON) print as NAME=<value> with the value JSON-escaped so the
|
|
469
|
+
// output stays one line per name and pastes into a world config env block directly.
|
|
470
|
+
process.stdout.write(`${n}=${v.includes('\n') ? JSON.stringify(v) : v}\n`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (cmd === 'init') {
|
|
477
|
+
if (!subject) throw new Error('volter-world init: missing world name (usage: volter-world init <name> --repo <path>)');
|
|
478
|
+
const repo = optionValue(rest, '--repo');
|
|
479
|
+
if (!repo) throw new Error('volter-world init: --repo <path> is required (the application repo to build the world for)');
|
|
480
|
+
const out = optionValue(rest, '--out');
|
|
481
|
+
const result = initWorld(subject, repo, {
|
|
482
|
+
root,
|
|
483
|
+
...(out ? { out } : {}),
|
|
484
|
+
force: rest.includes('--force'),
|
|
485
|
+
allowUnknown: rest.includes('--allow-unknown'),
|
|
486
|
+
acknowledge: acknowledgeOptions(rest, 'init'),
|
|
487
|
+
});
|
|
488
|
+
process.stdout.write(rest.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : formatInitReport(result));
|
|
489
|
+
// Vendor coverage IS the exit code. App boot risks remain a separate, prominent report field.
|
|
490
|
+
if (!result.ok) process.exit(1);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// R17 — the upkeep sweeps, surfaced per world the way npm does: `outdated` is the version
|
|
495
|
+
// and protocol drift of the twins this world mounted; `audit` is what the catalog knows about
|
|
496
|
+
// them (matrix debt, census baseline). Both read instance.json and the committed artifacts —
|
|
497
|
+
// nothing is fetched.
|
|
498
|
+
if (cmd === 'outdated' || cmd === 'audit') {
|
|
499
|
+
if (!subject) throw new Error(`volter-world ${cmd}: missing world name (usage: volter-world ${cmd} <world> [--json])`);
|
|
500
|
+
const status = statusWorld(subject, root);
|
|
501
|
+
const rows = worldUpkeep(status, cmd);
|
|
502
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
503
|
+
else {
|
|
504
|
+
if (rows.length === 0) process.stdout.write(`World ${status.name}: no twin mounted with a resolved version — boot it once (up) so instance.json records what was mounted\n`);
|
|
505
|
+
for (const r of rows) process.stdout.write(`${r.line}\n`);
|
|
506
|
+
}
|
|
507
|
+
if (rows.some((r) => r.attention)) process.exit(1);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (cmd === 'covers') {
|
|
511
|
+
if (!subject) throw new Error('volter-world covers: missing world name (usage: volter-world covers <world> --repo <path>)');
|
|
512
|
+
const repo = optionValue(rest, '--repo');
|
|
513
|
+
if (!repo) throw new Error('volter-world covers: --repo <path> is required (the application repo to prove coverage for)');
|
|
514
|
+
const report = coverWorld(subject, repo, { root, allowUnknown: rest.includes('--allow-unknown'), acknowledge: acknowledgeOptions(rest, 'covers') });
|
|
515
|
+
process.stdout.write(rest.includes('--json') ? `${JSON.stringify(report, null, 2)}\n` : formatCoverageReport(report));
|
|
516
|
+
if (!report.ok) process.exit(1);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (cmd === 'clock') {
|
|
521
|
+
// THE operator door for world time (physics): show / set <iso> / advance <duration>.
|
|
522
|
+
// The clock is a frozen instant every twin reads per request (kernel worldNow()); setting
|
|
523
|
+
// or advancing takes effect live, no restarts. `advance` requires a set clock (advancing
|
|
524
|
+
// wall-clock would silently freeze time as a side effect).
|
|
525
|
+
// argv shape here: cmd='clock', subject=<world>, rest=[action, value?, flags...]
|
|
526
|
+
const name = subject;
|
|
527
|
+
const [action, value] = rest;
|
|
528
|
+
if (!name || !action || (action !== 'show' && action !== 'set' && action !== 'advance')) {
|
|
529
|
+
console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> [--root <repo>]');
|
|
530
|
+
process.exit(2);
|
|
531
|
+
}
|
|
532
|
+
const rootDir = resolve(optionValue(rest, '--root') ?? process.cwd());
|
|
533
|
+
const file = clockFile(rootDir, name);
|
|
534
|
+
if (action === 'show') {
|
|
535
|
+
console.log(existsSync(file) ? `${readFileSync(file, 'utf8').trim()} (frozen)` : `${new Date().toISOString()} (wall clock — no world clock set)`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (action === 'set') {
|
|
539
|
+
const parsed = Date.parse(value ?? '');
|
|
540
|
+
if (Number.isNaN(parsed)) { console.error(`clock set: ${JSON.stringify(value)} is not an ISO-8601 instant`); process.exit(2); }
|
|
541
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
542
|
+
writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
|
|
543
|
+
console.log(new Date(parsed).toISOString());
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec((value ?? '').trim());
|
|
547
|
+
if (!m) { console.error(`clock advance: ${JSON.stringify(value)} is not <N>(s|m|h|d)`); process.exit(2); }
|
|
548
|
+
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); }
|
|
549
|
+
const base = Date.parse(readFileSync(file, 'utf8').trim());
|
|
550
|
+
const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
|
|
551
|
+
const next = new Date(base + Number(m[1]) * unit).toISOString();
|
|
552
|
+
writeFileSync(file, `${next}\n`);
|
|
553
|
+
console.log(next);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (cmd === 'urls') {
|
|
558
|
+
if (!subject) throw new Error('volter-world urls: missing world name');
|
|
559
|
+
const urls = urlsWorld(subject, root);
|
|
560
|
+
if (rest.includes('--json')) {
|
|
561
|
+
process.stdout.write(`${JSON.stringify(urls, null, 2)}\n`);
|
|
562
|
+
} else {
|
|
563
|
+
process.stdout.write(`World ${urls.name} (${urls.mode})\n`);
|
|
564
|
+
for (const [serviceId, service] of Object.entries(urls.services)) {
|
|
565
|
+
process.stdout.write(` ${serviceId}: ${service.localUrl}${service.publicUrl ? ` public=${service.publicUrl}` : ''}\n`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (cmd === 'url') {
|
|
572
|
+
if (!subject) throw new Error('volter-world url: missing world name (usage: volter-world url <world> [service])');
|
|
573
|
+
const [service] = positionalArgs(rest);
|
|
574
|
+
const urls = urlWorld(subject, { root, ...(service === undefined ? {} : { service }) });
|
|
575
|
+
if (service !== undefined) process.stdout.write(`${urls[0]!.url}\n`);
|
|
576
|
+
else for (const entry of urls) process.stdout.write(`${entry.id} ${entry.url}\n`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (cmd === 'app-url') {
|
|
581
|
+
if (!subject) throw new Error('volter-world app-url: missing world name (usage: volter-world app-url <world> [--set <url>|--detect <pid|port>])');
|
|
582
|
+
const set = optionValue(rest, '--set');
|
|
583
|
+
const detect = optionValue(rest, '--detect');
|
|
584
|
+
if (set && detect) throw new Error('volter-world app-url: pass --set OR --detect, not both');
|
|
585
|
+
const json = rest.includes('--json');
|
|
586
|
+
// --host (repeatable): a hostname the app answers as in production, routed to it inside the World
|
|
587
|
+
const hosts = rest.flatMap((arg, index) => (arg === '--host' ? [rest[index + 1] ?? ''] : []));
|
|
588
|
+
if (set || detect || hosts.length) {
|
|
589
|
+
let record = set ? setAppUrl(subject, set, { root }) : detect ? await detectAppUrl(subject, detect, { root }) : undefined;
|
|
590
|
+
if (hosts.length) record = addAppHosts(subject, hosts, { root });
|
|
591
|
+
if (json) process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
592
|
+
else process.stdout.write(`Recorded app URL for world ${subject}: ${record!.url}${record!.detail ? ` (${record!.detail})` : ''}${record!.hosts?.length ? `; answering as ${record!.hosts.join(', ')}` : ''}\n`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const record = readAppUrl(subject, { root });
|
|
596
|
+
if (record === null) {
|
|
597
|
+
// LOUD when unset — a consumer must never mistake "nobody registered it" for an endpoint.
|
|
598
|
+
process.stderr.write(`${appUrlUnsetMessage(subject)}\n`);
|
|
599
|
+
if (json) process.stdout.write(`${JSON.stringify({ world: subject, url: null }, null, 2)}\n`);
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
// bare URL on stdout: ready for `curl "$(volter-world app-url <world>)/health"` interpolation
|
|
603
|
+
process.stdout.write(json ? `${JSON.stringify(record, null, 2)}\n` : `${record.url}\n`);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (cmd === 'tail' || cmd === 'log') {
|
|
608
|
+
if (!subject) throw new Error(`volter-world ${cmd}: missing world name (usage: volter-world log <world> [service...])`);
|
|
609
|
+
const services = positionalArgs(rest);
|
|
610
|
+
const controller = new AbortController();
|
|
611
|
+
process.once('SIGINT', () => controller.abort());
|
|
612
|
+
process.once('SIGTERM', () => controller.abort());
|
|
613
|
+
await tailWorldActions(subject, {
|
|
614
|
+
root,
|
|
615
|
+
...(services.length ? { services } : {}),
|
|
616
|
+
follow: !rest.includes('--no-follow'),
|
|
617
|
+
json: rest.includes('--json'),
|
|
618
|
+
requests: rest.includes('--requests'),
|
|
619
|
+
signal: controller.signal,
|
|
620
|
+
});
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (cmd === 'seed' || cmd === 'reset') {
|
|
625
|
+
if (!subject) throw new Error(`volter-world ${cmd}: missing world name (usage: volter-world ${cmd} <world> [--entry <file>] [--cwd <dir>])`);
|
|
626
|
+
const entry = optionValue(rest, '--entry') || undefined;
|
|
627
|
+
const cwd = optionValue(rest, '--cwd') || undefined;
|
|
628
|
+
const json = rest.includes('--json');
|
|
629
|
+
const outcome = cmd === 'seed' ? await seedWorld(subject, { root, ...(entry ? { entry } : {}), ...(cwd ? { cwd } : {}) }) : await resetWorld(subject, { root, ...(entry ? { entry } : {}), ...(cwd ? { cwd } : {}) });
|
|
630
|
+
if (json) process.stdout.write(`${JSON.stringify({ world: outcome.world, entry: outcome.entry, exitCode: outcome.exitCode, observed: outcome.observed }, null, 2)}\n`);
|
|
631
|
+
else process.stdout.write(`${cmd === 'reset' ? 'Reset' : 'Seeded'} world ${subject} from the placeholder remote (${outcome.entry})\n${Object.entries(outcome.observed).map(([s, n]) => ` ${s}: ${n} observed`).join('\n')}\n${outcome.exitCode === 0 ? '' : ` seed entry exited ${outcome.exitCode}\n`}`);
|
|
632
|
+
process.exitCode = outcome.exitCode === 0 ? 0 : 1;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (cmd === 'branch') {
|
|
637
|
+
const [name] = positionalArgs(rest);
|
|
638
|
+
if (!subject || !name) throw new Error('volter-world branch: usage: volter-world branch <world> <name> [--env-file=<path>]');
|
|
639
|
+
const envFile = optionValue(rest, '--env-file') || undefined;
|
|
640
|
+
const { instance, forked } = await branchWorld(subject, name, { root, ...(envFile ? { envFile } : {}) });
|
|
641
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name, base: subject, forked }, null, 2)}\n`);
|
|
642
|
+
else { process.stdout.write(`Branched world ${name} from ${subject}\n${Object.entries(forked).map(([s, states]) => ` ${s}: mirror forked (${states.join(', ')})`).join('\n')}\n`); printReady(instance); }
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (cmd === 'checkout') {
|
|
646
|
+
if (!subject) throw new Error('volter-world checkout: missing world name');
|
|
647
|
+
const instance = await checkoutWorld(subject, { root });
|
|
648
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name }, null, 2)}\n`); else printReady(instance);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (cmd === 'fetch') {
|
|
652
|
+
if (!subject) throw new Error('volter-world fetch: missing world name (usage: volter-world fetch <world> --key <key> [--origin <url> --namespace <org>/<world>] [--full])');
|
|
653
|
+
const key = optionValue(rest, '--key') || process.env.VOLTER_TWINS_READ_KEY || process.env.VOLTER_TWINS_KEY || '';
|
|
654
|
+
if (!key) throw new Error('volter-world fetch: --key <twins key> is required (the read key for a fetch, the admin key for --full)');
|
|
655
|
+
const services: string[] = []; for (let i = 0; i < rest.length; i++) if (rest[i] === '--service' && rest[i + 1]) services.push(rest[i + 1]!);
|
|
656
|
+
const url = optionValue(rest, '--origin') || undefined; const namespace = optionValue(rest, '--namespace') || undefined;
|
|
657
|
+
const outcome = await fetchFromOrigin(subject, { root, key, ...(url ? { url } : {}), ...(namespace ? { namespace } : {}), ...(rest.includes('--full') ? { full: true } : {}), ...(services.length ? { services } : {}) });
|
|
658
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
659
|
+
else if (outcome.full) process.stdout.write(`Cloned ${outcome.origin.namespace} from ${outcome.origin.url} into world ${subject} (${outcome.files} files)\n`);
|
|
660
|
+
else process.stdout.write(`Fetched from ${outcome.origin.namespace} at ${outcome.origin.url} into world ${subject}\n${Object.entries(outcome.appended).map(([k, n]) => ` ${k}: ${n} observed`).join('\n')}\n`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (cmd === 'origin') {
|
|
664
|
+
if (!subject) throw new Error('volter-world origin: missing world name');
|
|
665
|
+
const origin = worldOrigin(subject, root);
|
|
666
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(origin, null, 2)}\n`);
|
|
667
|
+
else process.stdout.write(origin ? `${origin.namespace} at ${origin.url}${origin.fetchedAt ? ` (fetched ${origin.fetchedAt})` : ''}\n` : 'placeholder (no hosted origin recorded — `volter-world fetch <world> --full --origin <url> --namespace <org>/<world> --key <admin key>` clones one)\n');
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (cmd === 'mark') {
|
|
672
|
+
if (!subject) throw new Error('volter-world mark: missing world name (usage: volter-world mark <world> [--id <marker>] [--note <text>])');
|
|
673
|
+
const id = optionValue(rest, '--id');
|
|
674
|
+
const note = optionValue(rest, '--note');
|
|
675
|
+
const marker = markWorld(subject, { root, ...(id ? { id } : {}), ...(note ? { note } : {}) });
|
|
676
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(marker, null, 2)}\n`);
|
|
677
|
+
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`);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (cmd === 'diff') {
|
|
682
|
+
if (!subject) throw new Error('volter-world diff: missing world name (usage: volter-world diff <world> [--base <marker>])');
|
|
683
|
+
const base = optionValue(rest, '--base');
|
|
684
|
+
const delta = diffWorld(subject, { root, ...(base ? { base } : {}) });
|
|
685
|
+
process.stdout.write(rest.includes('--json') ? `${JSON.stringify(delta, null, 2)}\n` : formatLedgerDelta(delta));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (cmd === 'changeset') {
|
|
690
|
+
const CHANGESET_VALUE_FLAGS = ['--root', '--base', '--into', '--world', '--verifier', '--as', '--note'];
|
|
691
|
+
const args = positionalArgs(rest, CHANGESET_VALUE_FLAGS);
|
|
692
|
+
const world = optionValue(rest, '--world');
|
|
693
|
+
const json = rest.includes('--json');
|
|
694
|
+
if (subject === 'create') {
|
|
695
|
+
const [worldName, name] = args;
|
|
696
|
+
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]"]...');
|
|
697
|
+
const base = optionValue(rest, '--base');
|
|
698
|
+
const verifiers = rest
|
|
699
|
+
.flatMap((flag, index) => (flag === '--verifier' && rest[index + 1] ? [rest[index + 1]!] : []))
|
|
700
|
+
.map((expression, index) => parseVerifierExpression(expression, `v${index + 1}`));
|
|
701
|
+
const changeset = createWorldChangeset(worldName, name, { root, overwrite: rest.includes('--force'), ...(base ? { base } : {}), ...(verifiers.length ? { verifiers } : {}) });
|
|
702
|
+
process.stdout.write(json ? `${JSON.stringify(changeset, null, 2)}\n` : formatChangeset(changeset));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (subject === 'show') {
|
|
706
|
+
const [name] = args;
|
|
707
|
+
if (!name) throw new Error('volter-world changeset show: usage: volter-world changeset show <name> [--world <world>]');
|
|
708
|
+
const located = findWorldChangeset(name, { root, ...(world ? { world } : {}) });
|
|
709
|
+
process.stdout.write(json ? `${JSON.stringify(located.changeset, null, 2)}\n` : formatChangeset(located.changeset));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (subject === 'list') {
|
|
713
|
+
const found = listWorldChangesets({ root, ...(world ? { world } : {}) });
|
|
714
|
+
if (json) {
|
|
715
|
+
process.stdout.write(`${JSON.stringify(found.map((entry) => ({ ...entry.changeset, path: entry.path })), null, 2)}\n`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (!found.length) process.stdout.write('No changesets yet — create one with `volter-world changeset create <world> <name>`\n');
|
|
719
|
+
for (const entry of found) {
|
|
720
|
+
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`);
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (subject === 'replay') {
|
|
725
|
+
const [name] = args;
|
|
726
|
+
if (!name) throw new Error('volter-world changeset replay: usage: volter-world changeset replay <name> --into <world>');
|
|
727
|
+
const into = optionValue(rest, '--into');
|
|
728
|
+
if (!into) throw new Error('volter-world changeset replay: --into <world> is required (the world to replay the changeset into)');
|
|
729
|
+
const report = await replayWorldChangeset(name, { root, into, ...(world ? { world } : {}) });
|
|
730
|
+
process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : formatReplayReport(report));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (subject === 'verify') {
|
|
734
|
+
const [name] = args;
|
|
735
|
+
if (!name) throw new Error('volter-world changeset verify: usage: volter-world changeset verify <name> (--into <world> | --ephemeral)');
|
|
736
|
+
const into = optionValue(rest, '--into');
|
|
737
|
+
const outcome = await verifyWorldChangeset(name, { root, ...(into ? { into } : {}), ephemeral: rest.includes('--ephemeral'), ...(world ? { world } : {}) });
|
|
738
|
+
process.stdout.write(json ? `${JSON.stringify(outcome.verification, null, 2)}\n` : formatVerification(name, outcome.verification, outcome.report));
|
|
739
|
+
if (!outcome.verification.passed) process.exitCode = 1;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (subject === 'approve') {
|
|
743
|
+
const [name] = args;
|
|
744
|
+
if (!name) throw new Error('volter-world changeset approve: usage: volter-world changeset approve <name> --as <principal> [--note <text>]');
|
|
745
|
+
const principal = optionValue(rest, '--as');
|
|
746
|
+
if (!principal) throw new Error('volter-world changeset approve: --as <principal> is required (who is signing)');
|
|
747
|
+
const note = optionValue(rest, '--note');
|
|
748
|
+
const outcome = approveWorldChangeset(name, { root, principal, ...(note ? { note } : {}), ...(world ? { world } : {}) });
|
|
749
|
+
if (json) process.stdout.write(`${JSON.stringify(outcome.approval, null, 2)}\n`);
|
|
750
|
+
else process.stdout.write(`Approved changeset ${name} as ${outcome.approval.principal} at ${outcome.approval.at}\n bound to ${outcome.approval.contentHash}\n`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (subject === 'status') {
|
|
754
|
+
const [name] = args;
|
|
755
|
+
if (!name) throw new Error('volter-world changeset status: usage: volter-world changeset status <name> [--world <world>]');
|
|
756
|
+
const readiness = statusWorldChangeset(name, { root, ...(world ? { world } : {}) });
|
|
757
|
+
process.stdout.write(json ? `${JSON.stringify(readiness, null, 2)}\n` : formatChangesetStatus(readiness));
|
|
758
|
+
if (!readiness.ready) process.exitCode = 1;
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (subject === 'rebase') {
|
|
762
|
+
const [name] = args;
|
|
763
|
+
if (!name) throw new Error('volter-world changeset rebase: usage: volter-world changeset rebase <name> [--world <world>]');
|
|
764
|
+
const outcome = rebaseWorldChangeset(name, { root, ...(world ? { world } : {}) });
|
|
765
|
+
process.stdout.write(json ? `${JSON.stringify(outcome.report, null, 2)}\n` : formatRebaseReport(outcome.report));
|
|
766
|
+
if (outcome.report.outcome === 'conflict') process.exitCode = 1;
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (subject === 'apply' || subject === 'push') {
|
|
770
|
+
const [name] = args;
|
|
771
|
+
if (!name) throw new Error('volter-world changeset push: usage: volter-world changeset push <name> --key <namespace key> [--to <twins url> --namespace <org>/<world>] [--force]');
|
|
772
|
+
const key = optionValue(rest, '--key') || process.env.VOLTER_TWINS_KEY || '';
|
|
773
|
+
if (!key) throw new Error('volter-world changeset push: --key <namespace key> is required (a push is a write: the read key is refused; a vendor credential never leaves the remote)');
|
|
774
|
+
const to = optionValue(rest, '--to') || undefined; const namespace = optionValue(rest, '--namespace') || undefined;
|
|
775
|
+
const outcome = await pushWorldChangeset(name, { root, key, ...(to ? { to } : {}), ...(namespace ? { namespace } : {}), ...(rest.includes('--force') ? { force: true } : {}), ...(world ? { world } : {}) });
|
|
776
|
+
process.stdout.write(json ? `${JSON.stringify(outcome.application, null, 2)}\n` : `${formatApplication(name, outcome.application)} remote: ${outcome.remote.namespace} at ${outcome.remote.url}\n`);
|
|
777
|
+
if (outcome.unsent) process.stderr.write(`warning: ${outcome.unsent}\n`);
|
|
778
|
+
if (outcome.application.outcome !== 'applied') process.exitCode = 1;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
throw new Error(`volter-world changeset: unknown subcommand ${JSON.stringify(subject ?? '')} (want create|show|list|replay|verify|approve|status|rebase|push)`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (cmd === 'list') {
|
|
785
|
+
const listArgs = [subject, ...rest].filter((arg): arg is string => arg !== undefined);
|
|
786
|
+
const listRoot = optionValue(listArgs, '--root', process.cwd());
|
|
787
|
+
const worlds = listWorlds(listRoot).map(world => ({ ...world, usage: worldUsage(listRoot, world.name) }));
|
|
788
|
+
if (rest.includes('--json') || subject === '--json') process.stdout.write(`${JSON.stringify(worlds, null, 2)}\n`);
|
|
789
|
+
else for (const world of worlds) {
|
|
790
|
+
process.stdout.write(`${world.name}\t${!world.running ? 'stopped' : world.degraded ? 'degraded' : 'running'}\t${world.config ?? ''}\tcreated=${world.usage.createdAt ?? 'unknown'}\tlast-observed-use=${world.usage.lastUsedAt ?? 'unknown'} (${world.usage.lastUseSource ?? 'unrecorded'}; partial coverage)\n`);
|
|
791
|
+
for (const warning of world.usage.warnings) process.stdout.write(` WARNING: ${warning}\n`);
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (cmd === 'env') {
|
|
797
|
+
if (!subject) throw new Error('volter-world env: missing world name');
|
|
798
|
+
const split = rest.indexOf('--');
|
|
799
|
+
if (split < 0) throw new Error('volter-world env: pass command after --');
|
|
800
|
+
const exitCode = await attachWorld(subject, rest.slice(split + 1), root, { owner: ownerOption(rest.slice(0, split)), verbose: rest.slice(0, split).includes('--verbose') });
|
|
801
|
+
process.exit(exitCode);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (cmd === 'attach') {
|
|
805
|
+
// `subject` may be the ref, a flag, or the `--` separator — reassemble and split.
|
|
806
|
+
const args = [subject, ...rest].filter((arg): arg is string => arg !== undefined);
|
|
807
|
+
const split = args.indexOf('--');
|
|
808
|
+
const head = split >= 0 ? args.slice(0, split) : args;
|
|
809
|
+
const command = split >= 0 ? args.slice(split + 1) : [];
|
|
810
|
+
const explicit = head[0] !== undefined && !head[0].startsWith('-') ? head[0] : undefined;
|
|
811
|
+
const via = optionValue(head, '--via', 'env');
|
|
812
|
+
// when the ref is omitted, `subject` was a flag — the global root parse missed its value
|
|
813
|
+
const attachRoot = optionValue(head, '--root', process.cwd());
|
|
814
|
+
const resolved = resolveWorldRef(explicit, { cwd: process.cwd(), env: process.env });
|
|
815
|
+
if (ownerOption(head) !== undefined && (isRemoteWorldRef(resolved.ref) || via !== 'env')) {
|
|
816
|
+
throw new Error('--owner is supported for local attach --via env commands');
|
|
817
|
+
}
|
|
818
|
+
if (isRemoteWorldRef(resolved.ref)) {
|
|
819
|
+
// remote world: fetch the manifest, synthesize the attach env, exec
|
|
820
|
+
const attachToken = optionValue(head, '--token') || process.env.VOLTER_WORLD_TOKEN || undefined;
|
|
821
|
+
const manifest = await fetchRemoteManifest(resolved.ref, attachToken === undefined ? {} : { token: attachToken });
|
|
822
|
+
if (via === 'direct') {
|
|
823
|
+
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (via !== 'env') throw new Error(`volter-world attach: --via ${via} is not available for a remote world (env|direct)`);
|
|
827
|
+
if (command.length === 0) throw new Error('volter-world attach: pass the command after --');
|
|
828
|
+
let caFile: string | undefined;
|
|
829
|
+
if (manifest.ca !== null) {
|
|
830
|
+
caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
|
|
831
|
+
writeFileSync(caFile, manifest.ca);
|
|
832
|
+
}
|
|
833
|
+
const injectPath = createRequire(import.meta.url).resolve('@volter/world-core/inject');
|
|
834
|
+
const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
|
|
835
|
+
// the world's byte-stream doors, bridged to loopback listeners here, beside a command in any language
|
|
836
|
+
const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge') as { bridgeStreams: (streams: WorldManifest['streams'], token: string) => Promise<{ env: Record<string, string> }>; drainStreams: (ms?: number) => Promise<void> };
|
|
837
|
+
const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
|
|
838
|
+
// VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
|
|
839
|
+
const commandEnv: Record<string, string | undefined> = { ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref };
|
|
840
|
+
commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
|
|
841
|
+
const child = spawn(command[0]!, command.slice(1), { env: commandEnv, stdio: 'inherit' });
|
|
842
|
+
const code = await new Promise<number>((resolveExit) => { child.on('exit', (status) => resolveExit(status ?? 1)); child.on('error', () => resolveExit(127)); });
|
|
843
|
+
// a client that wrote and exited at once still has bytes on their way through a bridge
|
|
844
|
+
await drainStreams();
|
|
845
|
+
process.exit(code);
|
|
846
|
+
}
|
|
847
|
+
if (via === 'direct') {
|
|
848
|
+
process.stdout.write(`${JSON.stringify(worldManifest(resolved.ref, attachRoot), null, 2)}\n`);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (via === 'reflect') {
|
|
852
|
+
// The reflect attachment (docs/ATTACH.md): the consumer is unmodified; its DNS is the world's resolver
|
|
853
|
+
// and its TLS trust is the session CA. A `docker compose` command is composed here — every service
|
|
854
|
+
// gets the override — and everything else runs with the trust env and VOLTER_WORLD set, its DNS
|
|
855
|
+
// being the caller's to point (a container's --dns, a resolver on the host).
|
|
856
|
+
if (command.length === 0) throw new Error('volter-world attach: pass the command after --');
|
|
857
|
+
const manifest = readReflectManifest(attachRoot, resolved.ref);
|
|
858
|
+
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`);
|
|
859
|
+
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 };
|
|
860
|
+
Object.assign(env, sessionTrustEnv(manifest.caCertPath, env));
|
|
861
|
+
let toRun = command;
|
|
862
|
+
const compose = splitDockerComposeArgs(command);
|
|
863
|
+
if (compose) {
|
|
864
|
+
// the consumer's own service names, from its own files, through its own docker
|
|
865
|
+
const listed = spawnSync(compose.head[0]!, [...compose.head.slice(1), ...compose.composeFlags, 'config', '--services'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
866
|
+
if (listed.status !== 0) throw new Error(`volter-world attach --via reflect: docker compose config --services failed:\n${listed.stderr.toString().trim()}`);
|
|
867
|
+
const services = listed.stdout.toString().split('\n').map((s) => s.trim()).filter(Boolean);
|
|
868
|
+
const overridePath = join(instanceDir(attachRoot, resolved.ref), 'reflect-compose.override.yml');
|
|
869
|
+
writeFileSync(overridePath, composeOverrideForReflect(manifest, services, resolved.ref));
|
|
870
|
+
toRun = dockerComposeWithOverride(command, overridePath);
|
|
871
|
+
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`);
|
|
872
|
+
}
|
|
873
|
+
const result = spawnSync(toRun[0]!, toRun.slice(1), { env, stdio: 'inherit' });
|
|
874
|
+
process.exit(result.status ?? 1);
|
|
875
|
+
}
|
|
876
|
+
if (via !== 'env') {
|
|
877
|
+
throw new Error(`volter-world attach: --via ${via} is not available (env|reflect|direct)`);
|
|
878
|
+
}
|
|
879
|
+
if (command.length === 0) throw new Error('volter-world attach: pass the command after -- (or use --via direct for the manifest)');
|
|
880
|
+
// the world resolves at --root; the COMMAND runs where the caller stands —
|
|
881
|
+
// an attached repo's `pnpm start` must run in the repo, not the twin root
|
|
882
|
+
// (ponder blind-adoption finding)
|
|
883
|
+
process.exit(await attachWorld(resolved.ref, command, attachRoot, { cwd: process.cwd(), owner: ownerOption(head), verbose: head.includes('--verbose') }));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (cmd === 'manifest') {
|
|
887
|
+
if (!subject) throw new Error('volter-world manifest: missing world name');
|
|
888
|
+
process.stdout.write(`${JSON.stringify(worldManifest(subject, root), null, 2)}\n`);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (cmd === 'reflect') {
|
|
893
|
+
if (!subject) throw new Error('volter-world reflect: missing world name');
|
|
894
|
+
const targetIp = optionValue(rest, '--target-ip');
|
|
895
|
+
if (!targetIp) throw new Error('volter-world reflect: --target-ip <ip> is required (the address attachers reach the front at)');
|
|
896
|
+
const routesPath = reflectRoutesPath(root, subject);
|
|
897
|
+
const front = await startReflectFront({
|
|
898
|
+
envLoader: () => statusWorld(subject, root).env,
|
|
899
|
+
tlsDir: join(instanceDir(root, subject), 'tls'),
|
|
900
|
+
port: Number(optionValue(rest, '--port', '0')) || 0,
|
|
901
|
+
});
|
|
902
|
+
const resolver = await startReflectResolver({
|
|
903
|
+
routesLoader: () => readReflectRoutes(routesPath),
|
|
904
|
+
targetIp,
|
|
905
|
+
upstream: optionValue(rest, '--upstream') || undefined,
|
|
906
|
+
port: Number(optionValue(rest, '--resolver-port', '0')) || 0,
|
|
907
|
+
});
|
|
908
|
+
const resolverIp = optionValue(rest, '--resolver-ip') || targetIp;
|
|
909
|
+
writeReflectManifest(root, subject, { targetIp, resolverIp, frontPort: front.port, resolverPort: resolver.port, caCertPath: front.caCertPath });
|
|
910
|
+
process.stdout.write(`Reflect attachment for world ${subject}:\n`);
|
|
911
|
+
process.stdout.write(` front: ${targetIp}:${front.port} (SNI TLS door — session CA: ${front.caCertPath})\n`);
|
|
912
|
+
process.stdout.write(` resolver: ${resolverIp}:${resolver.port} (point the attacher's DNS here, e.g. docker run --dns)\n`);
|
|
913
|
+
process.stdout.write(` routes: ${routesPath} (volter-world route ${subject} add <host>)\n`);
|
|
914
|
+
process.stdout.write('Foreground — Ctrl+C to stop. The runtime supervises nothing (see docs/guides/route-a-cli-through-the-world.md).\n');
|
|
915
|
+
await new Promise<void>((resolveSignal) => {
|
|
916
|
+
process.once('SIGINT', () => resolveSignal());
|
|
917
|
+
process.once('SIGTERM', () => resolveSignal());
|
|
918
|
+
});
|
|
919
|
+
clearReflectManifest(root, subject);
|
|
920
|
+
await resolver.close();
|
|
921
|
+
await front.close();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (cmd === 'serve') {
|
|
926
|
+
if (!subject) throw new Error('volter-world serve: missing world name');
|
|
927
|
+
const advertise = optionValue(rest, '--advertise');
|
|
928
|
+
if (!advertise) throw new Error('volter-world serve: --advertise <https-origin> is required (how attachers reach the door, e.g. https://worlds.corp:8443)');
|
|
929
|
+
let advertisedUrl: URL;
|
|
930
|
+
try {
|
|
931
|
+
advertisedUrl = new URL(advertise);
|
|
932
|
+
} catch {
|
|
933
|
+
throw new Error(`volter-world serve: --advertise must be an origin URL, got "${advertise}"`);
|
|
934
|
+
}
|
|
935
|
+
if (advertisedUrl.protocol !== 'https:') throw new Error('volter-world serve: the advertised origin must be https (the door terminates TLS with the session CA)');
|
|
936
|
+
const frontPort = Number(advertisedUrl.port) || 443;
|
|
937
|
+
const front = await startReflectFront({
|
|
938
|
+
envLoader: () => statusWorld(subject, root).env,
|
|
939
|
+
tlsDir: join(instanceDir(root, subject), 'tls'),
|
|
940
|
+
port: Number(optionValue(rest, '--port', String(frontPort))) || frontPort,
|
|
941
|
+
extraHosts: [advertisedUrl.hostname],
|
|
942
|
+
});
|
|
943
|
+
const serveToken = optionValue(rest, '--token') || undefined;
|
|
944
|
+
const manifestServer = await startManifestServer({
|
|
945
|
+
manifest: () => advertiseWorldManifest(subject, root, advertisedUrl.origin),
|
|
946
|
+
port: Number(optionValue(rest, '--manifest-port', '0')) || 0,
|
|
947
|
+
...(serveToken === undefined ? {} : { token: serveToken }),
|
|
948
|
+
});
|
|
949
|
+
process.stdout.write(`Serving world ${subject}:\n`);
|
|
950
|
+
process.stdout.write(` door: ${advertisedUrl.origin} (SNI TLS — vendors + the advertised name; session CA: ${front.caCertPath})\n`);
|
|
951
|
+
process.stdout.write(` manifest: http://0.0.0.0:${manifestServer.port}${MANIFEST_PATH}\n`);
|
|
952
|
+
process.stdout.write(` attach: volter-world attach http://<this-host>:${manifestServer.port} -- <command...>\n`);
|
|
953
|
+
process.stdout.write('Foreground — Ctrl+C to stop. Read-only publication; the runtime supervises nothing.\n');
|
|
954
|
+
await new Promise<void>((resolveSignal) => {
|
|
955
|
+
process.once('SIGINT', () => resolveSignal());
|
|
956
|
+
process.once('SIGTERM', () => resolveSignal());
|
|
957
|
+
});
|
|
958
|
+
await manifestServer.close();
|
|
959
|
+
await front.close();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (cmd === 'route') {
|
|
964
|
+
if (!subject) throw new Error('volter-world route: missing world name');
|
|
965
|
+
const [verb, host] = rest.filter((arg) => !arg.startsWith('--'));
|
|
966
|
+
const routesPath = reflectRoutesPath(root, subject);
|
|
967
|
+
const routes = readReflectRoutes(routesPath);
|
|
968
|
+
if (verb === 'ls' || verb === undefined) {
|
|
969
|
+
for (const entry of [...routes].sort()) process.stdout.write(`${entry}\n`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (!host) throw new Error(`volter-world route: ${verb} needs a host`);
|
|
973
|
+
if (verb === 'add') routes.add(host.toLowerCase());
|
|
974
|
+
else if (verb === 'rm') routes.delete(host.toLowerCase());
|
|
975
|
+
else throw new Error(`volter-world route: unknown verb "${verb}" (add|rm|ls)`);
|
|
976
|
+
writeReflectRoutes(routesPath, routes);
|
|
977
|
+
process.stdout.write(`${[...routes].sort().join('\n')}${routes.size > 0 ? '\n' : ''}`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (cmd === 'activate') {
|
|
982
|
+
if (!subject) throw new Error('volter-world activate: missing world name');
|
|
983
|
+
process.stdout.write(activateScript(subject, root));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (cmd === 'shell') {
|
|
988
|
+
if (!subject) throw new Error('volter-world shell: missing world name');
|
|
989
|
+
process.exit(await shellWorld(subject, root));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (cmd === 'run') {
|
|
993
|
+
if (!subject) throw new Error('volter-world run: missing config');
|
|
994
|
+
const split = rest.indexOf('--');
|
|
995
|
+
if (split < 0) throw new Error('volter-world run: pass command after --');
|
|
996
|
+
const envFile = optionValue(rest, '--env-file');
|
|
997
|
+
if (!envFile) throw new Error('volter-world run: missing required --env-file=<path>');
|
|
998
|
+
const name = optionValue(rest, '--name') || undefined;
|
|
999
|
+
const mode = optionValue(rest, '--mode', 'local') as 'local' | 'share' | 'sealed';
|
|
1000
|
+
const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, owner: ownerOption(rest.slice(0, split)), keep: rest.slice(0, split).includes('--keep'), verbose: rest.slice(0, split).includes('--verbose') });
|
|
1001
|
+
if (result.exitCode !== 0) {
|
|
1002
|
+
const detail = result.outcome.error ? `could not start (${result.outcome.error})`
|
|
1003
|
+
: result.outcome.signal ? `was terminated by ${result.outcome.signal}`
|
|
1004
|
+
: `exited with code ${result.outcome.exitCode}`;
|
|
1005
|
+
process.stderr.write(`World ${result.instance.name}: foreground consumer failed: ${detail}\nLog: ${result.outcome.log}\n`);
|
|
1006
|
+
// A command that failed because a service under it died says so: each service that ended on its own during the
|
|
1007
|
+
// run (not by the World's teardown after it) is named with its end.
|
|
1008
|
+
for (const service of Object.values(result.instance.services)) {
|
|
1009
|
+
if (service.type === 'external' || !service.log) continue;
|
|
1010
|
+
const end = readServiceEnd(service.log);
|
|
1011
|
+
if (!end?.exited || end.exited.stopRequested) continue;
|
|
1012
|
+
process.stderr.write(` service ${service.id} ended during the run: it ${describeServiceEnd(end)}${end.lastOutput.length ? `; last output: ${end.lastOutput[end.lastOutput.length - 1]}` : ''}\n log: ${service.log}\n`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
process.exit(result.exitCode);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (cmd === 'share') {
|
|
1019
|
+
if (!subject) throw new Error('volter-world share: missing world name');
|
|
1020
|
+
const service = optionValue(rest, '--service') || undefined;
|
|
1021
|
+
const verifyPath = rest.includes('--no-verify')
|
|
1022
|
+
? false
|
|
1023
|
+
: rest.includes('--verify')
|
|
1024
|
+
? optionValue(rest, '--verify', '/health')
|
|
1025
|
+
: undefined;
|
|
1026
|
+
const split = rest.indexOf('--');
|
|
1027
|
+
const command = optionValue(rest, '--command') || undefined;
|
|
1028
|
+
const providerValue = optionValue(rest, '--provider') || undefined;
|
|
1029
|
+
if (providerValue !== undefined && providerValue !== 'cloudflare-quick' && providerValue !== 'command') {
|
|
1030
|
+
throw new Error('volter-world share: --provider must be cloudflare-quick or command');
|
|
1031
|
+
}
|
|
1032
|
+
const provider = providerValue as 'cloudflare-quick' | 'command' | undefined;
|
|
1033
|
+
if (provider === 'cloudflare-quick' && command) {
|
|
1034
|
+
throw new Error('volter-world share: --provider cloudflare-quick uses the built-in cloudflared command and cannot be combined with --command');
|
|
1035
|
+
}
|
|
1036
|
+
if (split >= 0 && !command) throw new Error('volter-world share: -- <args> requires --command <cmd>');
|
|
1037
|
+
const args = command && split >= 0 ? rest.slice(split + 1) : undefined;
|
|
1038
|
+
const instance = await shareWorldServices(subject, { root, service, verifyPath, provider, command, args, ephemeral: rest.includes('--ephemeral') || undefined });
|
|
1039
|
+
const serviceIds = service ? [service] : Object.values(instance.services)
|
|
1040
|
+
.filter((candidate) => candidate.publicUrl)
|
|
1041
|
+
.map((candidate) => candidate.id);
|
|
1042
|
+
for (const serviceId of serviceIds) {
|
|
1043
|
+
const shared = instance.services[serviceId];
|
|
1044
|
+
process.stdout.write(`${shared?.publicReady ? 'Shared' : 'Shared (unverified)'} ${subject}/${serviceId}: ${shared?.publicUrl}\n`);
|
|
1045
|
+
}
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (cmd === 'unshare') {
|
|
1050
|
+
if (!subject) throw new Error('volter-world unshare: missing world name');
|
|
1051
|
+
const service = optionValue(rest, '--service') || undefined;
|
|
1052
|
+
const result = unshareWorld(subject, { root, service });
|
|
1053
|
+
process.stdout.write(`Unshared world ${subject}${service ? `/${service}` : ''}\n`);
|
|
1054
|
+
if (result.escalated.length) process.stdout.write(`Escalated to SIGKILL (tunnel ignored SIGTERM past the grace): ${result.escalated.join(', ')}\n`);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
throw new Error(`Unknown command: ${cmd}`);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
main().catch((error) => {
|
|
1062
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1063
|
+
process.exit(1);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
/** The rows `outdated` / `audit` print. `outdated`: pinned vs mounted vs catalog-current version and the
|
|
1067
|
+
* protocol standing; `audit`: the catalog's upkeep verdicts for each mounted twin — the invariant
|
|
1068
|
+
* matrix's grandfathered cells and the spec census baseline — read from the pack's committed
|
|
1069
|
+
* census.json. `attention` marks a row the operator should act on (exit 1). */
|
|
1070
|
+
function worldUpkeep(status: ReturnType<typeof statusWorld>, kind: 'outdated' | 'audit'): Array<{ id: string; line: string; attention: boolean }> {
|
|
1071
|
+
const twinRoot = join(import.meta.dir, '..', '..', 'twin'); // the catalog: packages/twin
|
|
1072
|
+
const out: Array<{ id: string; line: string; attention: boolean }> = [];
|
|
1073
|
+
for (const [id, svc] of Object.entries(status.services)) {
|
|
1074
|
+
if (svc.type !== 'twin' && svc.resolvedVersion === undefined) continue;
|
|
1075
|
+
const vendor = id;
|
|
1076
|
+
let current: string | undefined;
|
|
1077
|
+
try { current = (JSON.parse(readFileSync(join(twinRoot, vendor, 'package.json'), 'utf8')) as { version?: string }).version; } catch { current = undefined; }
|
|
1078
|
+
const pinned = status.services[id] && (svc as { version?: string }).version;
|
|
1079
|
+
if (kind === 'outdated') {
|
|
1080
|
+
const behind = svc.resolvedVersion !== undefined && current !== undefined && svc.resolvedVersion !== current;
|
|
1081
|
+
const standing = svc.protocol?.standing ?? 'unknown';
|
|
1082
|
+
const attention = behind || standing === 'deprecated' || standing === 'refused';
|
|
1083
|
+
out.push({ id, attention, line: `${id.padEnd(18)} mounted ${(svc.resolvedVersion ?? '?').padEnd(8)} catalog ${(current ?? '?').padEnd(8)} ${behind ? 'BEHIND' : 'current'} protocol ${svc.protocol ? `major ${svc.protocol.major} (${standing})` : 'unknown'}${pinned ? ` pinned ${pinned}` : ''}` });
|
|
1084
|
+
} else {
|
|
1085
|
+
let census: { invariants?: Record<string, string>; spec?: unknown } | undefined;
|
|
1086
|
+
try { census = JSON.parse(readFileSync(join(twinRoot, vendor, 'census.json'), 'utf8')); } catch { census = undefined; }
|
|
1087
|
+
const debt = Object.entries(census?.invariants ?? {}).filter(([, v]) => v === 'grandfathered').map(([k]) => k);
|
|
1088
|
+
const attention = debt.length > 0;
|
|
1089
|
+
// R19 — the pin's age: MAINTAINERS.md's `last verified` cell is when this package's vendor
|
|
1090
|
+
// generation was last proven against a fresh spec; a stale one is drift nobody has looked for.
|
|
1091
|
+
let maintained = '';
|
|
1092
|
+
try {
|
|
1093
|
+
const row = readFileSync(join(twinRoot, '..', '..', 'policy', 'MAINTAINERS.md'), 'utf8').split('\n').find((l) => l.startsWith(`| ${vendor} |`));
|
|
1094
|
+
const cells = row ? row.split('|').map((c) => c.trim()) : [];
|
|
1095
|
+
if (cells.length > 8) {
|
|
1096
|
+
const days = Math.floor((Date.now() - Date.parse(cells[8]!)) / 86_400_000);
|
|
1097
|
+
maintained = ` ${cells[2]}, last verified ${cells[8]}${Number.isFinite(days) ? ` (${days}d ago)` : ''}`;
|
|
1098
|
+
if (Number.isFinite(days) && days > 30) maintained += ' STALE';
|
|
1099
|
+
}
|
|
1100
|
+
} catch { maintained = ''; }
|
|
1101
|
+
out.push({ id, attention: attention || maintained.endsWith('STALE'), line: `${id.padEnd(18)} matrix debt: ${debt.length ? debt.join(', ') : 'none'} census: ${census ? 'committed' : 'NONE (not Supported)'}${maintained}` });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return out;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** The files a service wrote into its own log directory (`<log>.d`, VOLTER_WORLD_SERVICE_LOG_DIR), paths in full. */
|
|
1108
|
+
function ownLogs(log: string): string[] {
|
|
1109
|
+
const dir = `${log.replace(/\.log$/, '')}.d`;
|
|
1110
|
+
try { return readdirSync(dir).map((entry) => join(dir, entry)); } catch { return []; }
|
|
1111
|
+
}
|