@volter/twin-world 0.1.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/package.json +61 -0
- package/src/browser-proxy-cli.ts +43 -0
- package/src/cli.ts +226 -0
- package/src/configs.ts +24 -0
- package/src/host-cli.ts +86 -0
- package/src/host-worker.ts +22 -0
- package/src/host.ts +142 -0
- package/src/index.ts +42 -0
- package/src/prerequisites.ts +109 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +437 -0
- package/src/runtime.ts +1888 -0
- package/src/schema.ts +471 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,1888 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync, writeSync, existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { Socket } from 'node:net';
|
|
7
|
+
import { withFileLock } from '@volter/twin';
|
|
8
|
+
import { loadWorldConfig } from './configs.ts';
|
|
9
|
+
import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
|
|
10
|
+
import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
|
|
11
|
+
|
|
12
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
export type UpWorldOptions = {
|
|
15
|
+
name?: string;
|
|
16
|
+
root?: string;
|
|
17
|
+
mode?: WorldMode;
|
|
18
|
+
/** Overrides the config's `isolation` for this boot (e.g. force 'process' for a share world). */
|
|
19
|
+
isolation?: WorldIsolation;
|
|
20
|
+
envFile?: string;
|
|
21
|
+
share?: Omit<ShareWorldOptions, 'root' | 'service' | 'verifyPath'> & { verifyPath?: string | false };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ShareWorldOptions = {
|
|
25
|
+
root?: string;
|
|
26
|
+
service?: string;
|
|
27
|
+
provider?: 'cloudflare-quick' | 'command';
|
|
28
|
+
command?: string;
|
|
29
|
+
args?: string[];
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
verifyPath?: string | false;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ShareWorldServicesOptions = Omit<ShareWorldOptions, 'service' | 'verifyPath'> & {
|
|
35
|
+
service?: string;
|
|
36
|
+
verifyPath?: string | false;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type WorldDoctorCheck = {
|
|
40
|
+
id: string;
|
|
41
|
+
ok: boolean;
|
|
42
|
+
message: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type WorldDoctorReport = {
|
|
46
|
+
name: string;
|
|
47
|
+
ok: boolean;
|
|
48
|
+
checks: WorldDoctorCheck[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RunWorldOptions = UpWorldOptions & {
|
|
52
|
+
keep?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type WorldUrlInfo = {
|
|
56
|
+
name: string;
|
|
57
|
+
mode: WorldMode;
|
|
58
|
+
services: Record<string, {
|
|
59
|
+
localUrl: string;
|
|
60
|
+
publicUrl?: string;
|
|
61
|
+
publicReady?: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function worldBaseDir(root: string): string {
|
|
66
|
+
return join(root, '.volter', 'worlds');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** World names become a path segment (instanceDir/instanceLockFile) and must never let a
|
|
70
|
+
* caller escape `worldBaseDir` (no `/`, and the leading-alnum requirement rules out a bare
|
|
71
|
+
* `..`). Shared by upWorld's boot-time check and downWorld's `--purge` (the one path that
|
|
72
|
+
* recursively `rmSync`s a name-derived directory, so it is the one that most needs this to
|
|
73
|
+
* hold). */
|
|
74
|
+
function assertSafeWorldName(name: string): void {
|
|
75
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`Invalid world name: ${name}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function instanceDir(root: string, name: string): string {
|
|
79
|
+
return join(worldBaseDir(root), name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function instanceFile(root: string, name: string): string {
|
|
83
|
+
return join(instanceDir(root, name), 'instance.json');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Lockfile guarding the synchronous claim-of-the-instance-dir section of `upWorld` (TWIN-36).
|
|
87
|
+
* It must survive the claim's own `rmSync` of the instance dir, so it lives BESIDE the dirs in
|
|
88
|
+
* a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
|
|
89
|
+
* collide with (or be wiped as) a world's instance dir. */
|
|
90
|
+
function instanceLockFile(root: string, name: string): string {
|
|
91
|
+
return join(worldBaseDir(root), '.locks', `${name}.lock`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Claim marker a winning `upWorld` writes into the freshly-wiped instance dir — under the
|
|
95
|
+
* instance lock, before releasing it — and removes once its boot attempt settles. It is what
|
|
96
|
+
* keeps later claimants out for the whole (async, possibly long) boot: mid-boot there are no
|
|
97
|
+
* recorded pids yet, so without it a second `upWorld` would see "nothing running" and rmSync
|
|
98
|
+
* the winner's dir out from under its freshly-spawned services. */
|
|
99
|
+
function bootingFile(root: string, name: string): string {
|
|
100
|
+
return join(instanceDir(root, name), 'booting.json');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type BootingClaim = { pid: number; at: string };
|
|
104
|
+
|
|
105
|
+
/** The live booting claim for a world, or null when there is none to honor. Only ever read
|
|
106
|
+
* under the instance lock, and the marker is written under that same lock — so a torn read is
|
|
107
|
+
* impossible; a malformed marker is a corrupt leftover and treated as stale. A claim whose
|
|
108
|
+
* recorded booter pid is dead is stale too (crashed mid-boot): the next claimant reclaims the
|
|
109
|
+
* dir, exactly like livePids() lets a fully-dead world be re-upped. */
|
|
110
|
+
function liveBootingClaim(path: string): BootingClaim | null {
|
|
111
|
+
if (!existsSync(path)) return null;
|
|
112
|
+
let claim: BootingClaim;
|
|
113
|
+
try {
|
|
114
|
+
claim = JSON.parse(readFileSync(path, 'utf8')) as BootingClaim;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (!Number.isInteger(claim.pid) || claim.pid <= 0) return null;
|
|
119
|
+
return livePids([claim.pid]).length > 0 ? claim : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Where the session-scoped CA + on-the-fly leaf certs live for a world (under the instance dir, so
|
|
123
|
+
* `down`'s teardown removes them — the CA never outlives the world). */
|
|
124
|
+
function tlsDir(root: string, name: string): string {
|
|
125
|
+
return join(instanceDir(root, name), 'tls');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** State of a detached ambient-redirect proxy daemon (so `activate` can export its env and `down`
|
|
129
|
+
* can stop it). Written by the daemon, read by activate/down. */
|
|
130
|
+
function proxyStateFile(root: string, name: string): string {
|
|
131
|
+
return join(instanceDir(root, name), 'proxy.json');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function proxyEnvFile(root: string, name: string): string {
|
|
135
|
+
return join(instanceDir(root, name), 'proxy-env.json');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ProxyState = { pid: number; url: string; caCertPath: string };
|
|
139
|
+
|
|
140
|
+
function readProxyState(root: string, name: string): ProxyState | null {
|
|
141
|
+
const file = proxyStateFile(root, name);
|
|
142
|
+
if (!existsSync(file)) return null;
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(readFileSync(file, 'utf8')) as ProxyState;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shellQuote(value: string): string {
|
|
151
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function envFileContents(env: Record<string, string>): string {
|
|
155
|
+
return `${Object.entries(env)
|
|
156
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
157
|
+
.map(([key, value]) => `export ${key}=${shellQuote(value)}`)
|
|
158
|
+
.join('\n')}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function injectPreloadSpecifier(): string {
|
|
162
|
+
return requireFromHere.resolve('@volter/twin/inject');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeProxyEnv(root: string, name: string, env: Record<string, string>): void {
|
|
166
|
+
writeFileSync(proxyEnvFile(root, name), `${JSON.stringify(env, null, 2)}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readProxyEnv(path: string): Record<string, string> {
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, string>;
|
|
172
|
+
} catch {
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Resolve a service's cliRedirect map (env var → template), substituting the literal `${url}`
|
|
178
|
+
* with the service's resolved URL. These flow into the world env so the vendor's real CLI lands
|
|
179
|
+
* in the twin. See docs/WORLD_ACTIVATE.md. */
|
|
180
|
+
function resolveCliRedirect(cliRedirect: Record<string, string> | undefined, url: string): Record<string, string> {
|
|
181
|
+
if (!cliRedirect) return {};
|
|
182
|
+
const out: Record<string, string> = {};
|
|
183
|
+
for (const [key, template] of Object.entries(cliRedirect)) out[key] = template.split('${url}').join(url);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveServiceEnvTemplates(
|
|
188
|
+
templates: Record<string, string> | undefined,
|
|
189
|
+
service: { host: string; port: number; url: string },
|
|
190
|
+
): Record<string, string> {
|
|
191
|
+
if (!templates) return {};
|
|
192
|
+
const out: Record<string, string> = {};
|
|
193
|
+
for (const [key, template] of Object.entries(templates)) {
|
|
194
|
+
out[key] = template
|
|
195
|
+
.split('${url}').join(service.url)
|
|
196
|
+
.split('${httpUrl}').join(service.url)
|
|
197
|
+
.split('${host}').join(service.host)
|
|
198
|
+
.split('${port}').join(String(service.port));
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const CONTROL_PLANE_EGRESS_ENV = [
|
|
204
|
+
'NODE_OPTIONS',
|
|
205
|
+
'HTTP_PROXY',
|
|
206
|
+
'HTTPS_PROXY',
|
|
207
|
+
'ALL_PROXY',
|
|
208
|
+
'http_proxy',
|
|
209
|
+
'https_proxy',
|
|
210
|
+
'all_proxy',
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
function serviceProcessEnv(
|
|
214
|
+
config: WorldConfig,
|
|
215
|
+
service: WorldServiceConfig,
|
|
216
|
+
worldEnv: Record<string, string>,
|
|
217
|
+
port: number,
|
|
218
|
+
): Record<string, string> {
|
|
219
|
+
const env: Record<string, string> = {
|
|
220
|
+
...process.env,
|
|
221
|
+
...(config.env ?? {}),
|
|
222
|
+
...worldEnv,
|
|
223
|
+
PORT: String(port),
|
|
224
|
+
};
|
|
225
|
+
env.NO_PROXY = env.NO_PROXY ? `127.0.0.1,localhost,${env.NO_PROXY}` : '127.0.0.1,localhost';
|
|
226
|
+
env.no_proxy = env.no_proxy ? `127.0.0.1,localhost,${env.no_proxy}` : '127.0.0.1,localhost';
|
|
227
|
+
if (service.controlPlane) {
|
|
228
|
+
for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
229
|
+
}
|
|
230
|
+
return { ...env, ...(service.env ?? {}) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function allocatePort(): Promise<number> {
|
|
234
|
+
return await new Promise((resolvePort, reject) => {
|
|
235
|
+
const server = createServer();
|
|
236
|
+
server.listen(0, '127.0.0.1', () => {
|
|
237
|
+
const address = server.address();
|
|
238
|
+
if (!address || typeof address === 'string') {
|
|
239
|
+
server.close(() => reject(new Error('Could not allocate a TCP port')));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const port = address.port;
|
|
243
|
+
server.close(() => resolvePort(port));
|
|
244
|
+
});
|
|
245
|
+
server.on('error', reject);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function waitForTcp(port: number, timeoutMs = 15_000): Promise<void> {
|
|
250
|
+
const started = Date.now();
|
|
251
|
+
while (Date.now() - started < timeoutMs) {
|
|
252
|
+
const ok = await new Promise<boolean>((resolveOk) => {
|
|
253
|
+
const socket = new Socket();
|
|
254
|
+
socket.setTimeout(500);
|
|
255
|
+
socket.once('connect', () => {
|
|
256
|
+
socket.destroy();
|
|
257
|
+
resolveOk(true);
|
|
258
|
+
});
|
|
259
|
+
socket.once('timeout', () => {
|
|
260
|
+
socket.destroy();
|
|
261
|
+
resolveOk(false);
|
|
262
|
+
});
|
|
263
|
+
socket.once('error', () => resolveOk(false));
|
|
264
|
+
socket.connect(port, '127.0.0.1');
|
|
265
|
+
});
|
|
266
|
+
if (ok) return;
|
|
267
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
|
|
268
|
+
}
|
|
269
|
+
throw new Error(`Service on port ${port} did not become reachable`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readPids(path: string): number[] {
|
|
273
|
+
if (!existsSync(path)) return [];
|
|
274
|
+
return readFileSync(path, 'utf8')
|
|
275
|
+
.split(/\r?\n/)
|
|
276
|
+
.map((line) => Number(line.trim()))
|
|
277
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function livePids(pids: number[]): number[] {
|
|
281
|
+
return pids.filter((pid) => {
|
|
282
|
+
try {
|
|
283
|
+
process.kill(pid, 0);
|
|
284
|
+
return true;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function tunnelPids(instance: WorldInstance): number[] {
|
|
292
|
+
return Object.values(instance.services)
|
|
293
|
+
.map((service) => service.tunnel?.pid)
|
|
294
|
+
.filter((pid): pid is number => Boolean(pid));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function writePidsFromInstance(instance: WorldInstance): void {
|
|
298
|
+
// Deduped: co-located services share the single host child's pid.
|
|
299
|
+
const pids = [...new Set([
|
|
300
|
+
...Object.values(instance.services).map((service) => service.pid),
|
|
301
|
+
...tunnelPids(instance),
|
|
302
|
+
].filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
303
|
+
writeFileSync(instance.pidsFile, `${pids.join('\n')}\n`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function saveWorldInstance(instance: WorldInstance): void {
|
|
307
|
+
writeFileSync(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The co-located host CHILD process cannot safely rewrite instance.json out from under the
|
|
311
|
+
* parent (which may still be mid-boot, or rewrite it later via share/unshare) — so a worker
|
|
312
|
+
* give-up (host.ts fires it on the first crash — never restarted) is recorded in a sidecar
|
|
313
|
+
* file next to instance.json instead
|
|
314
|
+
* (see host-cli.ts). Folding it in here means every reader (doctor, status, tests) sees the
|
|
315
|
+
* record on `services[<id>].workerGaveUp` exactly as if it had been written into instance.json. */
|
|
316
|
+
function mergeWorkerGaveUp(instance: WorldInstance, instancePath: string): void {
|
|
317
|
+
const sidecar = join(dirname(instancePath), 'host-gaveup.json');
|
|
318
|
+
if (!existsSync(sidecar)) return;
|
|
319
|
+
try {
|
|
320
|
+
const gaveUp = JSON.parse(readFileSync(sidecar, 'utf8')) as Record<string, { at: string; exits: number; detail: string }>;
|
|
321
|
+
for (const [id, record] of Object.entries(gaveUp)) {
|
|
322
|
+
const service = instance.services[id];
|
|
323
|
+
if (service) service.workerGaveUp = record;
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
// Best-effort merge: a sidecar torn by a concurrent host-cli write must never break an
|
|
327
|
+
// instance.json read (the give-up record just fails to surface for this one read).
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readWorldInstance(name: string, root: string): WorldInstance {
|
|
332
|
+
const path = instanceFile(root, name);
|
|
333
|
+
if (!existsSync(path)) throw new Error(`World instance not found: ${name}`);
|
|
334
|
+
const instance = JSON.parse(readFileSync(path, 'utf8')) as WorldInstance;
|
|
335
|
+
mergeWorkerGaveUp(instance, path);
|
|
336
|
+
return instance;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function assertMode(mode: string): asserts mode is WorldMode {
|
|
340
|
+
if (mode !== 'local' && mode !== 'share' && mode !== 'sealed') throw new Error(`Invalid world mode: ${mode}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function getByJsonPath(value: unknown, jsonPath: string): unknown {
|
|
344
|
+
let current: unknown = value;
|
|
345
|
+
for (const rawSegment of jsonPath.split('.')) {
|
|
346
|
+
const segment = rawSegment.trim();
|
|
347
|
+
if (segment === '') continue;
|
|
348
|
+
if (current === null || current === undefined) return undefined;
|
|
349
|
+
if (Array.isArray(current)) {
|
|
350
|
+
const index = Number(segment);
|
|
351
|
+
if (!Number.isInteger(index)) return undefined;
|
|
352
|
+
current = current[index];
|
|
353
|
+
} else if (typeof current === 'object') {
|
|
354
|
+
current = (current as Record<string, unknown>)[segment];
|
|
355
|
+
} else {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return current;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function runExternalCommand(
|
|
363
|
+
command: string[],
|
|
364
|
+
cwd: string,
|
|
365
|
+
serviceId: string,
|
|
366
|
+
phase: string,
|
|
367
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
368
|
+
): { stdout: string; stderr: string } {
|
|
369
|
+
const [bin, ...args] = command;
|
|
370
|
+
if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
|
|
371
|
+
if (!commandExists(bin)) {
|
|
372
|
+
throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
|
|
373
|
+
}
|
|
374
|
+
// 64MB ceiling (vs Node's 1MB default) so a chatty status/down doesn't ENOBUFS. The `up` phase
|
|
375
|
+
// uses runExternalUp (streamed, uncapped) since it's the one that restores DBs / boots stacks.
|
|
376
|
+
const result = spawnSync(bin, args, { cwd, encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
|
|
377
|
+
if (result.error) {
|
|
378
|
+
throw new Error(`External service "${serviceId}": ${phase} command failed to run (${result.error.message})`);
|
|
379
|
+
}
|
|
380
|
+
if (result.status !== 0) {
|
|
381
|
+
const detail = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
382
|
+
throw new Error(`External service "${serviceId}": ${phase} command exited ${result.status ?? 'null'}\n${detail}`);
|
|
383
|
+
}
|
|
384
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Run an external service's `up` command, STREAMING its stdout+stderr to the service log as it runs
|
|
388
|
+
* (instead of buffering through spawnSync's 1MB cap, which a large DB restore would overflow). Returns
|
|
389
|
+
* the combined output so `discover` can read it. Throws with a bounded tail on nonzero exit. */
|
|
390
|
+
async function runExternalUp(
|
|
391
|
+
command: string[],
|
|
392
|
+
cwd: string,
|
|
393
|
+
serviceId: string,
|
|
394
|
+
env: NodeJS.ProcessEnv,
|
|
395
|
+
logPath: string,
|
|
396
|
+
): Promise<string> {
|
|
397
|
+
const [bin, ...args] = command;
|
|
398
|
+
if (!bin) throw new Error(`External service "${serviceId}": up command is empty`);
|
|
399
|
+
if (!commandExists(bin)) {
|
|
400
|
+
throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the up command's tool is not installed)`);
|
|
401
|
+
}
|
|
402
|
+
writeFileSync(logPath, `$ ${command.join(' ')}\n`);
|
|
403
|
+
const out = openSync(logPath, 'a');
|
|
404
|
+
const chunks: string[] = [];
|
|
405
|
+
try {
|
|
406
|
+
const code = await new Promise<number | null>((resolveCode, reject) => {
|
|
407
|
+
const child = spawn(bin, args, { cwd, env });
|
|
408
|
+
child.on('error', reject);
|
|
409
|
+
const onData = (buf: Buffer) => {
|
|
410
|
+
const text = buf.toString('utf8');
|
|
411
|
+
chunks.push(text);
|
|
412
|
+
try { writeSync(out, text); } catch { /* best effort log */ }
|
|
413
|
+
};
|
|
414
|
+
child.stdout?.on('data', onData);
|
|
415
|
+
child.stderr?.on('data', onData);
|
|
416
|
+
child.on('close', (exitCode) => resolveCode(exitCode));
|
|
417
|
+
});
|
|
418
|
+
if (code !== 0) {
|
|
419
|
+
const detail = chunks.join('').trim().split('\n').slice(-40).join('\n');
|
|
420
|
+
throw new Error(`External service "${serviceId}": up command exited ${code ?? 'null'}\n${detail}`);
|
|
421
|
+
}
|
|
422
|
+
} finally {
|
|
423
|
+
closeSync(out);
|
|
424
|
+
}
|
|
425
|
+
return chunks.join('');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
|
|
429
|
+
* service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
|
|
430
|
+
* literal in the config). Other probe kinds (command/stdoutMatch) are returned unchanged. */
|
|
431
|
+
function resolveReadyProbe(ready: WorldExternalReadyWhen, svc: { host: string; port: number; url: string }): WorldExternalReadyWhen {
|
|
432
|
+
if (ready.httpUrl === undefined) return ready;
|
|
433
|
+
const httpUrl = ready.httpUrl
|
|
434
|
+
.split('${url}').join(svc.url)
|
|
435
|
+
.split('${host}').join(svc.host)
|
|
436
|
+
.split('${port}').join(String(svc.port));
|
|
437
|
+
return { ...ready, httpUrl };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function awaitReadiness(
|
|
441
|
+
ready: WorldExternalReadyWhen | undefined,
|
|
442
|
+
logPath: string,
|
|
443
|
+
cwd: string,
|
|
444
|
+
serviceId: string,
|
|
445
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
if (!ready) return;
|
|
448
|
+
const timeoutMs = ready.timeoutMs ?? 60_000;
|
|
449
|
+
const intervalMs = ready.intervalMs ?? 500;
|
|
450
|
+
const started = Date.now();
|
|
451
|
+
let lastError = '';
|
|
452
|
+
while (Date.now() - started < timeoutMs) {
|
|
453
|
+
if (ready.command !== undefined) {
|
|
454
|
+
if (!commandExists(ready.command)) {
|
|
455
|
+
throw new Error(`Service "${serviceId}": readiness command \`${ready.command}\` not found on PATH`);
|
|
456
|
+
}
|
|
457
|
+
const result = spawnSync(ready.command, ready.args ?? [], { cwd, encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
|
|
458
|
+
if (result.status === 0) return;
|
|
459
|
+
lastError = `command exited ${result.status ?? 'null'}: ${(result.stderr ?? '').trim()}`;
|
|
460
|
+
} else if (ready.httpUrl !== undefined) {
|
|
461
|
+
try {
|
|
462
|
+
const response = await fetch(ready.httpUrl);
|
|
463
|
+
if (response.status >= 200 && response.status < 400) return;
|
|
464
|
+
lastError = `HTTP ${response.status}`;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
467
|
+
}
|
|
468
|
+
} else if (ready.stdoutMatch !== undefined) {
|
|
469
|
+
const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
|
|
470
|
+
if (new RegExp(ready.stdoutMatch).test(text)) return;
|
|
471
|
+
lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
|
|
472
|
+
}
|
|
473
|
+
await sleep(intervalMs);
|
|
474
|
+
}
|
|
475
|
+
throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function startExternalService(
|
|
479
|
+
service: WorldServiceConfig,
|
|
480
|
+
paths: { root: string; logs: string },
|
|
481
|
+
worldEnv: Record<string, string>,
|
|
482
|
+
): Promise<WorldServiceInstance> {
|
|
483
|
+
const external = service.external;
|
|
484
|
+
if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
|
|
485
|
+
const log = join(paths.logs, `${service.id}.log`);
|
|
486
|
+
// External commands see the accumulated world env (process.env + earlier services' discovered/
|
|
487
|
+
// injected vars), so a later external can consume an earlier service's connection info.
|
|
488
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ...worldEnv };
|
|
489
|
+
|
|
490
|
+
// 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
|
|
491
|
+
const upOutput = await runExternalUp(external.up, paths.root, service.id, env, log);
|
|
492
|
+
|
|
493
|
+
// 2. readiness — Docker-backed externals take seconds to boot.
|
|
494
|
+
await awaitReadiness(external.readyWhen, log, paths.root, service.id, env);
|
|
495
|
+
|
|
496
|
+
// 3. status — discover connection info (optional; may reuse up output instead).
|
|
497
|
+
let statusOutput = '';
|
|
498
|
+
if (external.status) {
|
|
499
|
+
const statusResult = runExternalCommand(external.status, paths.root, service.id, 'status', env);
|
|
500
|
+
statusOutput = statusResult.stdout || statusResult.stderr;
|
|
501
|
+
appendFileSync(log, `\n$ ${external.status.join(' ')}\n${statusOutput}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// 4. discover — apply declarative mappings → injected env vars.
|
|
505
|
+
const injected: Record<string, string> = {};
|
|
506
|
+
const sources: Record<'status' | 'up', string> = {
|
|
507
|
+
status: statusOutput,
|
|
508
|
+
up: upOutput,
|
|
509
|
+
};
|
|
510
|
+
const jsonCache: Partial<Record<'status' | 'up', unknown>> = {};
|
|
511
|
+
for (const mapping of external.discover ?? []) {
|
|
512
|
+
const source = mapping.source ?? 'status';
|
|
513
|
+
const raw = sources[source];
|
|
514
|
+
let value: string | undefined;
|
|
515
|
+
if (mapping.jsonPath !== undefined) {
|
|
516
|
+
if (!(source in jsonCache)) {
|
|
517
|
+
try {
|
|
518
|
+
jsonCache[source] = JSON.parse(raw) as unknown;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${error instanceof Error ? error.message : String(error)})\nOutput: ${raw.slice(0, 500)}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const found = getByJsonPath(jsonCache[source], mapping.jsonPath);
|
|
524
|
+
value = found === undefined || found === null ? undefined : String(found);
|
|
525
|
+
} else if (mapping.pattern !== undefined) {
|
|
526
|
+
const match = new RegExp(mapping.pattern).exec(raw);
|
|
527
|
+
value = match?.groups?.value ?? match?.[1] ?? match?.[0];
|
|
528
|
+
}
|
|
529
|
+
if (value === undefined || value === '') {
|
|
530
|
+
throw new Error(`External service "${service.id}": discover "${mapping.as}" found ${value === '' ? 'an empty' : 'no'} value (jsonPath=${mapping.jsonPath ?? '-'} pattern=${mapping.pattern ?? '-'} source=${source})\nOutput: ${raw.slice(0, 500)}`);
|
|
531
|
+
}
|
|
532
|
+
injected[mapping.as] = value;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
id: service.id,
|
|
537
|
+
type: 'external',
|
|
538
|
+
command: [...external.up],
|
|
539
|
+
pid: 0,
|
|
540
|
+
log,
|
|
541
|
+
env: injected,
|
|
542
|
+
// Keep the discovered env on the instance so `down` can reference connection info it needs.
|
|
543
|
+
external: { down: [...external.down], cwd: paths.root, discoveredEnv: injected },
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function startService(
|
|
548
|
+
config: WorldConfig,
|
|
549
|
+
service: WorldServiceConfig,
|
|
550
|
+
worldEnv: Record<string, string>,
|
|
551
|
+
paths: { root: string; instance: string; logs: string; data: string },
|
|
552
|
+
): Promise<WorldServiceInstance> {
|
|
553
|
+
if (service.type === 'external') return startExternalService(service, paths, worldEnv);
|
|
554
|
+
if (!service.command) throw new Error(`Service "${service.id}" must define command`);
|
|
555
|
+
const port = service.port === undefined || service.port === 'auto' ? await allocatePort() : service.port;
|
|
556
|
+
const serviceDataDir = join(paths.data, service.id);
|
|
557
|
+
mkdirSync(serviceDataDir, { recursive: true });
|
|
558
|
+
|
|
559
|
+
const args = [...(service.args ?? [])];
|
|
560
|
+
if (service.portArg !== false) args.push(service.portArg ?? '--port', String(port));
|
|
561
|
+
if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
562
|
+
|
|
563
|
+
const log = join(paths.logs, `${service.id}.log`);
|
|
564
|
+
const out = openSync(log, 'a');
|
|
565
|
+
const env = serviceProcessEnv(config, service, worldEnv, port);
|
|
566
|
+
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
567
|
+
|
|
568
|
+
// Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
|
|
569
|
+
// (e.g. a sandbox) WITHOUT replacing the world's injector. Paths resolve to absolute against the
|
|
570
|
+
// service cwd — Node's `--require` treats a bare relative specifier as a package name otherwise.
|
|
571
|
+
// (Setting service.env.NODE_OPTIONS directly still replaces the default — the explicit opt-out.)
|
|
572
|
+
if (service.preload?.length) {
|
|
573
|
+
const requires = service.preload.map((module) => `--require ${resolve(cwd, module)}`).join(' ');
|
|
574
|
+
env.NODE_OPTIONS = env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${requires}` : requires;
|
|
575
|
+
}
|
|
576
|
+
const child = spawn(service.command, args, {
|
|
577
|
+
cwd,
|
|
578
|
+
env,
|
|
579
|
+
detached: true,
|
|
580
|
+
stdio: ['ignore', out, out],
|
|
581
|
+
});
|
|
582
|
+
child.unref();
|
|
583
|
+
|
|
584
|
+
// Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
|
|
585
|
+
// child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
|
|
586
|
+
// and dies — but waitForTcp would then see the OTHER process answering and the world would
|
|
587
|
+
// silently record this service's URL pointing at the WRONG twin (the multi-twin crossing
|
|
588
|
+
// bug). Detecting the child's own exit lets us fail LOUDLY instead.
|
|
589
|
+
// Object holder (not a bare `let`) so TS keeps the union type across the exit closure.
|
|
590
|
+
const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
|
|
591
|
+
child.on('exit', (code, signal) => {
|
|
592
|
+
exitState.value = { code, signal };
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
await waitForTcp(port).catch((error) => {
|
|
596
|
+
try {
|
|
597
|
+
process.kill(-child.pid!, 'SIGTERM');
|
|
598
|
+
} catch {
|
|
599
|
+
// best effort cleanup
|
|
600
|
+
}
|
|
601
|
+
throw new Error(`Service "${service.id}" failed to start: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
if (exitState.value !== null) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`Service "${service.id}" exited during startup (code=${exitState.value.code}, signal=${exitState.value.signal}) even though port ${port} answers — `
|
|
607
|
+
+ `the port is almost certainly held by another/stale process, so the twin never bound it. `
|
|
608
|
+
+ `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const host = '127.0.0.1';
|
|
613
|
+
const url = `http://${host}:${port}`;
|
|
614
|
+
|
|
615
|
+
// Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
|
|
616
|
+
// before it is actually serving, so this makes `up` wait until the service truly responds.
|
|
617
|
+
if (service.ready && service.ready !== 'tcp') {
|
|
618
|
+
await awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env).catch((error) => {
|
|
619
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
620
|
+
throw new Error(`Service "${service.id}" failed its readiness probe: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
id: service.id,
|
|
626
|
+
type: service.type ?? 'twin',
|
|
627
|
+
command: [service.command, ...args],
|
|
628
|
+
port,
|
|
629
|
+
url,
|
|
630
|
+
pid: child.pid ?? 0,
|
|
631
|
+
log,
|
|
632
|
+
env: {
|
|
633
|
+
...(service.injectEnv ? { [service.injectEnv]: url } : {}),
|
|
634
|
+
...resolveServiceEnvTemplates(service.injectEnvTemplates, { host, port, url }),
|
|
635
|
+
...resolveCliRedirect(service.cliRedirect, url),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Boot every `colocate`-declaring service inside ONE `volter-world-host` child (see src/host.ts —
|
|
642
|
+
* 'colocated' maps to the host's 'shared' mode, 'worker' to one Worker thread per twin). The
|
|
643
|
+
* recorded instances are shaped exactly like the spawn path's (port/url/injected env), except they
|
|
644
|
+
* all share the host child's pid — so pids/instance.json/downWorld need no special casing.
|
|
645
|
+
*/
|
|
646
|
+
async function startColocatedServices(
|
|
647
|
+
config: WorldConfig,
|
|
648
|
+
colocated: WorldServiceConfig[],
|
|
649
|
+
isolation: Exclude<WorldIsolation, 'process'>,
|
|
650
|
+
worldEnv: Record<string, string>,
|
|
651
|
+
paths: { root: string; instance: string; logs: string; data: string },
|
|
652
|
+
): Promise<WorldServiceInstance[]> {
|
|
653
|
+
const hostCli = join(import.meta.dir, 'host-cli.ts');
|
|
654
|
+
const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker'];
|
|
655
|
+
const allocated: Array<{ service: WorldServiceConfig; port: number }> = [];
|
|
656
|
+
for (const service of colocated) {
|
|
657
|
+
const port = service.port === undefined || service.port === 'auto' ? await allocatePort() : service.port;
|
|
658
|
+
const serviceDataDir = join(paths.data, service.id);
|
|
659
|
+
mkdirSync(serviceDataDir, { recursive: true });
|
|
660
|
+
// Relative module paths resolve from the world root (import() inside the host would
|
|
661
|
+
// otherwise resolve them against the runtime's own source directory).
|
|
662
|
+
const module = service.colocate!.module.startsWith('.') ? resolve(paths.root, service.colocate!.module) : service.colocate!.module;
|
|
663
|
+
args.push('--spec', [service.id, module, service.colocate!.export, String(port), serviceDataDir].join('|'));
|
|
664
|
+
allocated.push({ service, port });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const log = join(paths.logs, 'host.log');
|
|
668
|
+
const out = openSync(log, 'a');
|
|
669
|
+
// The host is world infrastructure, not an app process — same egress hygiene as controlPlane
|
|
670
|
+
// services (no injector preload, no ambient proxy): its twins SERVE, they don't call vendors.
|
|
671
|
+
const env: Record<string, string> = { ...process.env as Record<string, string>, ...(config.env ?? {}), ...worldEnv };
|
|
672
|
+
for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
673
|
+
const child = spawn(process.execPath, args, {
|
|
674
|
+
cwd: paths.root,
|
|
675
|
+
env,
|
|
676
|
+
detached: true,
|
|
677
|
+
stdio: ['ignore', out, out],
|
|
678
|
+
});
|
|
679
|
+
child.unref();
|
|
680
|
+
|
|
681
|
+
const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
|
|
682
|
+
child.on('exit', (code, signal) => {
|
|
683
|
+
exitState.value = { code, signal };
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const command = [process.execPath, ...args];
|
|
687
|
+
for (const { service, port } of allocated) {
|
|
688
|
+
await waitForTcp(port).catch((error) => {
|
|
689
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
690
|
+
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (exitState.value !== null) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`The co-located host exited during startup (code=${exitState.value.code}, signal=${exitState.value.signal}) even though its ports answer — `
|
|
696
|
+
+ `they are almost certainly held by other/stale processes. Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const host = '127.0.0.1';
|
|
701
|
+
return allocated.map(({ service, port }) => {
|
|
702
|
+
const url = `http://${host}:${port}`;
|
|
703
|
+
return {
|
|
704
|
+
id: service.id,
|
|
705
|
+
type: service.type ?? 'twin',
|
|
706
|
+
command,
|
|
707
|
+
port,
|
|
708
|
+
url,
|
|
709
|
+
pid: child.pid ?? 0,
|
|
710
|
+
log,
|
|
711
|
+
env: {
|
|
712
|
+
...(service.injectEnv ? { [service.injectEnv]: url } : {}),
|
|
713
|
+
...resolveServiceEnvTemplates(service.injectEnvTemplates, { host, port, url }),
|
|
714
|
+
...resolveCliRedirect(service.cliRedirect, url),
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export async function upWorld(configId: string, options: UpWorldOptions = {}): Promise<WorldInstance> {
|
|
721
|
+
const root = resolve(options.root ?? process.cwd());
|
|
722
|
+
const loaded = loadWorldConfig(configId, root);
|
|
723
|
+
const name = options.name ?? loaded.config.id;
|
|
724
|
+
const mode = options.mode ?? 'local';
|
|
725
|
+
assertSafeWorldName(name);
|
|
726
|
+
assertMode(mode);
|
|
727
|
+
const isolation = options.isolation ?? loaded.config.isolation ?? 'process';
|
|
728
|
+
// TWIN-67: schema.ts's WorldIsolation contract says process isolation ("one OS process per
|
|
729
|
+
// service") is "the only choice for share/sealed/hosted worlds" — colocating twins in one host
|
|
730
|
+
// process (or worker threads, which still share that process) under `share`/`sealed` puts a
|
|
731
|
+
// semi-untrusted session on colocated twins sharing one host process, exactly what the
|
|
732
|
+
// contract forbids. Enforce it here instead of leaving it a comment nothing checks. Checked
|
|
733
|
+
// before the file-lock claim / any dir creation below, so a rejected combination leaves nothing
|
|
734
|
+
// to clean up.
|
|
735
|
+
if (mode !== 'local' && isolation !== 'process') {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`world "${name}": mode "${mode}" requires isolation "process" (schema.ts: process isolation is the only choice for share/sealed/hosted worlds), got isolation "${isolation}". Use --isolation process (or omit --isolation) with --mode ${mode}.`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const instance = instanceDir(root, name);
|
|
742
|
+
const logs = join(instance, 'logs');
|
|
743
|
+
const data = join(instance, 'data');
|
|
744
|
+
const pidsFile = join(instance, 'pids');
|
|
745
|
+
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
746
|
+
const instanceFile = join(instance, 'instance.json');
|
|
747
|
+
|
|
748
|
+
// Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
|
|
749
|
+
// two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
|
|
750
|
+
// dir, and one clobbers the other's freshly-spawned services (orphaned processes + a corrupt
|
|
751
|
+
// instance dir). The lock guards only the SYNCHRONOUS claim below — check, wipe, re-create,
|
|
752
|
+
// write `booting.json` — so it is held for milliseconds and never spans the async service boot
|
|
753
|
+
// (which can exceed withFileLock's 10s contender timeout); the booting marker written inside
|
|
754
|
+
// the lock is what keeps contenders out for the boot's whole duration, and a contender that
|
|
755
|
+
// finds a live booter fails FAST with a clear error instead of waiting on an open-ended boot.
|
|
756
|
+
// The claim fn has no awaits, so two in-process callers cannot interleave inside it either;
|
|
757
|
+
// the lockfile adds the cross-process exclusion, and an in-process lock holder always releases
|
|
758
|
+
// before its first await, so withFileLock's synchronous contender wait can never deadlock the
|
|
759
|
+
// event loop against a holder in the same process.
|
|
760
|
+
withFileLock(instanceLockFile(root, name), () => {
|
|
761
|
+
const live = livePids(readPids(pidsFile));
|
|
762
|
+
if (live.length > 0) {
|
|
763
|
+
throw new Error(`World "${name}" is already running (${live.join(', ')}). Run: volter-world down ${name}`);
|
|
764
|
+
}
|
|
765
|
+
const booting = liveBootingClaim(bootingFile(root, name));
|
|
766
|
+
if (booting) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`World "${name}" is being booted by another process (pid ${booting.pid}, since ${booting.at}). Retry after it finishes (a dead booter's claim is reclaimed automatically).`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
rmSync(instance, { recursive: true, force: true });
|
|
772
|
+
mkdirSync(logs, { recursive: true });
|
|
773
|
+
mkdirSync(data, { recursive: true });
|
|
774
|
+
writeFileSync(bootingFile(root, name), `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() } satisfies BootingClaim)}\n`);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// From here on this call owns the claim; the finally below releases it however the boot
|
|
778
|
+
// settles. On success the recorded live pids (written before returning) take over as the
|
|
779
|
+
// "already running" guard; on failure the world never came up, so the next upWorld must be
|
|
780
|
+
// allowed to claim. Removing the marker is safe — only THIS call's marker can exist at this
|
|
781
|
+
// point (a foreign live claim made the claim section above throw before this try).
|
|
782
|
+
try {
|
|
783
|
+
const services: Record<string, WorldServiceInstance> = {};
|
|
784
|
+
const env: Record<string, string> = {
|
|
785
|
+
...(loaded.config.env ?? {}),
|
|
786
|
+
VOLTER_WORLD_NAME: name,
|
|
787
|
+
VOLTER_WORLD_MODE: mode,
|
|
788
|
+
VOLTER_WORLD_CONFIG: loaded.path,
|
|
789
|
+
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
790
|
+
// VOLTER_WORLD_SEALED is intent-only; VOLTER_TWIN_STRICT_EGRESS is what the ENFORCEMENT
|
|
791
|
+
// points actually gate on (redirect-proxy.ts's CONNECT/plain-proxy handlers and
|
|
792
|
+
// control-plane/inject.cjs's patched http/https/fetch) — both block any untwinned
|
|
793
|
+
// non-loopback host once this is set. Without it "sealed" only records intent and every
|
|
794
|
+
// untwinned host is blind-tunneled to the real internet (TWIN-60).
|
|
795
|
+
...(mode === 'sealed' ? { VOLTER_WORLD_SEALED: '1', VOLTER_TWIN_STRICT_EGRESS: '1' } : {}),
|
|
796
|
+
NODE_OPTIONS: `--require ${injectPreloadSpecifier()}${process.env.NODE_OPTIONS ? ` ${process.env.NODE_OPTIONS}` : ''}`,
|
|
797
|
+
};
|
|
798
|
+
env.NO_PROXY = process.env.NO_PROXY ? `127.0.0.1,localhost,${process.env.NO_PROXY}` : '127.0.0.1,localhost';
|
|
799
|
+
env.no_proxy = process.env.no_proxy ? `127.0.0.1,localhost,${process.env.no_proxy}` : '127.0.0.1,localhost';
|
|
800
|
+
writeProxyEnv(root, name, env);
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
// Non-'process' isolation: boot every colocate-declaring service inside ONE host child
|
|
804
|
+
// first; anything without `colocate` (and externals) still goes through the spawn path.
|
|
805
|
+
if (isolation !== 'process') {
|
|
806
|
+
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
807
|
+
for (const started of await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data })) {
|
|
808
|
+
services[started.id] = started;
|
|
809
|
+
Object.assign(env, started.env);
|
|
810
|
+
}
|
|
811
|
+
writeProxyEnv(root, name, env);
|
|
812
|
+
}
|
|
813
|
+
let proxyForServices: ReturnType<typeof ensureWorldProxyFromEnv> | null = null;
|
|
814
|
+
// TWIN-64: attempt the ambient proxy once activeVendorMap(env) has at least one twin — tried
|
|
815
|
+
// again after each service in case ITS start is what makes the map non-empty. A sealed world
|
|
816
|
+
// must never come up silently unsealed: if openssl itself is unavailable (no proxy is even
|
|
817
|
+
// possible) or the proxy daemon fails/times out, refuse rather than let unmodified CLIs bypass
|
|
818
|
+
// the twins undetected.
|
|
819
|
+
const attemptProxy = (): void => {
|
|
820
|
+
if (proxyForServices || Object.keys(activeVendorMap(env)).length === 0) return;
|
|
821
|
+
if (!opensslAvailable()) {
|
|
822
|
+
if (mode === 'sealed') {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`sealed world "${name}": openssl is unavailable — the ambient redirect proxy cannot start, so unmodified CLIs (curl, gh, stripe, …) could bypass the twins undetected; refusing to come up unsealed. Install openssl or use --mode local.`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
proxyForServices = ensureWorldProxyFromEnv(name, root, env);
|
|
830
|
+
if (proxyForServices?.url) {
|
|
831
|
+
Object.assign(env, proxyForServices.env);
|
|
832
|
+
} else if (mode === 'sealed') {
|
|
833
|
+
// The ambient redirect proxy timed out (see the loud WARN already emitted by
|
|
834
|
+
// ensureWorldProxyFromEnv). Composes with the existing catch below: stops the proxy
|
|
835
|
+
// daemon, kills started services, rethrows.
|
|
836
|
+
throw new Error(`sealed world "${name}": ambient redirect proxy failed to start — refusing to come up unsealed`);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
for (const service of loaded.config.services) {
|
|
840
|
+
if (services[service.id]) continue; // already up co-located
|
|
841
|
+
attemptProxy();
|
|
842
|
+
const started = await startService(loaded.config, service, env, { root, instance, logs, data });
|
|
843
|
+
services[started.id] = started;
|
|
844
|
+
Object.assign(env, started.env);
|
|
845
|
+
writeProxyEnv(root, name, env);
|
|
846
|
+
}
|
|
847
|
+
// Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
|
|
848
|
+
// only lands in `env` once IT has started, so a check that only runs "before starting the
|
|
849
|
+
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
850
|
+
// config test below exercises exactly this.
|
|
851
|
+
attemptProxy();
|
|
852
|
+
} catch (error) {
|
|
853
|
+
stopWorldProxy(root, name);
|
|
854
|
+
const pids = [...new Set(Object.values(services).map((service) => service.pid).filter(Boolean))];
|
|
855
|
+
signalPids(pids, 'SIGTERM');
|
|
856
|
+
// Stop any self-managed externals that DID come up, so a failed `up` never leaves a
|
|
857
|
+
// half-running external stack behind. Best-effort: the original error is what we throw —
|
|
858
|
+
// but a `down` that ITSELF fails must not be silent (the operator would have no hint that
|
|
859
|
+
// an external stack is still half-running), so those failures are appended to its message.
|
|
860
|
+
const rollbackErrors: string[] = [];
|
|
861
|
+
for (const service of Object.values(services)) {
|
|
862
|
+
if (service.type === 'external' && service.external) {
|
|
863
|
+
// Same env shape as up/status (startExternalService) and downWorld's teardown:
|
|
864
|
+
// process.env underneath so the tool is even findable on PATH — without it every
|
|
865
|
+
// rollback `down` died with "Executable not found" (silently, until surfaced below).
|
|
866
|
+
const downEnv = { ...process.env, ...env, ...(service.external.discoveredEnv ?? {}) };
|
|
867
|
+
try {
|
|
868
|
+
runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
|
|
869
|
+
} catch (downError) {
|
|
870
|
+
rollbackErrors.push(downError instanceof Error ? downError.message : String(downError));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Same SIGTERM→SIGKILL contract as downWorld: a service that ignores SIGTERM must not
|
|
875
|
+
// outlive a failed boot either. The poll exits as soon as everything is dead, so the
|
|
876
|
+
// common case (services honor SIGTERM) adds no delay to the failure path.
|
|
877
|
+
await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT);
|
|
878
|
+
if (rollbackErrors.length > 0) {
|
|
879
|
+
const suffix = `\n(rollback: external down failed — the stack may still be half-running: ${rollbackErrors.join('; ')})`;
|
|
880
|
+
// Mutate rather than re-wrap: callers match on the ORIGINAL message (which must keep
|
|
881
|
+
// leading) and the original stack/type stay intact.
|
|
882
|
+
if (error instanceof Error) error.message += suffix;
|
|
883
|
+
else throw new Error(`${String(error)}${suffix}`);
|
|
884
|
+
}
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const worldInstance: WorldInstance = {
|
|
889
|
+
name,
|
|
890
|
+
config: loaded.config.id,
|
|
891
|
+
configPath: loaded.path,
|
|
892
|
+
root,
|
|
893
|
+
createdAt: new Date().toISOString(),
|
|
894
|
+
mode,
|
|
895
|
+
dirs: { instance, logs, data },
|
|
896
|
+
services,
|
|
897
|
+
env,
|
|
898
|
+
envFile,
|
|
899
|
+
pidsFile,
|
|
900
|
+
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
901
|
+
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
mkdirSync(dirname(envFile), { recursive: true });
|
|
905
|
+
writeFileSync(envFile, envFileContents(env));
|
|
906
|
+
writePidsFromInstance(worldInstance);
|
|
907
|
+
saveWorldInstance(worldInstance);
|
|
908
|
+
if (mode === 'share') {
|
|
909
|
+
try {
|
|
910
|
+
return await shareWorldServices(name, { root, ...(options.share ?? {}) });
|
|
911
|
+
} catch (error) {
|
|
912
|
+
await downWorld(name, root);
|
|
913
|
+
throw error;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return worldInstance;
|
|
917
|
+
} finally {
|
|
918
|
+
rmSync(bootingFile(root, name), { force: true });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function teardownExternalServices(instance: WorldInstance): { stopped: string[]; errors: string[] } {
|
|
923
|
+
const stopped: string[] = [];
|
|
924
|
+
const errors: string[] = [];
|
|
925
|
+
for (const service of Object.values(instance.services)) {
|
|
926
|
+
if (service.type !== 'external' || !service.external) continue;
|
|
927
|
+
const downEnv = { ...process.env, ...(instance.env ?? {}), ...(service.external.discoveredEnv ?? {}) };
|
|
928
|
+
try {
|
|
929
|
+
runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
|
|
930
|
+
stopped.push(service.id);
|
|
931
|
+
} catch (error) {
|
|
932
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return { stopped, errors };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** SIGTERM contract (documented in WORLD.md § Teardown): services get SIGTERM to their process
|
|
939
|
+
* GROUP, then `downWorld` waits up to this grace for them to exit before SIGKILLing survivors.
|
|
940
|
+
* Overridable per call via `options.graceMs` (the CLI exposes `--grace-ms`). */
|
|
941
|
+
const DOWN_GRACE_MS_DEFAULT = 5_000;
|
|
942
|
+
|
|
943
|
+
/** Signal each pid's process GROUP (services are spawned `detached`, so each pid is a group
|
|
944
|
+
* leader), falling back to the single pid when the group signal fails. Returns the pids that
|
|
945
|
+
* accepted the signal. */
|
|
946
|
+
function signalPids(pids: number[], signal: NodeJS.Signals): number[] {
|
|
947
|
+
const signalled: number[] = [];
|
|
948
|
+
for (const pid of pids) {
|
|
949
|
+
try {
|
|
950
|
+
process.kill(-pid, signal);
|
|
951
|
+
signalled.push(pid);
|
|
952
|
+
} catch {
|
|
953
|
+
try {
|
|
954
|
+
process.kill(pid, signal);
|
|
955
|
+
signalled.push(pid);
|
|
956
|
+
} catch {}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return signalled;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** A zombie (exited but unreaped — e.g. its spawning `up` process is gone and this machine's
|
|
963
|
+
* PID 1 doesn't reap orphans, as in minimal containers) still answers `kill(pid, 0)` yet is dead
|
|
964
|
+
* for every practical purpose: it holds no ports and cannot receive SIGKILL. The escalation
|
|
965
|
+
* grace must not wait out a zombie — or worse, report "escalating" one. Linux: state field from
|
|
966
|
+
* /proc (the char right after the parenthesized comm). Elsewhere (macOS has no /proc): `ps`. */
|
|
967
|
+
function zombiePid(pid: number): boolean {
|
|
968
|
+
try {
|
|
969
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
970
|
+
return stat.slice(stat.lastIndexOf(')') + 2).charAt(0) === 'Z';
|
|
971
|
+
} catch {
|
|
972
|
+
const result = spawnSync('ps', ['-o', 'state=', '-p', String(pid)], { encoding: 'utf8' });
|
|
973
|
+
return result.status === 0 && result.stdout.trim().startsWith('Z');
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Pids from `pids` that are neither dead nor zombied — i.e. still need killing. Shared by both
|
|
978
|
+
* the async and sync flavors of the grace-then-SIGKILL poll below. */
|
|
979
|
+
function undeadPids(pids: number[]): number[] {
|
|
980
|
+
return livePids(pids).filter((pid) => !zombiePid(pid));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Bounded extra wait (ms) for a pid to actually finish dying once SIGKILL has been sent. SIGKILL
|
|
984
|
+
* cannot be caught/blocked, but delivery + the kernel's exit transition is not instantaneous —
|
|
985
|
+
* callers that gate on "confirmed dead" (purge, unshareWorld) need this, not just the signal sent. */
|
|
986
|
+
const KILL_CONFIRM_MS = 2_000;
|
|
987
|
+
|
|
988
|
+
/** The SIGKILL half of SIGTERM→SIGKILL escalation: bounded poll on pid liveness (returns as soon
|
|
989
|
+
* as everything is dead — no fixed sleep), then SIGKILL whatever survived the grace, group-first
|
|
990
|
+
* exactly like the SIGTERM was delivered, then a short bounded confirm-wait so survivors are
|
|
991
|
+
* actually gone (not just signalled) by the time this returns. Returns the pids that needed
|
|
992
|
+
* escalation. */
|
|
993
|
+
async function killSurvivorsAfterGrace(pids: number[], graceMs: number): Promise<number[]> {
|
|
994
|
+
const deadline = Date.now() + graceMs;
|
|
995
|
+
while (undeadPids(pids).length > 0 && Date.now() < deadline) {
|
|
996
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
|
997
|
+
}
|
|
998
|
+
const survivors = undeadPids(pids);
|
|
999
|
+
signalPids(survivors, 'SIGKILL');
|
|
1000
|
+
const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
|
|
1001
|
+
while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
|
|
1002
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
|
|
1003
|
+
}
|
|
1004
|
+
return survivors;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Synchronous twin of `killSurvivorsAfterGrace` for callers (`unshareWorld`) that must stay
|
|
1008
|
+
* synchronous — same "poll until dead or grace expires, then SIGKILL survivors, then confirm they
|
|
1009
|
+
* are actually gone" contract, using an `Atomics.wait` backoff (already this file's pattern for
|
|
1010
|
+
* synchronous polling — see `ensureWorldProxy`) instead of `setTimeout`, since a sync function has
|
|
1011
|
+
* no event loop to yield to. */
|
|
1012
|
+
function killSurvivorsAfterGraceSync(pids: number[], graceMs: number): number[] {
|
|
1013
|
+
const deadline = Date.now() + graceMs;
|
|
1014
|
+
while (undeadPids(pids).length > 0 && Date.now() < deadline) {
|
|
1015
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
1016
|
+
}
|
|
1017
|
+
const survivors = undeadPids(pids);
|
|
1018
|
+
signalPids(survivors, 'SIGKILL');
|
|
1019
|
+
const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
|
|
1020
|
+
while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
|
|
1021
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
1022
|
+
}
|
|
1023
|
+
return survivors;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Async solely for the SIGTERM→SIGKILL grace poll — everything else (proxy stop, SIGTERM,
|
|
1027
|
+
* external teardown) still runs in the first synchronous slice, so a legacy caller that fires
|
|
1028
|
+
* without awaiting gets exactly the old behavior plus the deferred escalation. A blocking
|
|
1029
|
+
* sleep-loop would keep the sync signature but stall the caller's event loop for up to the whole
|
|
1030
|
+
* grace; a detached watchdog child would escalate too but leak orphan processes — the await is
|
|
1031
|
+
* the honest shape (upWorld/runWorld/the CLI are already async). */
|
|
1032
|
+
/**
|
|
1033
|
+
* Delete a world's instance dir entirely — the `--purge` half of `down` (TWIN-45 dev/02b): no
|
|
1034
|
+
* twin data (per-service `data/<id>`, logs, instance.json) survives past teardown. Plain `rm`,
|
|
1035
|
+
* same honesty as `world scrub` on the control-plane side (see docs/DATA_AT_REST.md).
|
|
1036
|
+
*
|
|
1037
|
+
* Safety (never delete more than THIS world's own instance dir):
|
|
1038
|
+
* - `assertSafeWorldName` rejects any name containing `/` or starting with `.` — the only
|
|
1039
|
+
* names `instanceDir`/`instanceLockFile` can ever have produced — so `dir` cannot resolve
|
|
1040
|
+
* outside `worldBaseDir(root)` and cannot collide with a sibling instance's directory.
|
|
1041
|
+
* - `dir` is asserted to be an actual child of `worldBaseDir(root)` (defense in depth against
|
|
1042
|
+
* a future refactor of `instanceDir` weakening that guarantee) and can never equal
|
|
1043
|
+
* `worldBaseDir` itself (which would nuke every world) or its `.locks` sibling.
|
|
1044
|
+
* - The per-world claim lock (`instanceLockFile`) lives OUTSIDE the instance dir precisely so
|
|
1045
|
+
* `upWorld`'s claim survives an in-flight `rmSync` of the instance dir (see its own comment);
|
|
1046
|
+
* purge cleans up this world's own leftover lock file by exact name — never the `.locks` dir,
|
|
1047
|
+
* never another world's `.lock` file.
|
|
1048
|
+
*/
|
|
1049
|
+
function purgeInstanceDir(root: string, name: string): string {
|
|
1050
|
+
assertSafeWorldName(name);
|
|
1051
|
+
const base = worldBaseDir(root);
|
|
1052
|
+
const dir = instanceDir(root, name);
|
|
1053
|
+
const rel = relative(base, dir);
|
|
1054
|
+
if (!rel || rel === '.' || rel.startsWith('..') || rel.split(sep)[0] !== name) {
|
|
1055
|
+
throw new Error(`Refusing to purge: resolved instance dir "${dir}" is not a direct child of "${base}"`);
|
|
1056
|
+
}
|
|
1057
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1058
|
+
rmSync(instanceLockFile(root, name), { force: true }); // this world's own claim lock, if left over
|
|
1059
|
+
return dir;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export async function downWorld(
|
|
1063
|
+
name: string,
|
|
1064
|
+
root = process.cwd(),
|
|
1065
|
+
options: { graceMs?: number; purge?: boolean } = {},
|
|
1066
|
+
): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; purged?: string }> {
|
|
1067
|
+
const resolvedRoot = resolve(root);
|
|
1068
|
+
const dir = instanceDir(resolvedRoot, name);
|
|
1069
|
+
const pidsFile = join(dir, 'pids');
|
|
1070
|
+
// Tear down the ambient-redirect proxy + its session CA FIRST, so the trusted CA never outlives
|
|
1071
|
+
// the world (it was only ever trusted via per-shell env, never system-wide).
|
|
1072
|
+
stopWorldProxy(resolvedRoot, name);
|
|
1073
|
+
const pids = readPids(pidsFile);
|
|
1074
|
+
const stopped = signalPids(pids, 'SIGTERM');
|
|
1075
|
+
|
|
1076
|
+
// Stop self-managed externals via their declared `down` command. Tolerate a missing
|
|
1077
|
+
// instance file (nothing to tear down), but surface external `down` failures to the caller.
|
|
1078
|
+
let externalStopped: string[] = [];
|
|
1079
|
+
let externalErrors: string[] = [];
|
|
1080
|
+
try {
|
|
1081
|
+
const instance = readWorldInstance(name, resolvedRoot);
|
|
1082
|
+
const result = teardownExternalServices(instance);
|
|
1083
|
+
externalStopped = result.stopped;
|
|
1084
|
+
externalErrors = result.errors;
|
|
1085
|
+
} catch {
|
|
1086
|
+
// no instance.json (never fully came up) — only owned processes needed stopping.
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Escalate AFTER the (synchronous) external teardown so externals never wait on the grace, and
|
|
1090
|
+
// poll the full pids list rather than `stopped`: a pid whose SIGTERM errored is almost always
|
|
1091
|
+
// already dead, and livePids() re-checks anyway.
|
|
1092
|
+
const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
|
|
1093
|
+
|
|
1094
|
+
rmSync(pidsFile, { force: true });
|
|
1095
|
+
|
|
1096
|
+
// Purge LAST, only after every process (including SIGKILL escalations) is confirmed dead —
|
|
1097
|
+
// removing the instance dir out from under a still-running service would orphan it with no
|
|
1098
|
+
// recorded pid to ever find it again.
|
|
1099
|
+
if (!options.purge) return { name, stopped, escalated, externalStopped, externalErrors };
|
|
1100
|
+
if (externalErrors.length > 0) {
|
|
1101
|
+
// TWIN-61: instance.json is the ONLY record of a self-managed external's `down` command and
|
|
1102
|
+
// discovered env (schema.ts's WorldServiceInstance.external). If its `down` just failed, the
|
|
1103
|
+
// external stack is (probably) still running — deleting that record here would leave the
|
|
1104
|
+
// operator with a live external and no way to ever find how to stop it again. Skip the rmSync
|
|
1105
|
+
// and report the failure via `externalErrors` (already populated above); `purged` stays
|
|
1106
|
+
// undefined so the caller can tell purge did NOT happen.
|
|
1107
|
+
return { name, stopped, escalated, externalStopped, externalErrors };
|
|
1108
|
+
}
|
|
1109
|
+
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
1110
|
+
return { name, stopped, escalated, externalStopped, externalErrors, purged };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
export function statusWorld(name: string, root = process.cwd()): WorldInstance & { running: boolean; livePids: number[] } {
|
|
1114
|
+
const worldInstance = readWorldInstance(name, resolve(root));
|
|
1115
|
+
const live = livePids(readPids(worldInstance.pidsFile));
|
|
1116
|
+
return { ...worldInstance, running: live.length > 0, livePids: live };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function shareCommand(options: ShareWorldOptions, serviceUrl: string): { provider: 'cloudflare-quick' | 'command'; command: string; args: string[] } {
|
|
1120
|
+
if (!options.command && options.provider === 'command') {
|
|
1121
|
+
throw new Error(`share provider "command" requires a tunnel command (share.command / --command)`);
|
|
1122
|
+
}
|
|
1123
|
+
if (options.command) {
|
|
1124
|
+
return {
|
|
1125
|
+
provider: options.provider ?? 'command',
|
|
1126
|
+
command: options.command,
|
|
1127
|
+
args: (options.args ?? []).map((arg) => arg.replaceAll('{url}', serviceUrl)),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
provider: 'cloudflare-quick',
|
|
1132
|
+
command: 'cloudflared',
|
|
1133
|
+
args: ['tunnel', '--url', serviceUrl],
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string, timeoutMs: number, requireTryCloudflare: boolean): Promise<string> {
|
|
1138
|
+
const urlPattern = /https:\/\/[a-zA-Z0-9.-]+/g;
|
|
1139
|
+
let exited = false;
|
|
1140
|
+
let exitSummary = '';
|
|
1141
|
+
child.once('exit', (code, signal) => {
|
|
1142
|
+
exited = true;
|
|
1143
|
+
exitSummary = `code=${code ?? 'null'} signal=${signal ?? 'null'}`;
|
|
1144
|
+
});
|
|
1145
|
+
child.once('error', (error) => {
|
|
1146
|
+
exited = true;
|
|
1147
|
+
exitSummary = error.message;
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const started = Date.now();
|
|
1151
|
+
while (Date.now() - started < timeoutMs) {
|
|
1152
|
+
const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
|
|
1153
|
+
const matches = text.match(urlPattern);
|
|
1154
|
+
const url = requireTryCloudflare
|
|
1155
|
+
? matches?.find((candidate) => candidate.includes('trycloudflare.com'))
|
|
1156
|
+
: matches?.find((candidate) => candidate.includes('trycloudflare.com')) ?? matches?.[0];
|
|
1157
|
+
if (url) return url;
|
|
1158
|
+
if (exited) throw new Error(`Tunnel process exited before printing a public URL (${exitSummary}). Log: ${logPath}`);
|
|
1159
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
1160
|
+
}
|
|
1161
|
+
throw new Error(`Tunnel did not print a public https URL within ${timeoutMs}ms. Log: ${logPath}`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function sleep(ms: number): Promise<void> {
|
|
1165
|
+
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function commandExists(command: string): boolean {
|
|
1169
|
+
// Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
|
|
1170
|
+
const result = spawnSync('which', [command], { stdio: 'ignore', env: process.env });
|
|
1171
|
+
return result.status === 0;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function resolveWithPublicDns(hostname: string): string | undefined {
|
|
1175
|
+
const result = spawnSync('dig', ['+short', '@1.1.1.1', hostname, 'A'], {
|
|
1176
|
+
encoding: 'utf8',
|
|
1177
|
+
});
|
|
1178
|
+
if (result.status !== 0) return undefined;
|
|
1179
|
+
return result.stdout
|
|
1180
|
+
.split(/\r?\n/)
|
|
1181
|
+
.map((line) => line.trim())
|
|
1182
|
+
.find((line) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(line));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function waitForPublicDns(hostname: string, timeoutMs: number): Promise<string | undefined> {
|
|
1186
|
+
const started = Date.now();
|
|
1187
|
+
while (Date.now() - started < timeoutMs) {
|
|
1188
|
+
const ip = resolveWithPublicDns(hostname);
|
|
1189
|
+
if (ip) return ip;
|
|
1190
|
+
await sleep(1_000);
|
|
1191
|
+
}
|
|
1192
|
+
return undefined;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function publicHealthUrl(publicUrl: string, path: string): string {
|
|
1196
|
+
const url = new URL(publicUrl);
|
|
1197
|
+
url.pathname = path.startsWith('/') ? path : `/${path}`;
|
|
1198
|
+
url.search = '';
|
|
1199
|
+
url.hash = '';
|
|
1200
|
+
return url.toString();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function verifyPublicUrl(
|
|
1204
|
+
publicUrl: string,
|
|
1205
|
+
path: string,
|
|
1206
|
+
timeoutMs: number,
|
|
1207
|
+
): Promise<NonNullable<WorldServiceInstance['publicVerification']>> {
|
|
1208
|
+
if (!commandExists('dig')) throw new Error('Public URL verification requires dig');
|
|
1209
|
+
if (!commandExists('curl')) throw new Error('Public URL verification requires curl');
|
|
1210
|
+
const hostname = new URL(publicUrl).hostname;
|
|
1211
|
+
const started = Date.now();
|
|
1212
|
+
const resolvedIp = await waitForPublicDns(hostname, Math.min(timeoutMs, 60_000));
|
|
1213
|
+
if (!resolvedIp) {
|
|
1214
|
+
throw new Error(`Public URL did not resolve through @1.1.1.1 within ${Math.min(timeoutMs, 60_000)}ms: ${hostname}`);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const target = publicHealthUrl(publicUrl, path);
|
|
1218
|
+
let lastError = '';
|
|
1219
|
+
while (Date.now() - started < timeoutMs) {
|
|
1220
|
+
const remainingMs = Math.max(timeoutMs - (Date.now() - started), 1);
|
|
1221
|
+
const result = spawnSync(
|
|
1222
|
+
'curl',
|
|
1223
|
+
[
|
|
1224
|
+
'-sS',
|
|
1225
|
+
'-o',
|
|
1226
|
+
'-',
|
|
1227
|
+
'-w',
|
|
1228
|
+
'\n%{http_code}',
|
|
1229
|
+
'--max-time',
|
|
1230
|
+
String(Math.min(10, Math.max(1, Math.ceil(remainingMs / 1000)))),
|
|
1231
|
+
'--resolve',
|
|
1232
|
+
`${hostname}:443:${resolvedIp}`,
|
|
1233
|
+
target,
|
|
1234
|
+
],
|
|
1235
|
+
{ encoding: 'utf8' },
|
|
1236
|
+
);
|
|
1237
|
+
if (result.status === 0) {
|
|
1238
|
+
const output = result.stdout;
|
|
1239
|
+
const separator = output.lastIndexOf('\n');
|
|
1240
|
+
const body = separator >= 0 ? output.slice(0, separator).trim() : output.trim();
|
|
1241
|
+
const status = Number(separator >= 0 ? output.slice(separator + 1).trim() : '0');
|
|
1242
|
+
if (status >= 200 && status < 400) {
|
|
1243
|
+
return {
|
|
1244
|
+
path,
|
|
1245
|
+
checkedAt: new Date().toISOString(),
|
|
1246
|
+
hostname,
|
|
1247
|
+
resolvedIp,
|
|
1248
|
+
status,
|
|
1249
|
+
body: body.slice(0, 500),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
lastError = `HTTP ${status}${body ? `: ${body.slice(0, 200)}` : ''}`;
|
|
1253
|
+
} else {
|
|
1254
|
+
lastError = result.stderr.trim() || `curl exited ${result.status}`;
|
|
1255
|
+
}
|
|
1256
|
+
await sleep(Math.min(2_000, Math.max(timeoutMs - (Date.now() - started), 0)));
|
|
1257
|
+
}
|
|
1258
|
+
throw new Error(`Public URL did not pass health verification at ${target}: ${lastError || 'timed out'}`);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export async function shareWorld(name: string, options: ShareWorldOptions = {}): Promise<WorldInstance> {
|
|
1262
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1263
|
+
const serviceId = options.service ?? 'app';
|
|
1264
|
+
const instance = readWorldInstance(name, root);
|
|
1265
|
+
const service = instance.services[serviceId];
|
|
1266
|
+
if (!service) throw new Error(`World "${name}" has no service "${serviceId}"`);
|
|
1267
|
+
if (service.tunnel && livePids([service.tunnel.pid]).length > 0) {
|
|
1268
|
+
throw new Error(`World "${name}" service "${serviceId}" is already shared at ${service.publicUrl}`);
|
|
1269
|
+
}
|
|
1270
|
+
if (!service.url) {
|
|
1271
|
+
throw new Error(`World "${name}" service "${serviceId}" has no local URL to share (external/self-managed services are not assigned one)`);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const tunnel = shareCommand(options, service.url);
|
|
1275
|
+
const log = join(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
1276
|
+
const out = openSync(log, 'a');
|
|
1277
|
+
const child = spawn(tunnel.command, tunnel.args, {
|
|
1278
|
+
cwd: root,
|
|
1279
|
+
env: process.env,
|
|
1280
|
+
detached: true,
|
|
1281
|
+
stdio: ['ignore', out, out],
|
|
1282
|
+
});
|
|
1283
|
+
closeSync(out);
|
|
1284
|
+
|
|
1285
|
+
let publicUrl: string;
|
|
1286
|
+
let publicVerification: WorldServiceInstance['publicVerification'];
|
|
1287
|
+
try {
|
|
1288
|
+
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
|
|
1289
|
+
if (options.verifyPath !== false) {
|
|
1290
|
+
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
1291
|
+
}
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
// TWIN-62: never-half-shared means the tunnel must actually be confirmed dead before we
|
|
1294
|
+
// rethrow, not just best-effort SIGTERM'd — the same SIGTERM→grace→SIGKILL contract `downWorld`
|
|
1295
|
+
// gives owned services (a cloudflared that ignores SIGTERM must not keep serving after a
|
|
1296
|
+
// failed/rejected `share`).
|
|
1297
|
+
if (child.pid) {
|
|
1298
|
+
signalPids([child.pid], 'SIGTERM');
|
|
1299
|
+
await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
|
|
1300
|
+
}
|
|
1301
|
+
throw error;
|
|
1302
|
+
}
|
|
1303
|
+
child.unref();
|
|
1304
|
+
|
|
1305
|
+
service.publicUrl = publicUrl;
|
|
1306
|
+
service.publicUrlEphemeral = tunnel.provider === 'cloudflare-quick';
|
|
1307
|
+
service.publicReady = options.verifyPath !== false;
|
|
1308
|
+
if (publicVerification) service.publicVerification = publicVerification;
|
|
1309
|
+
else delete service.publicVerification;
|
|
1310
|
+
service.tunnel = {
|
|
1311
|
+
provider: tunnel.provider,
|
|
1312
|
+
pid: child.pid ?? 0,
|
|
1313
|
+
log,
|
|
1314
|
+
command: [tunnel.command, ...tunnel.args],
|
|
1315
|
+
startedAt: new Date().toISOString(),
|
|
1316
|
+
};
|
|
1317
|
+
writePidsFromInstance(instance);
|
|
1318
|
+
saveWorldInstance(instance);
|
|
1319
|
+
return instance;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function configuredShareTargets(
|
|
1323
|
+
instance: WorldInstance,
|
|
1324
|
+
requestedService?: string,
|
|
1325
|
+
verifyPath?: string | false,
|
|
1326
|
+
): Array<{ id: string; verifyPath?: string | false }> {
|
|
1327
|
+
if (requestedService) return [{ id: requestedService, verifyPath }];
|
|
1328
|
+
const config = loadWorldConfig(instance.configPath, instance.root).config;
|
|
1329
|
+
if (config.share?.services.length) {
|
|
1330
|
+
return config.share.services.map((service) => ({
|
|
1331
|
+
id: service.id,
|
|
1332
|
+
verifyPath: verifyPath ?? service.verifyPath,
|
|
1333
|
+
}));
|
|
1334
|
+
}
|
|
1335
|
+
return [{ id: 'app', verifyPath }];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
export async function shareWorldServices(name: string, options: ShareWorldServicesOptions = {}): Promise<WorldInstance> {
|
|
1339
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1340
|
+
let instance = readWorldInstance(name, root);
|
|
1341
|
+
const config = loadWorldConfig(instance.configPath, instance.root).config;
|
|
1342
|
+
const targets = configuredShareTargets(instance, options.service, options.verifyPath);
|
|
1343
|
+
for (const target of targets) {
|
|
1344
|
+
instance = await shareWorld(name, {
|
|
1345
|
+
...options,
|
|
1346
|
+
root,
|
|
1347
|
+
provider: options.provider ?? config.share?.provider,
|
|
1348
|
+
command: options.command ?? config.share?.command,
|
|
1349
|
+
args: options.args ?? config.share?.args,
|
|
1350
|
+
service: target.id,
|
|
1351
|
+
verifyPath: target.verifyPath,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
return instance;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** TWIN-62: `unshare` must give tunnels the same contract plain `downWorld` already gives owned
|
|
1358
|
+
* services — SIGTERM, confirm dead (escalating to SIGKILL past the grace), and only THEN erase the
|
|
1359
|
+
* tunnel facts/pids. A best-effort SIGTERM with no confirmation can leave a `cloudflared` that
|
|
1360
|
+
* ignores SIGTERM serving the public URL forever while instance.json (and the pids file) say
|
|
1361
|
+
* nothing is shared — the exact "half-shared" state WORLD.md promises cannot exist. Stays
|
|
1362
|
+
* synchronous (via `killSurvivorsAfterGraceSync`) to match the existing signature/call sites
|
|
1363
|
+
* (the CLI does not await it). Returns the instance plus the tunnel pids that needed escalating,
|
|
1364
|
+
* for callers that want to report it. */
|
|
1365
|
+
export function unshareWorld(name: string, options: { root?: string; service?: string; graceMs?: number } = {}): WorldInstance & { escalated: number[] } {
|
|
1366
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1367
|
+
const instance = readWorldInstance(name, root);
|
|
1368
|
+
const serviceIds = options.service ? [options.service] : Object.keys(instance.services);
|
|
1369
|
+
const escalated: number[] = [];
|
|
1370
|
+
const stillLive: string[] = [];
|
|
1371
|
+
for (const serviceId of serviceIds) {
|
|
1372
|
+
const service = instance.services[serviceId];
|
|
1373
|
+
if (!service) throw new Error(`World "${name}" has no service "${serviceId}"`);
|
|
1374
|
+
if (service.tunnel?.pid) {
|
|
1375
|
+
const pid = service.tunnel.pid;
|
|
1376
|
+
signalPids([pid], 'SIGTERM');
|
|
1377
|
+
const survivors = killSurvivorsAfterGraceSync([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
|
|
1378
|
+
if (survivors.length > 0) escalated.push(...survivors);
|
|
1379
|
+
// `undeadPids` (not the raw `livePids`) is the right "confirmed dead" test here: a zombie
|
|
1380
|
+
// (exited but unreaped — likely since we just polled it synchronously via `Atomics.wait`,
|
|
1381
|
+
// which blocks the event loop that would otherwise let this process auto-reap its child) still
|
|
1382
|
+
// answers `kill(pid, 0)` yet is dead for every practical purpose — see `zombiePid`'s own
|
|
1383
|
+
// comment. Using plain `livePids` here would treat every zombie as "still shared".
|
|
1384
|
+
if (undeadPids([pid]).length > 0) {
|
|
1385
|
+
// Even SIGKILL didn't confirm it dead (e.g. no permission to signal it) — refuse to erase
|
|
1386
|
+
// the only record of this tunnel rather than orphan it with instance.json saying "unshared".
|
|
1387
|
+
stillLive.push(serviceId);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
delete service.publicUrl;
|
|
1392
|
+
delete service.publicUrlEphemeral;
|
|
1393
|
+
delete service.publicReady;
|
|
1394
|
+
delete service.publicVerification;
|
|
1395
|
+
delete service.tunnel;
|
|
1396
|
+
}
|
|
1397
|
+
writePidsFromInstance(instance);
|
|
1398
|
+
saveWorldInstance(instance);
|
|
1399
|
+
if (stillLive.length > 0) {
|
|
1400
|
+
throw new Error(`World "${name}": tunnel process(es) for ${stillLive.join(', ')} could not be confirmed dead (even after SIGKILL) — refusing to erase their records`);
|
|
1401
|
+
}
|
|
1402
|
+
return Object.assign(instance, { escalated });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** Short, hardcoded timeout for `doctorWorld`'s external re-probe (TWIN-68) — this is a health
|
|
1406
|
+
* CHECK, not a boot wait, so it must never make `doctor` hang: a dead external should fail FAST. */
|
|
1407
|
+
const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
|
|
1408
|
+
|
|
1409
|
+
/** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
|
|
1410
|
+
* polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
|
|
1411
|
+
* must report the CURRENT state, not wait for it to become healthy). Reuses the same probe shapes
|
|
1412
|
+
* (`command`/`httpUrl`/`stdoutMatch`) declared in config for the boot-time `readyWhen`. */
|
|
1413
|
+
async function probeExternalOnce(
|
|
1414
|
+
ready: WorldExternalReadyWhen,
|
|
1415
|
+
cwd: string,
|
|
1416
|
+
env: NodeJS.ProcessEnv,
|
|
1417
|
+
logPath: string,
|
|
1418
|
+
): Promise<{ ok: boolean; message: string }> {
|
|
1419
|
+
if (ready.command !== undefined) {
|
|
1420
|
+
if (!commandExists(ready.command)) {
|
|
1421
|
+
return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
|
|
1422
|
+
}
|
|
1423
|
+
const result = spawnSync(ready.command, ready.args ?? [], {
|
|
1424
|
+
cwd, encoding: 'utf8', env, timeout: DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
1425
|
+
});
|
|
1426
|
+
if (result.status === 0) return { ok: true, message: 'probe ok (command)' };
|
|
1427
|
+
const timedOut = result.signal !== null && result.status === null;
|
|
1428
|
+
return { ok: false, message: timedOut ? `probe command timed out after ${DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS}ms` : `probe command exited ${result.status ?? 'null'}: ${(result.stderr ?? '').trim().slice(0, 300)}` };
|
|
1429
|
+
}
|
|
1430
|
+
if (ready.httpUrl !== undefined) {
|
|
1431
|
+
try {
|
|
1432
|
+
const response = await fetch(ready.httpUrl, { signal: AbortSignal.timeout(DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS) });
|
|
1433
|
+
if (response.status >= 200 && response.status < 400) return { ok: true, message: `probe ok (HTTP ${response.status})` };
|
|
1434
|
+
return { ok: false, message: `probe returned HTTP ${response.status}` };
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
return { ok: false, message: `probe failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (ready.stdoutMatch !== undefined) {
|
|
1440
|
+
const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
|
|
1441
|
+
if (new RegExp(ready.stdoutMatch).test(text)) return { ok: true, message: 'probe ok (stdoutMatch)' };
|
|
1442
|
+
return { ok: false, message: `probe pattern /${ready.stdoutMatch}/ not found in log` };
|
|
1443
|
+
}
|
|
1444
|
+
return { ok: false, message: 'malformed readiness probe (no command/httpUrl/stdoutMatch)' };
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
export async function doctorWorld(name: string, options: { root?: string; verifyPublic?: boolean; timeoutMs?: number } = {}): Promise<WorldDoctorReport> {
|
|
1448
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1449
|
+
const checks: WorldDoctorCheck[] = [];
|
|
1450
|
+
let instance: WorldInstance;
|
|
1451
|
+
try {
|
|
1452
|
+
instance = readWorldInstance(name, root);
|
|
1453
|
+
checks.push({ id: 'instance', ok: true, message: `instance=${instanceFile(root, name)}` });
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1456
|
+
return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
|
|
1460
|
+
|
|
1461
|
+
const live = livePids(readPids(instance.pidsFile));
|
|
1462
|
+
checks.push({
|
|
1463
|
+
id: 'pids',
|
|
1464
|
+
ok: live.length > 0,
|
|
1465
|
+
message: live.length > 0 ? `live=${live.join(', ')}` : 'no live world processes',
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
// Loaded once, best-effort: only used to find each external service's declared readyWhen/status
|
|
1469
|
+
// probe (TWIN-68) — if the config can't be reloaded (moved/deleted since `up`), externals just
|
|
1470
|
+
// fall back to the "no probe declared" informational check rather than failing doctor entirely.
|
|
1471
|
+
// (Named distinctly from the `config` reloaded further below for the share-mode checks, which
|
|
1472
|
+
// intentionally keeps its own separate, non-best-effort load.)
|
|
1473
|
+
let externalProbeConfig: WorldConfig | undefined;
|
|
1474
|
+
try {
|
|
1475
|
+
externalProbeConfig = loadWorldConfig(instance.configPath, instance.root).config;
|
|
1476
|
+
} catch {
|
|
1477
|
+
externalProbeConfig = undefined;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
for (const service of Object.values(instance.services)) {
|
|
1481
|
+
if (service.type === 'external') {
|
|
1482
|
+
// We don't own the external tool's process or its port — report what it discovered, plus
|
|
1483
|
+
// (TWIN-68) re-run its declared probe once so a stack that died mid-session goes red instead
|
|
1484
|
+
// of reporting an unconditional ok:true forever.
|
|
1485
|
+
const discovered = Object.keys(service.env ?? {});
|
|
1486
|
+
const discoveredMessage = discovered.length > 0 ? `discovered ${discovered.join(', ')}` : 'self-managed (no discovered env)';
|
|
1487
|
+
const configuredExternal = externalProbeConfig?.services.find((candidate) => candidate.id === service.id)?.external;
|
|
1488
|
+
const probeCwd = service.external?.cwd ?? root;
|
|
1489
|
+
const probeEnv = { ...process.env, ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
|
|
1490
|
+
if (configuredExternal?.readyWhen) {
|
|
1491
|
+
const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv, service.log);
|
|
1492
|
+
checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
|
|
1493
|
+
} else if (configuredExternal?.status) {
|
|
1494
|
+
try {
|
|
1495
|
+
runExternalCommand(configuredExternal.status, probeCwd, service.id, 'doctor-probe', probeEnv);
|
|
1496
|
+
checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; probe ok (status)` });
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
1499
|
+
}
|
|
1500
|
+
} else {
|
|
1501
|
+
checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; no probe declared — health unknown` });
|
|
1502
|
+
}
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
const pidLive = livePids([service.pid]).length > 0;
|
|
1506
|
+
checks.push({
|
|
1507
|
+
id: `service:${service.id}:pid`,
|
|
1508
|
+
ok: pidLive,
|
|
1509
|
+
message: pidLive ? `pid=${service.pid}` : `pid ${service.pid} is not live`,
|
|
1510
|
+
});
|
|
1511
|
+
if (service.workerGaveUp) {
|
|
1512
|
+
// A worker-isolated twin whose Worker thread crashed: the host process (and its pid) is
|
|
1513
|
+
// still alive, but this twin's own worker was given up on (never restarted) — surface it
|
|
1514
|
+
// explicitly rather than let it hide behind an otherwise-generic tcp-refused check below.
|
|
1515
|
+
checks.push({
|
|
1516
|
+
id: `service:${service.id}:worker`,
|
|
1517
|
+
ok: false,
|
|
1518
|
+
message: `worker exited and was not restarted (${service.workerGaveUp.detail}), at ${service.workerGaveUp.at}`,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
await waitForTcp(service.port!, 1_000);
|
|
1523
|
+
checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url! });
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
checks.push({
|
|
1526
|
+
id: `service:${service.id}:tcp`,
|
|
1527
|
+
ok: false,
|
|
1528
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
if (service.tunnel || service.publicUrl) {
|
|
1532
|
+
const tunnelLive = service.tunnel ? livePids([service.tunnel.pid]).length > 0 : false;
|
|
1533
|
+
checks.push({
|
|
1534
|
+
id: `service:${service.id}:tunnel`,
|
|
1535
|
+
ok: tunnelLive,
|
|
1536
|
+
message: service.tunnel ? `pid=${service.tunnel.pid}` : 'missing tunnel process',
|
|
1537
|
+
});
|
|
1538
|
+
checks.push({
|
|
1539
|
+
id: `service:${service.id}:public-ready`,
|
|
1540
|
+
ok: service.publicReady === true,
|
|
1541
|
+
message: service.publicUrl ?? 'missing public URL',
|
|
1542
|
+
});
|
|
1543
|
+
if (options.verifyPublic && service.publicUrl && service.publicVerification?.path) {
|
|
1544
|
+
try {
|
|
1545
|
+
const verification = await verifyPublicUrl(service.publicUrl, service.publicVerification.path, options.timeoutMs ?? 10_000);
|
|
1546
|
+
checks.push({
|
|
1547
|
+
id: `service:${service.id}:public-health`,
|
|
1548
|
+
ok: true,
|
|
1549
|
+
message: `${verification.status} ${service.publicUrl}${service.publicVerification.path}`,
|
|
1550
|
+
});
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
checks.push({
|
|
1553
|
+
id: `service:${service.id}:public-health`,
|
|
1554
|
+
ok: false,
|
|
1555
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (instance.mode === 'share') {
|
|
1563
|
+
const config = loadWorldConfig(instance.configPath, instance.root).config;
|
|
1564
|
+
const expected = config.share?.services.length ? config.share.services : [{ id: 'app' }];
|
|
1565
|
+
for (const target of expected) {
|
|
1566
|
+
const service = instance.services[target.id];
|
|
1567
|
+
checks.push({
|
|
1568
|
+
id: `share:${target.id}`,
|
|
1569
|
+
ok: service?.publicReady === true && Boolean(service.publicUrl),
|
|
1570
|
+
message: service?.publicUrl ?? 'not shared',
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return { name: instance.name, ok: checks.every((check) => check.ok), checks };
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
export function urlsWorld(name: string, root = process.cwd()): WorldUrlInfo {
|
|
1579
|
+
const instance = readWorldInstance(name, resolve(root));
|
|
1580
|
+
return {
|
|
1581
|
+
name: instance.name,
|
|
1582
|
+
mode: instance.mode,
|
|
1583
|
+
services: Object.fromEntries(Object.values(instance.services).map((service) => [
|
|
1584
|
+
service.id,
|
|
1585
|
+
{
|
|
1586
|
+
localUrl: service.url ?? '',
|
|
1587
|
+
...(service.publicUrl ? { publicUrl: service.publicUrl } : {}),
|
|
1588
|
+
...(service.publicReady !== undefined ? { publicReady: service.publicReady } : {}),
|
|
1589
|
+
},
|
|
1590
|
+
])),
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
export async function runWorld(configId: string, command: string[], options: RunWorldOptions = {}): Promise<{ instance: WorldInstance; exitCode: number }> {
|
|
1595
|
+
if (command.length === 0) throw new Error('Missing command after --');
|
|
1596
|
+
const instance = await upWorld(configId, options);
|
|
1597
|
+
let exitCode = 1;
|
|
1598
|
+
try {
|
|
1599
|
+
exitCode = runWithWorldEnv(instance.name, command, instance.root);
|
|
1600
|
+
return { instance, exitCode };
|
|
1601
|
+
} finally {
|
|
1602
|
+
if (!options.keep) await downWorld(instance.name, instance.root);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
export function listWorlds(root = process.cwd()): Array<{ name: string; running: boolean; config?: string }> {
|
|
1607
|
+
const base = worldBaseDir(resolve(root));
|
|
1608
|
+
if (!existsSync(base)) return [];
|
|
1609
|
+
return readdirSync(base, { withFileTypes: true })
|
|
1610
|
+
.filter((entry) => entry.isDirectory())
|
|
1611
|
+
.map((entry) => {
|
|
1612
|
+
try {
|
|
1613
|
+
const status = statusWorld(entry.name, root);
|
|
1614
|
+
return { name: entry.name, running: status.running, config: status.config };
|
|
1615
|
+
} catch {
|
|
1616
|
+
return { name: entry.name, running: false };
|
|
1617
|
+
}
|
|
1618
|
+
})
|
|
1619
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
export function runWithWorldEnv(name: string, command: string[], root = process.cwd()): number {
|
|
1623
|
+
if (command.length === 0) throw new Error('Missing command after --');
|
|
1624
|
+
const status = statusWorld(name, root);
|
|
1625
|
+
const sealed = status.env.VOLTER_WORLD_MODE === 'sealed';
|
|
1626
|
+
let proxyEnv: Record<string, string> = {};
|
|
1627
|
+
try {
|
|
1628
|
+
const proxy = ensureWorldProxy(name, root);
|
|
1629
|
+
if (proxy?.url) {
|
|
1630
|
+
proxyEnv = proxy.env;
|
|
1631
|
+
} else if (sealed && proxy === null) {
|
|
1632
|
+
// TWIN-64: ensureWorldProxy() returns bare `null` when openssl is unavailable — silently, by
|
|
1633
|
+
// design, for the non-sealed env-only fallback. A SEALED world running a command without the
|
|
1634
|
+
// ambient proxy is a different story: the Node injector (loaded via NODE_OPTIONS, still in
|
|
1635
|
+
// `status.env`) keeps blocking Node http/https/fetch calls, but unmodified non-Node CLIs
|
|
1636
|
+
// (curl, gh, stripe, …) have no HTTPS_PROXY to redirect them and can bypass the twins
|
|
1637
|
+
// entirely. Never let that be silent — the daemon-timeout case is already loud (see the WARN
|
|
1638
|
+
// `ensureWorldProxy` itself emits), this covers the "no openssl at all" gap.
|
|
1639
|
+
process.stderr.write(
|
|
1640
|
+
`!! WARN: world "${name}" is sealed but openssl is unavailable — NOT proxy-sealed for this command; unmodified CLIs (curl, gh, stripe, …) can bypass the twins here (Node http/https/fetch calls are still blocked via the injector).\n`,
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
} catch (error) {
|
|
1644
|
+
if (sealed) {
|
|
1645
|
+
// Never swallow a sealed world's proxy failure silently (TWIN-64): the command is about to
|
|
1646
|
+
// run NOT proxy-sealed for unmodified CLIs even though Node calls stay guarded by the
|
|
1647
|
+
// injector.
|
|
1648
|
+
process.stderr.write(
|
|
1649
|
+
`!! WARN: world "${name}" is sealed but the ambient redirect proxy failed to start (${error instanceof Error ? error.message : String(error)}) — NOT proxy-sealed for this command; unmodified CLIs (curl, gh, stripe, …) can bypass the twins here.\n`,
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
// Keep env-only redirect if the ambient proxy cannot start.
|
|
1653
|
+
}
|
|
1654
|
+
const result = spawnSync(command[0]!, command.slice(1), {
|
|
1655
|
+
cwd: resolve(root),
|
|
1656
|
+
env: { ...process.env, ...status.env, ...proxyEnv },
|
|
1657
|
+
stdio: 'inherit',
|
|
1658
|
+
});
|
|
1659
|
+
return result.status ?? 1;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/** The shell + env + cwd a `volter-world shell` subshell launches with (extracted for testability):
|
|
1663
|
+
* the world env (twin `*_URL`s + fake keys + the Node injector + cliRedirect endpoint vars) on top
|
|
1664
|
+
* of the caller's env, plus `VOLTER_WORLD` so a prompt can show which world is active. The ambient
|
|
1665
|
+
* TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
|
|
1666
|
+
* since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
|
|
1667
|
+
export function worldShellEnv(name: string, root = process.cwd()): { shell: string; cwd: string; env: Record<string, string> } {
|
|
1668
|
+
const status = statusWorld(name, root);
|
|
1669
|
+
if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
|
|
1670
|
+
return {
|
|
1671
|
+
shell: process.env.SHELL || '/bin/bash',
|
|
1672
|
+
cwd: resolve(root),
|
|
1673
|
+
env: { ...process.env, ...status.env, VOLTER_WORLD: name } as Record<string, string>,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** Drop into an interactive subshell with the world active — the contained sibling of `activate`
|
|
1678
|
+
* (which wires your CURRENT shell + sets the `(world:<name>)` prompt marker). The subshell gets the
|
|
1679
|
+
* world env AND, when openssl is available, an in-process ambient TLS redirect proxy: unmodified
|
|
1680
|
+
* `gh`/`stripe`/`curl`/app in the subshell transparently hit the twins. Vendor calls in this subshell
|
|
1681
|
+
* land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
|
|
1682
|
+
* trusted ONLY via this subshell's env — never installed system-wide. See docs/WORLD_ACTIVATE.md. */
|
|
1683
|
+
export async function shellWorld(name: string, root = process.cwd()): Promise<number> {
|
|
1684
|
+
const { shell, cwd, env } = worldShellEnv(name, root);
|
|
1685
|
+
const resolvedRoot = resolve(root);
|
|
1686
|
+
let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
|
|
1687
|
+
if (opensslAvailable()) {
|
|
1688
|
+
try {
|
|
1689
|
+
proxy = await startRedirectProxy({ env, tlsDir: tlsDir(resolvedRoot, name) });
|
|
1690
|
+
Object.assign(env, proxy.proxyEnv());
|
|
1691
|
+
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (ambient proxy ${proxy.url}). type 'exit' to leave.\n`);
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${error instanceof Error ? error.message : String(error)}); falling back to env-only redirect.\n`);
|
|
1694
|
+
}
|
|
1695
|
+
} else {
|
|
1696
|
+
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (env-only; install openssl for ambient https redirect). type 'exit' to leave.\n`);
|
|
1697
|
+
}
|
|
1698
|
+
try {
|
|
1699
|
+
const result = spawnSync(shell, ['-i'], { cwd, env, stdio: 'inherit' });
|
|
1700
|
+
return result.status ?? 0;
|
|
1701
|
+
} finally {
|
|
1702
|
+
if (proxy) {
|
|
1703
|
+
await proxy.close();
|
|
1704
|
+
tearDownCa(tlsDir(resolvedRoot, name));
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/** The daemon-poll deadline (ms) used by `ensureWorldProxy`/`ensureWorldProxyFromEnv` below. Hardcoded
|
|
1710
|
+
* to 5000ms in production; `VOLTER_PROXY_DEADLINE_MS` is a TEST-ONLY seam so tests can force a
|
|
1711
|
+
* deterministic timeout without an actual multi-second wait. Never set this env var in real usage. */
|
|
1712
|
+
function proxyDaemonDeadlineMs(): number {
|
|
1713
|
+
const raw = process.env.VOLTER_PROXY_DEADLINE_MS;
|
|
1714
|
+
if (raw === undefined) return 5000;
|
|
1715
|
+
const parsed = Number(raw);
|
|
1716
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5000;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/** Ensure a DETACHED ambient-redirect proxy daemon is running for this world (used by `activate`,
|
|
1720
|
+
* which only emits a script and can't host an in-process server for the life of the shell). Idempotent:
|
|
1721
|
+
* reuses a live daemon. Returns its proxy env, or null if openssl is missing (env-only fallback). The
|
|
1722
|
+
* daemon is recorded in `proxy.json` and stopped by `down`. */
|
|
1723
|
+
export function ensureWorldProxy(name: string, root = process.cwd()): { url: string; caCertPath: string; env: Record<string, string> } | null {
|
|
1724
|
+
const resolvedRoot = resolve(root);
|
|
1725
|
+
if (!opensslAvailable()) return null;
|
|
1726
|
+
const existing = readProxyState(resolvedRoot, name);
|
|
1727
|
+
if (existing && isAlive(existing.pid)) {
|
|
1728
|
+
return { url: existing.url, caCertPath: existing.caCertPath, env: proxyEnvFor(existing.url, existing.caCertPath) };
|
|
1729
|
+
}
|
|
1730
|
+
// Pre-create the CA synchronously so we can return its path immediately; the daemon reuses it.
|
|
1731
|
+
const ca = ensureCa(tlsDir(resolvedRoot, name));
|
|
1732
|
+
const child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', resolvedRoot], {
|
|
1733
|
+
cwd: resolvedRoot,
|
|
1734
|
+
env: process.env,
|
|
1735
|
+
detached: true,
|
|
1736
|
+
stdio: 'ignore',
|
|
1737
|
+
});
|
|
1738
|
+
child.unref();
|
|
1739
|
+
// Wait briefly for the daemon to write its state (port).
|
|
1740
|
+
const deadlineMs = proxyDaemonDeadlineMs();
|
|
1741
|
+
const deadline = Date.now() + deadlineMs;
|
|
1742
|
+
while (Date.now() < deadline) {
|
|
1743
|
+
const state = readProxyState(resolvedRoot, name);
|
|
1744
|
+
if (state && state.pid === child.pid) {
|
|
1745
|
+
return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath) };
|
|
1746
|
+
}
|
|
1747
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // 50ms synchronous backoff
|
|
1748
|
+
}
|
|
1749
|
+
// Daemon didn't report in time — fall back to env-only redirect rather than fabricate success.
|
|
1750
|
+
// LOUD, never silent: a world that appears sealed but isn't is a purpose-1 integrity hole.
|
|
1751
|
+
process.stderr.write(`!! WARN: ambient proxy daemon for world "${name}" did not report within ${deadlineMs}ms — world is NOT proxy-sealed; unmodified CLIs will bypass the twins (env-only redirect still active).\n`);
|
|
1752
|
+
return { url: '', caCertPath: ca.caCert, env: {} };
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function ensureWorldProxyFromEnv(name: string, root: string, env: Record<string, string>): { url: string; caCertPath: string; env: Record<string, string> } | null {
|
|
1756
|
+
const resolvedRoot = resolve(root);
|
|
1757
|
+
if (!opensslAvailable()) return null;
|
|
1758
|
+
writeProxyEnv(resolvedRoot, name, env);
|
|
1759
|
+
const existing = readProxyState(resolvedRoot, name);
|
|
1760
|
+
if (existing && isAlive(existing.pid)) {
|
|
1761
|
+
return { url: existing.url, caCertPath: existing.caCertPath, env: proxyEnvFor(existing.url, existing.caCertPath) };
|
|
1762
|
+
}
|
|
1763
|
+
const ca = ensureCa(tlsDir(resolvedRoot, name));
|
|
1764
|
+
const child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', resolvedRoot, '--env-file', proxyEnvFile(resolvedRoot, name)], {
|
|
1765
|
+
cwd: resolvedRoot,
|
|
1766
|
+
env: process.env,
|
|
1767
|
+
detached: true,
|
|
1768
|
+
stdio: 'ignore',
|
|
1769
|
+
});
|
|
1770
|
+
child.unref();
|
|
1771
|
+
const deadlineMs = proxyDaemonDeadlineMs();
|
|
1772
|
+
const deadline = Date.now() + deadlineMs;
|
|
1773
|
+
while (Date.now() < deadline) {
|
|
1774
|
+
const state = readProxyState(resolvedRoot, name);
|
|
1775
|
+
if (state && state.pid === child.pid) {
|
|
1776
|
+
return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath) };
|
|
1777
|
+
}
|
|
1778
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
1779
|
+
}
|
|
1780
|
+
// Daemon didn't report in time — fall back to env-only redirect rather than fabricate success.
|
|
1781
|
+
// LOUD, never silent: `upWorld` (sealed mode) turns this into a hard failure; other callers just
|
|
1782
|
+
// see the empty url and keep the env-only redirect, but the operator must be told either way.
|
|
1783
|
+
process.stderr.write(`!! WARN: ambient proxy daemon for world "${name}" did not report within ${deadlineMs}ms — world is NOT proxy-sealed; unmodified CLIs will bypass the twins (env-only redirect still active).\n`);
|
|
1784
|
+
return { url: '', caCertPath: ca.caCert, env: {} };
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function isAlive(pid: number): boolean {
|
|
1788
|
+
if (!pid) return false;
|
|
1789
|
+
try {
|
|
1790
|
+
process.kill(pid, 0);
|
|
1791
|
+
return true;
|
|
1792
|
+
} catch {
|
|
1793
|
+
return false;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function proxyDaemonEntry(): string {
|
|
1798
|
+
return new URL('./proxy-daemon.ts', import.meta.url).pathname;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** The detached proxy daemon's body (invoked as its own process by `ensureWorldProxy`). Starts the
|
|
1802
|
+
* redirect proxy bound to an ephemeral port, records its pid/url/CA in `proxy.json`, and stays up
|
|
1803
|
+
* until killed by `down`. Exported so the daemon entry file can delegate to it. */
|
|
1804
|
+
export async function runProxyDaemon(name: string, root = process.cwd(), envFile?: string): Promise<void> {
|
|
1805
|
+
const resolvedRoot = resolve(root);
|
|
1806
|
+
const envLoader = envFile
|
|
1807
|
+
? () => ({ ...process.env, ...readProxyEnv(envFile) } as Record<string, string>)
|
|
1808
|
+
: undefined;
|
|
1809
|
+
let env: Record<string, string> | undefined;
|
|
1810
|
+
if (!envLoader) {
|
|
1811
|
+
const status = statusWorld(name, resolvedRoot);
|
|
1812
|
+
if (!status.running) throw new Error(`World "${name}" is not running`);
|
|
1813
|
+
env = { ...process.env, ...status.env } as Record<string, string>;
|
|
1814
|
+
}
|
|
1815
|
+
const proxy = await startRedirectProxy({ env, envLoader, tlsDir: tlsDir(resolvedRoot, name) });
|
|
1816
|
+
const state: ProxyState = { pid: process.pid, url: proxy.url, caCertPath: proxy.caCertPath };
|
|
1817
|
+
writeFileSync(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
|
|
1818
|
+
const stop = async () => {
|
|
1819
|
+
try { await proxy.close(); } catch { /* ignore */ }
|
|
1820
|
+
try { rmSync(proxyStateFile(resolvedRoot, name), { force: true }); } catch { /* ignore */ }
|
|
1821
|
+
process.exit(0);
|
|
1822
|
+
};
|
|
1823
|
+
process.on('SIGTERM', stop);
|
|
1824
|
+
process.on('SIGINT', stop);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Stop a world's ambient-redirect proxy daemon (if any) and drop its session CA. Best-effort —
|
|
1828
|
+
* called from `downWorld` so the trusted CA never outlives the world. */
|
|
1829
|
+
function stopWorldProxy(root: string, name: string): void {
|
|
1830
|
+
const state = readProxyState(root, name);
|
|
1831
|
+
if (state && isAlive(state.pid)) {
|
|
1832
|
+
try { process.kill(state.pid, 'SIGTERM'); } catch { /* ignore */ }
|
|
1833
|
+
}
|
|
1834
|
+
try { rmSync(proxyStateFile(root, name), { force: true }); } catch { /* ignore */ }
|
|
1835
|
+
try { rmSync(proxyEnvFile(root, name), { force: true }); } catch { /* ignore */ }
|
|
1836
|
+
tearDownCa(tlsDir(root, name));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Emit a POSIX (bash/zsh) script to `eval` that ACTIVATES the world in the current shell — a
|
|
1841
|
+
* virtualenv for vendor APIs. It exports the world env (twin `*_URL`s + fake keys + the Node
|
|
1842
|
+
* injector + any `cliRedirect` endpoint vars), prepends `(world:<name>)` to the prompt so you can
|
|
1843
|
+
* always see you're pointed at twins (a safety signal, not cosmetic), and defines a `deactivate`
|
|
1844
|
+
* function that restores the prompt and unsets the vars. Usage: `eval "$(volter-world activate dev)"`.
|
|
1845
|
+
*
|
|
1846
|
+
* It ALSO starts (idempotently) the ambient TLS redirect proxy daemon for this world and exports
|
|
1847
|
+
* `HTTPS_PROXY`/`HTTP_PROXY` + the CA-trust vars (`NODE_EXTRA_CA_CERTS`, `CURL_CA_BUNDLE`, …) so an
|
|
1848
|
+
* UNMODIFIED `gh`/`stripe`/`curl`/app in the shell transparently hits the twins with zero per-tool
|
|
1849
|
+
* config (WORLD_ACTIVATE.md tier 1). The session CA is trusted ONLY via these per-shell exports,
|
|
1850
|
+
* never system-wide; `down` stops the daemon and drops the CA. If openssl is unavailable the script
|
|
1851
|
+
* still emits the env-only redirect (Phase-1 behavior) — it just skips the proxy exports.
|
|
1852
|
+
* See docs/WORLD_ACTIVATE.md.
|
|
1853
|
+
*/
|
|
1854
|
+
export function activateScript(name: string, root = process.cwd()): string {
|
|
1855
|
+
const status = statusWorld(name, root);
|
|
1856
|
+
if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
|
|
1857
|
+
const baseEnv = status.env ?? {};
|
|
1858
|
+
// Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
|
|
1859
|
+
// routes https through the twins too — not just the Node injector / cliRedirect endpoint vars.
|
|
1860
|
+
let proxyNote = '';
|
|
1861
|
+
let env: Record<string, string> = { ...baseEnv };
|
|
1862
|
+
try {
|
|
1863
|
+
const proxy = ensureWorldProxy(name, root);
|
|
1864
|
+
if (proxy && proxy.url) {
|
|
1865
|
+
env = { ...baseEnv, ...proxy.env };
|
|
1866
|
+
proxyNote = ` (ambient proxy ${proxy.url})`;
|
|
1867
|
+
}
|
|
1868
|
+
} catch {
|
|
1869
|
+
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
1870
|
+
}
|
|
1871
|
+
const keys = Object.keys(env).sort();
|
|
1872
|
+
const lines: string[] = [
|
|
1873
|
+
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|
|
1874
|
+
`if [ -n "\${_VOLTER_WORLD:-}" ]; then deactivate 2>/dev/null || true; fi`,
|
|
1875
|
+
`_VOLTER_WORLD=${shellQuote(name)}`,
|
|
1876
|
+
`_VOLTER_WORLD_OLD_PS1="\${PS1:-}"`,
|
|
1877
|
+
`deactivate () {`,
|
|
1878
|
+
...keys.map((k) => ` unset ${k}`),
|
|
1879
|
+
` PS1="\${_VOLTER_WORLD_OLD_PS1:-}"; export PS1`,
|
|
1880
|
+
` unset _VOLTER_WORLD _VOLTER_WORLD_OLD_PS1`,
|
|
1881
|
+
` unset -f deactivate`,
|
|
1882
|
+
`}`,
|
|
1883
|
+
...keys.map((k) => `export ${k}=${shellQuote(env[k]!)}`),
|
|
1884
|
+
`PS1="(world:${name}) \${PS1:-}"; export PS1`,
|
|
1885
|
+
`printf '%s\\n' "world '${name}' active — vendor calls now hit the twins${proxyNote}. run: deactivate" >&2`,
|
|
1886
|
+
];
|
|
1887
|
+
return `${lines.join('\n')}\n`;
|
|
1888
|
+
}
|