@volter/world-runtime 2.0.15 → 2.0.17

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.
@@ -156,7 +156,8 @@ export async function mountWorld(name, opts = {}) {
156
156
  const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
157
157
  const keep = () => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
158
158
  let timers = [];
159
- return {
159
+ let stopping = null;
160
+ const mounted = {
160
161
  name, served, root: worldRoot,
161
162
  get token() { return world.token; }, get readToken() { return world.readToken; },
162
163
  twins: () => world.twins(),
@@ -193,18 +194,40 @@ export async function mountWorld(name, opts = {}) {
193
194
  throw error;
194
195
  }
195
196
  },
196
- async stop() {
197
- for (const t of timers)
198
- clearInterval(t);
199
- try {
200
- rmSync(record, { force: true });
201
- }
202
- catch { /* already gone */ }
203
- const result = await downWorld(name, worldRoot);
204
- if (result.externalErrors.length)
205
- throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
197
+ stop() {
198
+ // one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
199
+ stopping ??= stopOnce().finally(() => { stopping = null; });
200
+ return stopping;
206
201
  },
207
202
  };
203
+ async function stopOnce() {
204
+ // refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
205
+ // down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
206
+ // refused before anything stopped, the World is served as it was; failed part way, its record names what remains
207
+ for (const t of timers)
208
+ clearInterval(t);
209
+ timers = [];
210
+ let result;
211
+ try {
212
+ result = await downWorld(name, worldRoot);
213
+ if (result.externalErrors.length)
214
+ throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
215
+ }
216
+ catch (error) {
217
+ if (instance) {
218
+ try {
219
+ timers = await scheduleRefreshes(name, worldRoot, instance);
220
+ }
221
+ catch { /* the stop's error is the one reported */ }
222
+ }
223
+ throw error;
224
+ }
225
+ try {
226
+ rmSync(record, { force: true });
227
+ }
228
+ catch { /* already gone */ }
229
+ }
230
+ return mounted;
208
231
  }
209
232
  /** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
210
233
  const localMirrors = new Map();
@@ -1564,8 +1587,10 @@ export class WorldDoors {
1564
1587
  const state = this.stateOf(vendor);
1565
1588
  const controlRoot = this.controlRoot(vendor);
1566
1589
  const counts = new Map();
1590
+ // what the World holds as the vendor's own resources: the twin's bookkeeping (a `_` type, as a screens sign-in's token)
1591
+ // is never a resource a person reads, as it is in no log or diff
1567
1592
  for (const r of readTree(state, controlRoot))
1568
- if (r.deleted !== true)
1593
+ if (r.deleted !== true && !r.type.startsWith('_'))
1569
1594
  counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
1570
1595
  twins.push({
1571
1596
  twin: vendor, position: wholeLog(state, controlRoot).length,
@@ -55,17 +55,8 @@ export async function serveWorldView(name, opts = {}) {
55
55
  switching = true;
56
56
  const from = world.name;
57
57
  try {
58
- // a stop that fails part way brings this branch back as it was before the error is reported
59
- try {
60
- await world.stop();
61
- }
62
- catch (error) {
63
- try {
64
- await world.boot(url);
65
- }
66
- catch { /* the stop's own error is the one reported */ }
67
- throw error;
68
- }
58
+ // a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
59
+ await world.stop();
69
60
  let next;
70
61
  try {
71
62
  next = await mountWorld(branch, mountOpts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.15",
3
+ "version": "2.0.17",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.9",
70
+ "@volter/world-core": "2.0.10",
71
71
  "@volter/world-console": "2.0.9"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.1",
77
- "@volter/world-core": "2.0.9",
77
+ "@volter/world-core": "2.0.10",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.9"
87
+ "@volter/twin-mongodb": "0.1.10"
88
88
  }
89
89
  }
package/src/infra-cli.ts CHANGED
@@ -1,177 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // Private implementation for infrastructure emitted by `volter-world init`. World configs invoke
3
- // this declared service; application agents never operate or diagnose its backing mechanism.
4
- import { existsSync, readFileSync } from 'node:fs';
5
- import { dirname, join } from 'node:path';
6
- import { spawnSync } from 'node:child_process';
7
- import { fileURLToPath } from 'node:url';
8
- import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
2
+ // `volter-world-infra up|status|down`: the World-managed infrastructure's declared lifecycle commands (infra.ts), as
3
+ // a process. World configs invoke this declared service; application agents never operate or diagnose its backing.
4
+ import { runInfrastructure } from './infra.ts';
9
5
 
10
- const phase = process.argv[2];
11
- if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
12
- process.stderr.write('managed infrastructure: expected up, status, or down\n');
13
- process.exit(2);
14
- }
15
-
16
- const worldConfig = process.env.VOLTER_WORLD_CONFIG;
17
- const worldData = process.env.VOLTER_WORLD_DATA;
18
- if (!worldConfig || !worldData) {
19
- process.stderr.write('managed infrastructure: missing World lifecycle context\n');
20
- process.exit(2);
21
- }
22
- const definition = join(dirname(worldConfig), 'world.infrastructure.yml');
23
- if (!existsSync(definition)) {
24
- process.stderr.write('managed infrastructure: declared definition is missing\n');
25
- process.exit(2);
26
- }
27
-
28
- const base = ['compose', '-f', definition];
29
- const run = (args: string[]) => spawnSync('docker', [...base, ...args], { windowsHide: true,
30
- encoding: 'utf8',
31
- env: { ...process.env, VOLTER_WORLD_DATA: worldData },
32
- timeout: 120_000,
6
+ const code = await runInfrastructure(process.argv[2] ?? '', process.env, {
7
+ out: (text) => { process.stdout.write(text); },
8
+ err: (text) => { process.stderr.write(text); },
33
9
  });
34
- const classify = (raw: string): string => /no space left on device|enospc/iu.test(raw)
35
- ? 'insufficient writable storage'
36
- : /out of memory|cannot allocate memory|killed/iu.test(raw)
37
- ? 'insufficient memory'
38
- : /cannot connect|not running|daemon/iu.test(raw)
39
- ? 'local execution capacity is unavailable'
40
- : 'managed infrastructure operation failed';
41
- const fail = (result: ReturnType<typeof run>): never => {
42
- const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
43
- process.stderr.write(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
44
- process.exit(result.status && result.status > 0 ? result.status : 1);
45
- };
46
-
47
- // ---- backing selection ------------------------------------------------------
48
- // The declared service contract (up/status/down) is the boundary; WHICH
49
- // runtime answers it is private and chosen here, per machine, at each phase:
50
- // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
51
- // 2. a working container runtime — the compose path, byte-identical to before;
52
- // 3. no container runtime + a definition of postgres and/or mongodb services —
53
- // the containerless backing (pglite-backing.ts: PGlite for postgres, the
54
- // MongoDB twin for mongodb), announced loudly;
55
- // 4. otherwise the honest refusal naming what this machine cannot serve.
56
- function selectBacking(): 'docker' | 'pglite' {
57
- const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
58
- if (forced === 'docker' || forced === 'pglite') return forced;
59
- if (forced !== undefined) {
60
- process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
61
- process.exit(2);
62
- }
63
- const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { windowsHide: true, encoding: 'utf8', timeout: 10_000 });
64
- if (probe.status === 0) return 'docker';
65
- // Absence of a container runtime (no binary, no daemon) is a capability
66
- // difference: swap backings. A PRESENT runtime failing on resources stays
67
- // on the docker path so the real capacity problem is classified and
68
- // surfaced — a full disk starves every backing equally.
69
- if (probe.error !== undefined) return 'pglite';
70
- const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
71
- return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
72
- }
73
-
74
- async function runPglite(): Promise<never> {
75
- const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
76
- const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
77
- if (services.length === 0) {
78
- process.stderr.write('managed infrastructure: the declared definition names no services\n');
79
- process.exit(1);
80
- }
81
- const unsupported = unsupportedKinds(services);
82
- if (unsupported.length > 0) {
83
- process.stderr.write(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
84
- process.exit(1);
85
- }
86
- if (phase === 'up') {
87
- process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
88
- try {
89
- await pgliteUp(services, worldData!);
90
- } catch (error) {
91
- process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
92
- process.exit(1);
93
- }
94
- process.stdout.write('managed infrastructure ready\n');
95
- process.exit(0);
96
- }
97
- if (phase === 'status') {
98
- const status = await pgliteStatus(services, worldData!);
99
- if (!status.ok) {
100
- process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
101
- process.exit(1);
102
- }
103
- process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
104
- process.exit(0);
105
- }
106
- await pgliteDown(services, worldData!);
107
- process.stdout.write('managed infrastructure stopped\n');
108
- process.exit(0);
109
- }
110
-
111
- // ---- redis: the twin, containerless ----------------------------------------
112
- // A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
113
- // unless the operator forces the container backing; the other services take the backing chosen above. Where the
114
- // twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
115
- // the container where there is one, else the backing's refusal naming redis.
116
- const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
117
- const { redisTwinCli } = await import('./redis-backing.ts');
118
- const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
119
- const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
120
- /** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
121
- const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
122
- if (twinned.length > 0) await runRedisTwin();
123
-
124
- async function runRedisTwin(): Promise<void> {
125
- const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
126
- const cli = twinCli!;
127
- if (phase === 'up') {
128
- process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
129
- try {
130
- await redisTwinUp(twinned, worldData!, cli);
131
- } catch (error) {
132
- process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
133
- process.exit(1);
134
- }
135
- } else if (phase === 'status') {
136
- const ready = await redisTwinStatus(twinned, worldData!);
137
- if (ready !== twinned.length) {
138
- process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
139
- process.exit(1);
140
- }
141
- } else {
142
- await redisTwinDown(twinned, worldData!);
143
- }
144
- if (composed!.length === 0) {
145
- if (phase === 'up') process.stdout.write('managed infrastructure ready\n');
146
- else if (phase === 'status') process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
147
- else process.stdout.write('managed infrastructure stopped\n');
148
- process.exit(0);
149
- }
150
- }
151
-
152
- if (selectBacking() === 'pglite') {
153
- await runPglite();
154
- }
155
-
156
- const only = composed ?? [];
157
- if (phase === 'up') {
158
- const result = run(['up', '-d', '--wait', ...only]);
159
- if (result.status !== 0) fail(result);
160
- process.stdout.write('managed infrastructure ready\n');
161
- } else if (phase === 'status') {
162
- const expected = run(['config', '--services']);
163
- if (expected.status !== 0) fail(expected);
164
- const running = run(['ps', '--status', 'running', '--services']);
165
- if (running.status !== 0) fail(running);
166
- const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
167
- const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
168
- if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
169
- process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
170
- process.exit(1);
171
- }
172
- process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
173
- } else {
174
- const result = run(['down', '--remove-orphans']);
175
- if (result.status !== 0) fail(result);
176
- process.stdout.write('managed infrastructure stopped\n');
177
- }
10
+ process.exit(code);
package/src/infra.ts ADDED
@@ -0,0 +1,225 @@
1
+ // Private implementation for infrastructure emitted by `volter-world init`. World configs invoke
2
+ // this declared service; application agents never operate or diagnose its backing mechanism.
3
+ //
4
+ // One phase (up, status, down) per call, answering its exit code, with its output written through `io`. The
5
+ // `volter-world-infra` bin (infra-cli.ts) runs it as a process; the World's own `up` runs the up phase in its own
6
+ // process (runtime.ts, `ownInfrastructureUp`), so the containerless services it starts are children of the process
7
+ // that owns the World's lifetime, as every other service's recorder is, and not of a tool that exits once they
8
+ // serve. A process layer where a child does not outlive its parent (a browser tab's) otherwise ended the PGlite host
9
+ // the moment `volter-world-infra up` returned, and the World's readiness probe never saw it.
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
15
+
16
+ export interface InfrastructureIo { out: (text: string) => void; err: (text: string) => void }
17
+
18
+ interface Ran { status: number | null; stdout: string; stderr: string; error?: Error }
19
+
20
+ /** A command run to its end without blocking this process: in the World's own `up` the phase shares the event loop
21
+ * with the boot's signal handling, where the tool's spawnSync blocked only the tool. Ended by `timeout` (SIGTERM,
22
+ * as spawnSync's timeout) or by the boot's abort. */
23
+ function runCommand(command: string, args: string[], options: { env: NodeJS.ProcessEnv; timeout: number; signal?: AbortSignal }): Promise<Ran> {
24
+ return new Promise((resolveRan) => {
25
+ let stdout = ''; let stderr = ''; let settled = false;
26
+ let grace: ReturnType<typeof setTimeout> | undefined;
27
+ const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], ...(options.signal ? { signal: options.signal } : {}) });
28
+ const settle = (ran: Ran) => {
29
+ if (settled) return;
30
+ settled = true; clearTimeout(timer); if (grace) clearTimeout(grace);
31
+ // a grandchild that still holds the pipes does not hold this answer
32
+ child.stdout?.destroy(); child.stderr?.destroy();
33
+ resolveRan(ran);
34
+ };
35
+ // the timeout is a hard limit and answers as spawnSync's did: the command is killed and the result carries an
36
+ // ETIMEDOUT error (a hung `docker info`, a daemon that never answers, is then no container runtime, as before)
37
+ const timer = setTimeout(() => {
38
+ child.kill('SIGTERM');
39
+ settle({ status: null, stdout, stderr, error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) });
40
+ }, options.timeout);
41
+ child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
42
+ child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
43
+ // a spawn that fails (no such binary, an abort) answers as spawnSync did: no status, the error
44
+ child.on('error', (error) => settle({ status: null, stdout, stderr, error }));
45
+ // the output is whole at `close`; once the command has exited, a descendant holding its pipes open delays the
46
+ // answer by at most a moment, never to the timeout
47
+ child.on('exit', (code) => { grace = setTimeout(() => settle({ status: code, stdout, stderr }), 500); });
48
+ child.on('close', (code) => settle({ status: code, stdout, stderr }));
49
+ });
50
+ }
51
+
52
+ /** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
53
+ * services this starts run in; `signal` is the boot's abort (the World's own up passes it; the tool has none). */
54
+ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, io: InfrastructureIo, signal?: AbortSignal): Promise<number> {
55
+ if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
56
+ io.err('managed infrastructure: expected up, status, or down\n');
57
+ return 2;
58
+ }
59
+
60
+ const worldConfig = env.VOLTER_WORLD_CONFIG;
61
+ const worldData = env.VOLTER_WORLD_DATA;
62
+ if (!worldConfig || !worldData) {
63
+ io.err('managed infrastructure: missing World lifecycle context\n');
64
+ return 2;
65
+ }
66
+ const definition = join(dirname(worldConfig), 'world.infrastructure.yml');
67
+ if (!existsSync(definition)) {
68
+ io.err('managed infrastructure: declared definition is missing\n');
69
+ return 2;
70
+ }
71
+
72
+ const base = ['compose', '-f', definition];
73
+ const run = async (args: string[]): Promise<Ran> => {
74
+ const ran = await runCommand('docker', [...base, ...args], { env: { ...env, VOLTER_WORLD_DATA: worldData }, timeout: 120_000, ...(signal ? { signal } : {}) });
75
+ signal?.throwIfAborted();
76
+ return ran;
77
+ };
78
+ const launch = { env, ...(signal ? { signal } : {}) };
79
+ const classify = (raw: string): string => /no space left on device|enospc/iu.test(raw)
80
+ ? 'insufficient writable storage'
81
+ : /out of memory|cannot allocate memory|killed/iu.test(raw)
82
+ ? 'insufficient memory'
83
+ : /cannot connect|not running|daemon/iu.test(raw)
84
+ ? 'local execution capacity is unavailable'
85
+ : 'managed infrastructure operation failed';
86
+ const fail = (result: Ran): number => {
87
+ const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
88
+ io.err(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
89
+ return result.status && result.status > 0 ? result.status : 1;
90
+ };
91
+
92
+ // ---- backing selection ------------------------------------------------------
93
+ // The declared service contract (up/status/down) is the boundary; WHICH
94
+ // runtime answers it is private and chosen here, per machine, at each phase:
95
+ // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
96
+ // 2. a working container runtime — the compose path, byte-identical to before;
97
+ // 3. no container runtime + a definition of postgres and/or mongodb services —
98
+ // the containerless backing (pglite-backing.ts: PGlite for postgres, the
99
+ // MongoDB twin for mongodb), announced loudly;
100
+ // 4. otherwise the honest refusal naming what this machine cannot serve.
101
+ async function selectBacking(): Promise<'docker' | 'pglite' | number> {
102
+ const forced = env.VOLTER_WORLD_INFRA_BACKING;
103
+ if (forced === 'docker' || forced === 'pglite') return forced;
104
+ if (forced !== undefined) {
105
+ io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
106
+ return 2;
107
+ }
108
+ const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
109
+ signal?.throwIfAborted();
110
+ if (probe.status === 0) return 'docker';
111
+ // Absence of a container runtime (no binary, no daemon) is a capability
112
+ // difference: swap backings. A PRESENT runtime failing on resources stays
113
+ // on the docker path so the real capacity problem is classified and
114
+ // surfaced — a full disk starves every backing equally.
115
+ // A shell's "command not found" (127) is the same absence: a process layer that runs commands by name answers
116
+ // an unknown one so, where spawnSync answered ENOSYS (a browser tab's).
117
+ if (probe.error !== undefined || probe.status === 127) return 'pglite';
118
+ const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
119
+ return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
120
+ }
121
+
122
+ async function runPglite(): Promise<number> {
123
+ const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
124
+ const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
125
+ if (services.length === 0) {
126
+ io.err('managed infrastructure: the declared definition names no services\n');
127
+ return 1;
128
+ }
129
+ const unsupported = unsupportedKinds(services);
130
+ if (unsupported.length > 0) {
131
+ io.err(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
132
+ return 1;
133
+ }
134
+ if (phase === 'up') {
135
+ io.out('managed infrastructure backing: containerless (no container runtime)\n');
136
+ try {
137
+ await pgliteUp(services, worldData!, launch);
138
+ } catch (error) {
139
+ io.err(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
140
+ return 1;
141
+ }
142
+ io.out('managed infrastructure ready\n');
143
+ return 0;
144
+ }
145
+ if (phase === 'status') {
146
+ const status = await pgliteStatus(services, worldData!);
147
+ if (!status.ok) {
148
+ io.err(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
149
+ return 1;
150
+ }
151
+ io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
152
+ return 0;
153
+ }
154
+ await pgliteDown(services, worldData!);
155
+ io.out('managed infrastructure stopped\n');
156
+ return 0;
157
+ }
158
+
159
+ // ---- redis: the twin, containerless ----------------------------------------
160
+ // A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
161
+ // unless the operator forces the container backing; the other services take the backing chosen above. Where the
162
+ // twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
163
+ // the container where there is one, else the backing's refusal naming redis.
164
+ const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
165
+ const { redisTwinCli } = await import('./redis-backing.ts');
166
+ const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
167
+ const twinned = env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
168
+ /** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
169
+ const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
170
+ if (twinned.length > 0) {
171
+ const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
172
+ const cli = twinCli!;
173
+ if (phase === 'up') {
174
+ io.out('managed infrastructure backing: redis twin (containerless)\n');
175
+ try {
176
+ await redisTwinUp(twinned, worldData!, cli, launch);
177
+ } catch (error) {
178
+ io.err(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
179
+ return 1;
180
+ }
181
+ } else if (phase === 'status') {
182
+ const ready = await redisTwinStatus(twinned, worldData!);
183
+ if (ready !== twinned.length) {
184
+ io.err(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
185
+ return 1;
186
+ }
187
+ } else {
188
+ await redisTwinDown(twinned, worldData!);
189
+ }
190
+ if (composed!.length === 0) {
191
+ if (phase === 'up') io.out('managed infrastructure ready\n');
192
+ else if (phase === 'status') io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
193
+ else io.out('managed infrastructure stopped\n');
194
+ return 0;
195
+ }
196
+ }
197
+
198
+ const backing = await selectBacking();
199
+ if (typeof backing === 'number') return backing;
200
+ if (backing === 'pglite') return runPglite();
201
+
202
+ const only = composed ?? [];
203
+ if (phase === 'up') {
204
+ const result = await run(['up', '-d', '--wait', ...only]);
205
+ if (result.status !== 0) return fail(result);
206
+ io.out('managed infrastructure ready\n');
207
+ } else if (phase === 'status') {
208
+ const expected = await run(['config', '--services']);
209
+ if (expected.status !== 0) return fail(expected);
210
+ const running = await run(['ps', '--status', 'running', '--services']);
211
+ if (running.status !== 0) return fail(running);
212
+ const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
213
+ const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
214
+ if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
215
+ io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
216
+ return 1;
217
+ }
218
+ io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, env) })}\n`);
219
+ } else {
220
+ const result = await run(['down', '--remove-orphans']);
221
+ if (result.status !== 0) return fail(result);
222
+ io.out('managed infrastructure stopped\n');
223
+ }
224
+ return 0;
225
+ }
@@ -97,11 +97,19 @@ export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSy
97
97
  throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
98
98
  }
99
99
 
100
+ /** How a backing starts its hosts: the environment they run in (the World's service environment, which
101
+ * the runtime computes for the infrastructure service: admitted host env, the World's env, the service's own, less
102
+ * the control plane's NODE_OPTIONS and proxies), and the boot's abort. The `volter-world-infra` process passes
103
+ * nothing and its hosts inherit its own environment, which is that same service environment. The World's `up`,
104
+ * running the up phase in its own process, passes it: its own process.env is the operator's, not the service's. */
105
+ export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal }
106
+
100
107
  /** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
101
- function hostArgv(service: InfraService, serviceData: string): string[] {
108
+ function hostArgv(service: InfraService, serviceData: string, env: NodeJS.ProcessEnv): string[] {
102
109
  if (service.kind === 'mongodb') {
103
- const cli = mongodbTwinCli(process.env.VOLTER_WORLD_CONFIG);
104
- return [twinRunner(cli), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
110
+ const cli = mongodbTwinCli(env.VOLTER_WORLD_CONFIG);
111
+ // blocking (spawnSync), as it was in the tool: reached only for a checkout's TypeScript cli under Node
112
+ return [twinRunner(cli, () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true, env }).status === 0), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
105
113
  }
106
114
  return ['node', hostScript(), '--port', String(service.hostPort), '--data', serviceData];
107
115
  }
@@ -138,7 +146,8 @@ function logSince(log: string, offset: number): string {
138
146
  try { return readFileSync(log).subarray(offset).toString('utf8'); } catch { return ''; }
139
147
  }
140
148
 
141
- export async function pgliteUp(services: InfraService[], dataDir: string): Promise<void> {
149
+ export async function pgliteUp(services: InfraService[], dataDir: string, launch: HostLaunch = {}): Promise<void> {
150
+ const env = launch.env ?? process.env;
142
151
  // Every check before any start: a refused up starts nothing, so it never leaves the World half-booted.
143
152
  const plan: Array<{ service: InfraService; name: string; serviceData: string; log: string; pidFile: string; argv: string[] }> = [];
144
153
  for (const service of services) {
@@ -150,7 +159,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
150
159
  if (await listening(service.hostPort)) {
151
160
  throw new Error(`${name} cannot serve port ${service.hostPort}: another process is already listening on it`);
152
161
  }
153
- plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
162
+ plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData, env) });
154
163
  }
155
164
  const started: Array<{ pid: number; pidFile: string }> = [];
156
165
  try {
@@ -163,6 +172,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
163
172
  const logFd = openSync(log, 'a');
164
173
  const child = spawn(command!, args, {
165
174
  windowsHide: true,
175
+ env,
166
176
  detached: true,
167
177
  stdio: ['ignore', logFd, logFd],
168
178
  });
@@ -177,6 +187,8 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
177
187
  while (!(alive(child.pid!) && logSince(log, offset).includes(line) && await listening(service.hostPort))) {
178
188
  if (!alive(child.pid!)) throw new Error(`${name} exited before serving — see ${log}`);
179
189
  if (Date.now() > deadline) throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
190
+ // an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
191
+ launch.signal?.throwIfAborted();
180
192
  await sleep(200);
181
193
  }
182
194
  }
@@ -13,7 +13,7 @@ import { connect } from 'node:net';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { spawn } from 'node:child_process';
15
15
  import { findInstalledPackage, packCli } from './catalog.ts';
16
- import type { InfraService } from './pglite-backing.ts';
16
+ import type { HostLaunch, InfraService } from './pglite-backing.ts';
17
17
 
18
18
  const PACKAGE = '@volter/twin-redis';
19
19
  const READY_TIMEOUT_MS = 60_000;
@@ -49,7 +49,7 @@ function listening(port: number): Promise<boolean> {
49
49
 
50
50
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
51
51
 
52
- export async function redisTwinUp(services: InfraService[], dataDir: string, cli: string): Promise<void> {
52
+ export async function redisTwinUp(services: InfraService[], dataDir: string, cli: string, launch: HostLaunch = {}): Promise<void> {
53
53
  for (const service of services) {
54
54
  const pidFile = pidPath(dataDir, service);
55
55
  if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort)) continue;
@@ -66,6 +66,7 @@ export async function redisTwinUp(services: InfraService[], dataDir: string, cli
66
66
  // type transformation, not only stripping (the kernel declares constructor parameter properties)
67
67
  const runtime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
68
68
  const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
69
+ env: launch.env ?? process.env,
69
70
  detached: true,
70
71
  stdio: ['ignore', logFd, logFd],
71
72
  });
@@ -75,6 +76,7 @@ export async function redisTwinUp(services: InfraService[], dataDir: string, cli
75
76
  const deadline = Date.now() + READY_TIMEOUT_MS;
76
77
  while (!(await listening(service.hostPort)) || !alive(child.pid!)) {
77
78
  if (!alive(child.pid!)) throw new Error(`redis exited before serving — see ${log}`);
79
+ launch.signal?.throwIfAborted();
78
80
  if (Date.now() > deadline) throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
79
81
  await sleep(200);
80
82
  }