@volter/world-runtime 2.0.0 → 2.0.1
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/dist/known-external-services.json +0 -8
- package/dist/src/branch.js +79 -2
- package/dist/src/cli.js +47 -19
- package/dist/src/fixture-env.d.ts +3 -0
- package/dist/src/fixture-env.js +24 -0
- package/dist/src/infra-cli.js +59 -10
- package/dist/src/init.js +31 -5
- package/dist/src/pglite-backing.d.ts +8 -0
- package/dist/src/pglite-backing.js +118 -35
- package/dist/src/pglite-host.mjs +520 -14
- package/dist/src/redirect-proxy.d.ts +1 -1
- package/dist/src/redirect-proxy.js +2 -2
- package/dist/src/redis-backing.d.ts +8 -0
- package/dist/src/redis-backing.js +120 -0
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +24 -7
- package/known-external-services.json +0 -8
- package/package.json +9 -4
- package/src/branch.ts +66 -2
- package/src/cli.ts +40 -18
- package/src/fixture-env.ts +25 -0
- package/src/infra-cli.ts +54 -10
- package/src/init.ts +31 -5
- package/src/pglite-backing.ts +109 -36
- package/src/pglite-host.mjs +520 -14
- package/src/redirect-proxy.ts +2 -2
- package/src/redis-backing.ts +107 -0
- package/src/runtime.ts +25 -7
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The containerless backing for a World's managed REDIS: the redis twin (@volter/twin-redis) serving Redis's
|
|
2
|
+
// own protocol on the loopback port the definition declares, its keys held in the World's tree under
|
|
3
|
+
// VOLTER_WORLD_DATA. Chosen by `volter-world-infra` for every redis service unless the operator forces the
|
|
4
|
+
// container backing (VOLTER_WORLD_INFRA_BACKING=docker); the definition, the injected REDIS_URL and the declared
|
|
5
|
+
// service contract are the same either way, as the boundary comment in infra-cli.ts promises.
|
|
6
|
+
//
|
|
7
|
+
// Why the twin and not a container: a World's state branches, checkpoints and rolls back as one tree, and a Redis
|
|
8
|
+
// in a container is state outside it (a branch of the World would share the container's queue and cache). The
|
|
9
|
+
// twin keeps each key a subject of the World (packages/twin/redis/src/redis-storage.ts). It needs nothing
|
|
10
|
+
// installed: no container runtime, no redis-server binary.
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { connect } from 'node:net';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { findInstalledPackage, packCli } from "./catalog.js";
|
|
16
|
+
const PACKAGE = '@volter/twin-redis';
|
|
17
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
18
|
+
/** The redis twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
|
|
19
|
+
export function redisTwinCli(from) {
|
|
20
|
+
for (const dir of from) {
|
|
21
|
+
const found = findInstalledPackage(dir, PACKAGE);
|
|
22
|
+
const cli = found === undefined ? undefined : packCli(found);
|
|
23
|
+
if (cli !== undefined && existsSync(cli))
|
|
24
|
+
return cli;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const pidPath = (dataDir, service) => join(dataDir, `redis-twin-${service.hostPort}.pid`);
|
|
29
|
+
/** Where the service's keys live: the World's tree for the `redis` service, under the World's data. */
|
|
30
|
+
export const redisTwinRoot = (dataDir) => join(dataDir, 'redis-twin');
|
|
31
|
+
function alive(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function listening(port) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
|
|
43
|
+
// a port that answers PING with PONG is this service; anything else is not ready (or not ours)
|
|
44
|
+
socket.once('connect', () => socket.write('PING\r\n'));
|
|
45
|
+
socket.once('data', (chunk) => { socket.destroy(); resolve(chunk.toString('latin1').startsWith('+PONG')); });
|
|
46
|
+
socket.on('error', () => resolve(false));
|
|
47
|
+
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
+
export async function redisTwinUp(services, dataDir, cli) {
|
|
52
|
+
for (const service of services) {
|
|
53
|
+
const pidFile = pidPath(dataDir, service);
|
|
54
|
+
if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
|
|
55
|
+
continue;
|
|
56
|
+
// a port that already answers is another process's (another World's twin, a redis-server): serving there would
|
|
57
|
+
// hand this World's app someone else's keys, so it is refused, never taken as ready
|
|
58
|
+
if (await listening(service.hostPort))
|
|
59
|
+
throw new Error(`port ${service.hostPort} already serves Redis for another process`);
|
|
60
|
+
const root = redisTwinRoot(dataDir);
|
|
61
|
+
mkdirSync(root, { recursive: true });
|
|
62
|
+
const log = join(dataDir, `redis-twin-${service.hostPort}.log`);
|
|
63
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
64
|
+
// a real fd, not a pipe: the server outlives this process untethered
|
|
65
|
+
const logFd = openSync(log, 'a');
|
|
66
|
+
// the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
|
|
67
|
+
// type transformation, not only stripping (the kernel declares constructor parameter properties)
|
|
68
|
+
const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
|
|
69
|
+
const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: ['ignore', logFd, logFd],
|
|
72
|
+
});
|
|
73
|
+
child.unref();
|
|
74
|
+
closeSync(logFd);
|
|
75
|
+
writeFileSync(pidFile, `${child.pid}\n`);
|
|
76
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
77
|
+
while (!(await listening(service.hostPort)) || !alive(child.pid)) {
|
|
78
|
+
if (!alive(child.pid))
|
|
79
|
+
throw new Error(`redis exited before serving — see ${log}`);
|
|
80
|
+
if (Date.now() > deadline)
|
|
81
|
+
throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
82
|
+
await sleep(200);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function redisTwinStatus(services, dataDir) {
|
|
87
|
+
let ready = 0;
|
|
88
|
+
for (const service of services) {
|
|
89
|
+
const pidFile = pidPath(dataDir, service);
|
|
90
|
+
if (!existsSync(pidFile))
|
|
91
|
+
continue;
|
|
92
|
+
if (alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
|
|
93
|
+
ready += 1;
|
|
94
|
+
}
|
|
95
|
+
return ready;
|
|
96
|
+
}
|
|
97
|
+
export async function redisTwinDown(services, dataDir) {
|
|
98
|
+
for (const service of services) {
|
|
99
|
+
const pidFile = pidPath(dataDir, service);
|
|
100
|
+
if (!existsSync(pidFile))
|
|
101
|
+
continue;
|
|
102
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
103
|
+
if (alive(pid)) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 'SIGTERM');
|
|
106
|
+
}
|
|
107
|
+
catch { /* already gone */ }
|
|
108
|
+
const deadline = Date.now() + 5000;
|
|
109
|
+
while (alive(pid) && Date.now() < deadline)
|
|
110
|
+
await sleep(100);
|
|
111
|
+
if (alive(pid)) {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(pid, 'SIGKILL');
|
|
114
|
+
}
|
|
115
|
+
catch { /* raced */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
rmSync(pidFile, { force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -122,6 +122,7 @@ export declare function pruneWorlds(options?: {
|
|
|
122
122
|
entries: WorldCleanupEntry[];
|
|
123
123
|
};
|
|
124
124
|
export declare function parseCloudflareQuickPublicUrl(candidate: string): string | undefined;
|
|
125
|
+
export declare function ownCommand(command: string[]): string[];
|
|
125
126
|
export declare function shareWorld(name: string, options?: ShareWorldOptions): Promise<WorldInstance>;
|
|
126
127
|
export declare function shareWorldServices(name: string, options?: ShareWorldServicesOptions): Promise<WorldInstance>;
|
|
127
128
|
/** TWIN-62: `unshare` must give tunnels the same contract plain `downWorld` already gives owned
|
package/dist/src/runtime.js
CHANGED
|
@@ -17,7 +17,7 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
17
17
|
import { packFacts } from "./pack-facts.js";
|
|
18
18
|
import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
|
|
19
19
|
import { Socket } from 'node:net';
|
|
20
|
-
import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
|
|
20
|
+
import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
|
|
21
21
|
import { loadWorldConfig } from "./configs.js";
|
|
22
22
|
import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
23
23
|
import { resolveServicePackage } from "./catalog.js";
|
|
@@ -762,7 +762,7 @@ function getByJsonPath(value, jsonPath) {
|
|
|
762
762
|
return current;
|
|
763
763
|
}
|
|
764
764
|
async function runExternalCommand(command, cwd, serviceId, phase, env = process.env, abort, timeoutMs = 60_000) {
|
|
765
|
-
const [bin, ...args] = command;
|
|
765
|
+
const [bin, ...args] = ownCommand(command);
|
|
766
766
|
if (!bin)
|
|
767
767
|
throw new Error(`External service "${serviceId}": ${phase} command is empty`);
|
|
768
768
|
if (!commandExists(bin))
|
|
@@ -803,7 +803,7 @@ async function runExternalCommand(command, cwd, serviceId, phase, env = process.
|
|
|
803
803
|
* (instead of buffering through spawnSync's 1MB cap, which a large DB restore would overflow). Returns
|
|
804
804
|
* the combined output so `discover` can read it. Throws with a bounded tail on nonzero exit. */
|
|
805
805
|
async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
|
|
806
|
-
const [bin, ...args] = command;
|
|
806
|
+
const [bin, ...args] = ownCommand(command);
|
|
807
807
|
if (!bin)
|
|
808
808
|
throw new Error(`External service "${serviceId}": up command is empty`);
|
|
809
809
|
if (!commandExists(bin)) {
|
|
@@ -1453,7 +1453,8 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1453
1453
|
VOLTER_WORLD_MODE: mode,
|
|
1454
1454
|
VOLTER_WORLD_CONFIG: loaded.path,
|
|
1455
1455
|
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
1456
|
-
|
|
1456
|
+
// every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
|
|
1457
|
+
[WORLD_DATA_ENV]: data,
|
|
1457
1458
|
// the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
|
|
1458
1459
|
VOLTER_WORLD_ENV_FILE: envFile,
|
|
1459
1460
|
// THE WORLD CLOCK: every service stamps from this file via the kernel's worldNow();
|
|
@@ -2220,8 +2221,23 @@ function sleep(ms) {
|
|
|
2220
2221
|
}
|
|
2221
2222
|
function commandExists(command) {
|
|
2222
2223
|
// Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
|
|
2223
|
-
|
|
2224
|
-
|
|
2224
|
+
return OWN_COMMANDS[command] !== undefined || onPath(command);
|
|
2225
|
+
}
|
|
2226
|
+
/** A lifecycle command naming this runtime's own bin (`volter-world-infra`, which `init` names in every managed
|
|
2227
|
+
* infrastructure's lifecycle) runs this runtime's own script with the executable running it, when nothing on PATH
|
|
2228
|
+
* answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
|
|
2229
|
+
* PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
|
|
2230
|
+
* command on PATH still wins, as before. */
|
|
2231
|
+
const OWN_COMMANDS = { 'volter-world-infra': 'infra-cli' };
|
|
2232
|
+
export function ownCommand(command) {
|
|
2233
|
+
const [bin, ...args] = command;
|
|
2234
|
+
const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
|
|
2235
|
+
if (stem === undefined || onPath(bin))
|
|
2236
|
+
return command;
|
|
2237
|
+
return [process.execPath, siblingScript(import.meta.url, stem), ...args];
|
|
2238
|
+
}
|
|
2239
|
+
function onPath(command) {
|
|
2240
|
+
return spawnSync('which', [command], { stdio: 'ignore', env: process.env }).status === 0;
|
|
2225
2241
|
}
|
|
2226
2242
|
function resolveWithPublicDns(hostname) {
|
|
2227
2243
|
const result = spawnSync('dig', ['+short', '@1.1.1.1', hostname, 'A'], {
|
|
@@ -2523,7 +2539,8 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
|
|
|
2523
2539
|
if (!commandExists(ready.command)) {
|
|
2524
2540
|
return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
|
|
2525
2541
|
}
|
|
2526
|
-
const
|
|
2542
|
+
const [probeBin, ...probeArgs] = ownCommand([ready.command, ...(ready.args ?? [])]);
|
|
2543
|
+
const result = spawnSync(probeBin, probeArgs, {
|
|
2527
2544
|
cwd, encoding: 'utf8', env, timeout: DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
2528
2545
|
});
|
|
2529
2546
|
if (result.status === 0)
|
|
@@ -373,10 +373,6 @@
|
|
|
373
373
|
"disposition": "demanded",
|
|
374
374
|
"reason": "vendor google-play (Play Developer API), ×1 — ladder classification 2026-09-02"
|
|
375
375
|
},
|
|
376
|
-
"@googleapis/calendar": {
|
|
377
|
-
"disposition": "demanded",
|
|
378
|
-
"reason": "vendor google-calendar, ×1 (plus the GOOGLE_CALENDAR_API_KEY stem, ×1) — ladder classification 2026-09-02"
|
|
379
|
-
},
|
|
380
376
|
"@googleapis/sheets": {
|
|
381
377
|
"disposition": "demanded",
|
|
382
378
|
"reason": "vendor google-sheets, ×1 — ladder classification 2026-09-02"
|
|
@@ -863,10 +859,6 @@
|
|
|
863
859
|
"disposition": "demanded",
|
|
864
860
|
"reason": "vendor ghost (VITE_PUBLIC_GHOST_CONTENT_API_KEY — the browser-side Content API key), ×1 — ladder classification 2026-09-02"
|
|
865
861
|
},
|
|
866
|
-
"googlecalendar": {
|
|
867
|
-
"disposition": "demanded",
|
|
868
|
-
"reason": "vendor google-calendar (GOOGLE_CALENDAR_API_KEY; the @googleapis/calendar client is the same demand), ×1 — ladder classification 2026-09-02"
|
|
869
|
-
},
|
|
870
862
|
"googlecloud": {
|
|
871
863
|
"disposition": "demanded",
|
|
872
864
|
"reason": "vendor googlecloud (GOOGLE_CLOUD_API_KEY; the roster's one consumer spends it on the Web Fonts Developer API at www.googleapis.com/webfonts/v1, so the stem is a Google Cloud API key rather than a googleoauth credential), ×1 — ladder classification 2026-09-02"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.2.20",
|
|
61
61
|
"@types/node": "^24.0.0",
|
|
62
|
+
"mongodb": "~6.20.0",
|
|
62
63
|
"pg": "8.16.3",
|
|
63
64
|
"typescript": "^5.9.0"
|
|
64
65
|
},
|
|
@@ -66,12 +67,13 @@
|
|
|
66
67
|
"node": ">=22.3"
|
|
67
68
|
},
|
|
68
69
|
"peerDependencies": {
|
|
69
|
-
"@volter/world-core": "2.0.
|
|
70
|
-
"@volter/world-console": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.1",
|
|
71
|
+
"@volter/world-console": "2.0.1"
|
|
71
72
|
},
|
|
72
73
|
"dependencies": {
|
|
73
74
|
"@electric-sql/pglite": "0.5.8",
|
|
74
|
-
"@
|
|
75
|
+
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
|
+
"@volter/world-core": "2.0.1",
|
|
75
77
|
"pg-gateway": "0.3.0-beta.4",
|
|
76
78
|
"smol-toml": "^1.8.0"
|
|
77
79
|
},
|
|
@@ -79,5 +81,8 @@
|
|
|
79
81
|
"@volter/world-console": {
|
|
80
82
|
"optional": true
|
|
81
83
|
}
|
|
84
|
+
},
|
|
85
|
+
"optionalDependencies": {
|
|
86
|
+
"@volter/twin-mongodb": "0.1.1"
|
|
82
87
|
}
|
|
83
88
|
}
|
package/src/branch.ts
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
// log of changes, and a world is a branch with compute attached (docs/concepts/the-model.md).
|
|
3
3
|
// `branch <world> <name>` boots a new world from the base's config and forks every twin's mirror
|
|
4
4
|
// into it (core's `forkTwin`: a POINTER to the base at its current position — the base's whole
|
|
5
|
-
// history, its own entries included, is the branch's parent; nothing is copied).
|
|
5
|
+
// history, its own entries included, is the branch's parent; nothing is copied). A managed database (containerless
|
|
6
|
+
// Postgres) keeps no history, so it is the one thing copied: the stopped base's data, as of now. Two branches of one
|
|
7
|
+
// base running at once share the database port its definition names; run one at a time (the SDK does). `checkout <name>`
|
|
6
8
|
// brings a stopped world back up from its own instance record, state intact.
|
|
7
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
8
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
9
12
|
import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withAncestryLock, worldPaths, worldStateRoot } from '@volter/world-core';
|
|
10
13
|
import { downWorld, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
|
|
11
14
|
import type { WorldInstance } from './schema.ts';
|
|
15
|
+
import { parseInfraDefinition, pgliteDown, pgliteUp } from './pglite-backing.ts';
|
|
16
|
+
import { redisTwinCli, redisTwinDown, redisTwinUp } from './redis-backing.ts';
|
|
12
17
|
|
|
13
18
|
export type BranchOptions = { root?: string; envFile?: string; now?: Date; /** branch from a point in the base's history: an instant, or a position per twin (contract "Just like Neon", 3) */ at?: { instant?: string; positions?: Record<string, number>; views?: Record<string, string> } };
|
|
14
19
|
|
|
@@ -37,6 +42,12 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
|
|
|
37
42
|
return { state, view, position, generation: stateGeneration(worldPaths(state, fromRoot).dir) };
|
|
38
43
|
}) };
|
|
39
44
|
}));
|
|
45
|
+
// the base's managed databases (checked before the branch boots: its database would share the base's port)
|
|
46
|
+
const databases = managedDatabases(from);
|
|
47
|
+
if (databases.length && (options.at?.instant || options.at?.positions || options.at?.views)) {
|
|
48
|
+
throw new Error(`volter-world branch: ${base} has a managed database, which keeps no history to branch from at an instant or position; branch from its current state`);
|
|
49
|
+
}
|
|
50
|
+
refuseLiveDatabases(from, databases);
|
|
40
51
|
const instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
|
|
41
52
|
const forked: Record<string, string[]> = {};
|
|
42
53
|
try {
|
|
@@ -48,6 +59,7 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
|
|
|
48
59
|
}
|
|
49
60
|
if (states.length) forked[service] = states.map(s => s.state);
|
|
50
61
|
}
|
|
62
|
+
await branchManagedDatabases(from, instance, databases);
|
|
51
63
|
} catch (error) {
|
|
52
64
|
await downWorld(name, root, { purge: true, expectedCreatedAt: instance.createdAt });
|
|
53
65
|
throw error;
|
|
@@ -55,6 +67,58 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
|
|
|
55
67
|
return { instance, forked };
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/** The base's containerless managed databases: each PGlite `pglite-<kind>-data` and the MongoDB twin's
|
|
71
|
+
* `twin-mongodb-data` (pglite-backing.ts), and the redis twin's `redis-twin` tree (redis-backing.ts). A stopped World's
|
|
72
|
+
* hosts leave no pid file; a docker-backed World keeps its data under the container's volume, which a branch does not
|
|
73
|
+
* carry. */
|
|
74
|
+
function managedDatabases(from: WorldInstance): string[] {
|
|
75
|
+
const data = from.dirs.data;
|
|
76
|
+
if (!existsSync(data)) return [];
|
|
77
|
+
return readdirSync(data).filter((n) => /^pglite-[a-z0-9_-]+-data$/.test(n) || n === 'twin-mongodb-data' || n === 'redis-twin');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The pid files of the hosts serving one managed database directory. */
|
|
81
|
+
function hostPidFiles(data: string, db: string): string[] {
|
|
82
|
+
if (db === 'redis-twin') return readdirSync(data).filter((n) => /^redis-twin-\d+\.pid$/.test(n)).map((n) => join(data, n));
|
|
83
|
+
return [join(data, `${db.slice(0, -'-data'.length)}.pid`)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A copy of a live database directory is not a consistent one, and the branch's database would answer on the base's
|
|
87
|
+
* port: the base must be stopped first (`volter world branch` stops it, as a branch replaces its base). */
|
|
88
|
+
function refuseLiveDatabases(from: WorldInstance, dbs: string[]): void {
|
|
89
|
+
for (const db of dbs) {
|
|
90
|
+
for (const pidFile of hostPidFiles(from.dirs.data, db)) {
|
|
91
|
+
const pid = existsSync(pidFile) ? Number(readFileSync(pidFile, 'utf8').trim()) : NaN;
|
|
92
|
+
let live = false;
|
|
93
|
+
if (Number.isInteger(pid) && pid > 0) { try { process.kill(pid, 0); live = true; } catch { live = false; } }
|
|
94
|
+
if (live) throw new Error(`volter-world branch: ${from.name}'s managed ${db} is running (pid ${pid}); stop ${from.name} first so its database can be copied into the branch consistently`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The branch's managed databases as the base holds them: the branch's own, just booted empty by `up`, are stopped,
|
|
100
|
+
* replaced by a copy of the base's data, and started again, so a branch of a migrated and seeded World serves that
|
|
101
|
+
* data, as its twins serve the base's history. */
|
|
102
|
+
async function branchManagedDatabases(from: WorldInstance, instance: WorldInstance, dbs: string[]): Promise<void> {
|
|
103
|
+
if (!dbs.length) return;
|
|
104
|
+
const definition = join(dirname(instance.configPath), 'world.infrastructure.yml');
|
|
105
|
+
if (!existsSync(definition)) return;
|
|
106
|
+
const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
107
|
+
const redis = services.filter((s) => s.kind === 'redis');
|
|
108
|
+
const hosted = services.filter((s) => s.kind !== 'redis');
|
|
109
|
+
const redisCli = dbs.includes('redis-twin') && redis.length ? redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(instance.configPath)]) : undefined;
|
|
110
|
+
await pgliteDown(hosted, instance.dirs.data);
|
|
111
|
+
if (redisCli) await redisTwinDown(redis, instance.dirs.data);
|
|
112
|
+
for (const db of dbs) {
|
|
113
|
+
if (db === 'redis-twin' && !redisCli) continue;
|
|
114
|
+
const to = join(instance.dirs.data, db);
|
|
115
|
+
rmSync(to, { recursive: true, force: true });
|
|
116
|
+
cpSync(join(from.dirs.data, db), to, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
if (dbs.some((db) => db !== 'redis-twin')) await pgliteUp(hosted, instance.dirs.data);
|
|
119
|
+
if (redisCli) await redisTwinUp(redis, instance.dirs.data, redisCli);
|
|
120
|
+
}
|
|
121
|
+
|
|
58
122
|
/** A stopped world back up, from its own record. */
|
|
59
123
|
export async function checkoutWorld(name: string, options: { root?: string } = {}): Promise<WorldInstance> {
|
|
60
124
|
const root = resolve(options.root ?? process.cwd());
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { sessionTrustEnv } from './ca-trust.ts';
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { dirname, join, resolve, resolve as resolvePath } from 'node:path';
|
|
8
8
|
import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, upWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from './index.ts';
|
|
@@ -518,39 +518,61 @@ async function main(): Promise<void> {
|
|
|
518
518
|
}
|
|
519
519
|
|
|
520
520
|
if (cmd === 'clock') {
|
|
521
|
-
// THE operator door for world time (physics): show / set <iso> / advance <duration
|
|
522
|
-
// The clock
|
|
523
|
-
//
|
|
524
|
-
//
|
|
521
|
+
// THE operator door for world time (physics): show / set <iso> / advance <duration> / shift <duration> / clear.
|
|
522
|
+
// The clock every twin reads per request (kernel worldNow()), and every application process through the injector
|
|
523
|
+
// (inject.cjs, WORLD TIME); a change takes effect live, no restarts. Its two forms (world-clock.cjs): `set` freezes
|
|
524
|
+
// time at an instant (scripted time: a life, a seed); `shift` moves a World forward while its time keeps running (a
|
|
525
|
+
// World serving an application, whose time a frozen instant would stop); `advance` moves either form; `clear`
|
|
526
|
+
// returns the World to the machine's time. `advance` refuses a World on the machine's time, which it would freeze.
|
|
525
527
|
// argv shape here: cmd='clock', subject=<world>, rest=[action, value?, flags...]
|
|
528
|
+
const clockForm = createRequire(import.meta.url)('@volter/world-core/world-clock') as typeof import('@volter/world-core/world-clock');
|
|
526
529
|
const name = subject;
|
|
527
530
|
const [action, value] = rest;
|
|
528
|
-
if (!name || !action ||
|
|
529
|
-
console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> [--root <repo>]');
|
|
531
|
+
if (!name || !action || !['show', 'set', 'advance', 'shift', 'clear'].includes(action)) {
|
|
532
|
+
console.error('usage: volter-world clock <world> show | set <iso-8601> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear [--root <repo>]');
|
|
530
533
|
process.exit(2);
|
|
531
534
|
}
|
|
532
535
|
const rootDir = resolve(optionValue(rest, '--root') ?? process.cwd());
|
|
533
536
|
const file = clockFile(rootDir, name);
|
|
537
|
+
const current = existsSync(file) ? clockForm.parseClock(readFileSync(file, 'utf8')) : null;
|
|
538
|
+
const write = (clock: Parameters<typeof clockForm.formatClock>[0]) => {
|
|
539
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
540
|
+
writeFileSync(file, `${clockForm.formatClock(clock)}\n`);
|
|
541
|
+
// the World's time now, bare, as scripts read it (`show` says which form)
|
|
542
|
+
console.log(new Date(clockForm.clockNowMs(clock, Date.now())).toISOString());
|
|
543
|
+
};
|
|
534
544
|
if (action === 'show') {
|
|
535
|
-
|
|
545
|
+
if (!current) console.log(`${new Date().toISOString()} (wall clock — no world clock set)`);
|
|
546
|
+
else if (current.kind === 'frozen') console.log(`${new Date(current.at).toISOString()} (frozen)`);
|
|
547
|
+
else {
|
|
548
|
+
const ahead = current.at - current.since;
|
|
549
|
+
console.log(`${new Date(clockForm.clockNowMs(current, Date.now())).toISOString()} (running, ${ahead >= 0 ? '+' : '-'}${Math.abs(ahead) / 1000}s from the machine's time)`);
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (action === 'clear') {
|
|
554
|
+
if (existsSync(file)) rmSync(file);
|
|
555
|
+
console.log(`${new Date().toISOString()} (wall clock — no world clock set)`);
|
|
536
556
|
return;
|
|
537
557
|
}
|
|
538
558
|
if (action === 'set') {
|
|
539
559
|
const parsed = Date.parse(value ?? '');
|
|
540
560
|
if (Number.isNaN(parsed)) { console.error(`clock set: ${JSON.stringify(value)} is not an ISO-8601 instant`); process.exit(2); }
|
|
541
|
-
|
|
542
|
-
writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
|
|
543
|
-
console.log(new Date(parsed).toISOString());
|
|
561
|
+
write({ kind: 'frozen', at: parsed });
|
|
544
562
|
return;
|
|
545
563
|
}
|
|
546
564
|
const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec((value ?? '').trim());
|
|
547
|
-
if (!m) { console.error(`clock
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
565
|
+
if (!m) { console.error(`clock ${action}: ${JSON.stringify(value)} is not <N>(s|m|h|d)`); process.exit(2); }
|
|
566
|
+
const by = Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as 's' | 'm' | 'h' | 'd'];
|
|
567
|
+
if (action === 'advance') {
|
|
568
|
+
if (!current) { console.error('clock advance: no world clock is set (advance from wall-clock would freeze time as a side effect) — `clock set <iso>` first, or `clock shift` to move a running World'); process.exit(2); }
|
|
569
|
+
write({ ...current, at: current.at + by });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// shift: a World on the machine's time, or already running, moves forward and keeps running
|
|
573
|
+
if (current?.kind === 'frozen') { console.error('clock shift: this World\'s clock is frozen — `clock advance` moves it, or `clock clear` first'); process.exit(2); }
|
|
574
|
+
const wall = Date.now();
|
|
575
|
+
write(current ? { ...current, at: current.at + by } : { kind: 'running', at: wall + by, since: wall });
|
|
554
576
|
return;
|
|
555
577
|
}
|
|
556
578
|
|
package/src/fixture-env.ts
CHANGED
|
@@ -152,6 +152,11 @@ export function isGoogleServiceAccountEnvName(name: string): boolean {
|
|
|
152
152
|
* world-runtime must not import a pack, and the tests pin every value against the injector's actual
|
|
153
153
|
* VENDOR_HOSTS predicate so either side changing makes the contract fail loudly. */
|
|
154
154
|
const ENDPOINT_SHAPES: Array<{ match: RegExp; value: string; source: string }> = [
|
|
155
|
+
// the vendor's own API URL, which the World routes to its twin: an app that reads a base-URL override (empty in its
|
|
156
|
+
// example, the vendor's URL as its default) gets the vendor's URL, never an unreachable fake
|
|
157
|
+
{ match: /^DYNADOT_BASE_URL$/, value: 'https://api.dynadot.com/api3.json', source: 'packages/twin/dynadot/src/index.ts hosts (api.dynadot.com); dubinc/dub apps/web/lib/dynadot/client.ts default' },
|
|
158
|
+
// Vercel KV's REST URL (the Upstash Redis REST API under Vercel's names): an upstash host the twin serves
|
|
159
|
+
{ match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'packages/world-core inject VENDOR_HOSTS.upstash; lukevella/rallly KV_REST_API_URL' },
|
|
155
160
|
{
|
|
156
161
|
match: /(^|_)UPSTASH_REDIS_REST_URL$/,
|
|
157
162
|
value: 'https://twin-fake.upstash.io',
|
|
@@ -200,6 +205,14 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
200
205
|
// (`.clerk.accounts.dev`, packages/twin/clerk/src/index.ts hosts), so the boundary — the injector
|
|
201
206
|
// server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
|
|
202
207
|
// allowlists that host stays legal (peak drive 2026-08-26).
|
|
208
|
+
// Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
|
|
209
|
+
// refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
|
|
210
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
|
|
211
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
|
|
212
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
|
|
213
|
+
// tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
|
|
214
|
+
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
|
|
215
|
+
{ match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
|
|
203
216
|
{ match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'packages/twin/clerk/src/clerk-twin.ts' },
|
|
204
217
|
{ match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'packages/twin/clerk/src/index.ts' },
|
|
205
218
|
];
|
|
@@ -211,8 +224,20 @@ const APP_KEY_SHAPES: Array<{ match: RegExp; value: () => string; source: string
|
|
|
211
224
|
// AES-256-GCM: the key base64-decodes to 32 bytes ("ENCRYPTION_KEY must be 32 bytes (base64-encoded)"); the 32 bytes
|
|
212
225
|
// spell out that they are fake
|
|
213
226
|
{ match: /^ENCRYPTION_KEY$/, value: () => Buffer.from('twin-fake-encryption-key-32bytes').toString('base64'), source: "dubinc/dub apps/web/lib/encryption.ts" },
|
|
227
|
+
// AES-256-CBC as hex: the key is 32 bytes (64 hex characters) and the IV 16 (32); LibreChat refuses to boot on any
|
|
228
|
+
// other shape; the bytes spell out that they are fake
|
|
229
|
+
// AES-256 over the key's latin1 bytes: exactly 32 characters (Cal.com's symmetricEncrypt)
|
|
230
|
+
{ match: /^CALENDSO_ENCRYPTION_KEY$/, value: () => 'twin-fake-calendso-key-32-chars!', source: 'calcom/cal.diy packages/lib/crypto.ts' },
|
|
231
|
+
{ match: /^CREDS_KEY$/, value: () => Buffer.from('twin-fake-creds-key-of-32-bytes!').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
|
|
232
|
+
{ match: /^CREDS_IV$/, value: () => Buffer.from('twin-fake-iv-16b').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
|
|
214
233
|
];
|
|
215
234
|
|
|
235
|
+
/** Whether `name` is a key an application parses (APP_KEY_SHAPES): a secret init fakes in its shape, whatever the
|
|
236
|
+
* example leaves it (LibreChat's CREDS_IV is empty in its example and not credential-shaped by name). */
|
|
237
|
+
export function isAppKeyName(name: string): boolean {
|
|
238
|
+
return APP_KEY_SHAPES.some((shape) => shape.match.test(name));
|
|
239
|
+
}
|
|
240
|
+
|
|
216
241
|
export function fakeEnvValue(name: string): string {
|
|
217
242
|
if (isGoogleOAuthClientEnvName(name)) return fakeGoogleOAuthClientJson();
|
|
218
243
|
for (const shape of APP_KEY_SHAPES) if (shape.match.test(name)) return shape.value();
|
package/src/infra-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
|
|
8
9
|
|
|
9
10
|
const phase = process.argv[2];
|
|
@@ -48,8 +49,9 @@ const fail = (result: ReturnType<typeof run>): never => {
|
|
|
48
49
|
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
49
50
|
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
50
51
|
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
51
|
-
// 3. no container runtime + a postgres
|
|
52
|
-
// (pglite-
|
|
52
|
+
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
53
|
+
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
54
|
+
// MongoDB twin for mongodb), announced loudly;
|
|
53
55
|
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
54
56
|
function selectBacking(): 'docker' | 'pglite' {
|
|
55
57
|
const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
|
|
@@ -71,7 +73,7 @@ function selectBacking(): 'docker' | 'pglite' {
|
|
|
71
73
|
|
|
72
74
|
async function runPglite(): Promise<never> {
|
|
73
75
|
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
|
|
74
|
-
const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
76
|
+
const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
|
|
75
77
|
if (services.length === 0) {
|
|
76
78
|
process.stderr.write('managed infrastructure: the declared definition names no services\n');
|
|
77
79
|
process.exit(1);
|
|
@@ -82,7 +84,7 @@ async function runPglite(): Promise<never> {
|
|
|
82
84
|
process.exit(1);
|
|
83
85
|
}
|
|
84
86
|
if (phase === 'up') {
|
|
85
|
-
process.stdout.write('managed infrastructure backing:
|
|
87
|
+
process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
|
|
86
88
|
try {
|
|
87
89
|
await pgliteUp(services, worldData!);
|
|
88
90
|
} catch (error) {
|
|
@@ -98,7 +100,7 @@ async function runPglite(): Promise<never> {
|
|
|
98
100
|
process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
99
101
|
process.exit(1);
|
|
100
102
|
}
|
|
101
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services:
|
|
103
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
102
104
|
process.exit(0);
|
|
103
105
|
}
|
|
104
106
|
await pgliteDown(services, worldData!);
|
|
@@ -106,12 +108,54 @@ async function runPglite(): Promise<never> {
|
|
|
106
108
|
process.exit(0);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// ---- redis: the twin, containerless ----------------------------------------
|
|
112
|
+
// A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
|
|
113
|
+
// unless the operator forces the container backing; the other services take the backing chosen above. Where the
|
|
114
|
+
// twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
|
|
115
|
+
// the container where there is one, else the backing's refusal naming redis.
|
|
116
|
+
const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
117
|
+
const { redisTwinCli } = await import('./redis-backing.ts');
|
|
118
|
+
const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
|
|
119
|
+
const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
|
|
120
|
+
/** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
|
|
121
|
+
const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
|
|
122
|
+
if (twinned.length > 0) await runRedisTwin();
|
|
123
|
+
|
|
124
|
+
async function runRedisTwin(): Promise<void> {
|
|
125
|
+
const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
|
|
126
|
+
const cli = twinCli!;
|
|
127
|
+
if (phase === 'up') {
|
|
128
|
+
process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
|
|
129
|
+
try {
|
|
130
|
+
await redisTwinUp(twinned, worldData!, cli);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
} else if (phase === 'status') {
|
|
136
|
+
const ready = await redisTwinStatus(twinned, worldData!);
|
|
137
|
+
if (ready !== twinned.length) {
|
|
138
|
+
process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
await redisTwinDown(twinned, worldData!);
|
|
143
|
+
}
|
|
144
|
+
if (composed!.length === 0) {
|
|
145
|
+
if (phase === 'up') process.stdout.write('managed infrastructure ready\n');
|
|
146
|
+
else if (phase === 'status') process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
147
|
+
else process.stdout.write('managed infrastructure stopped\n');
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
109
152
|
if (selectBacking() === 'pglite') {
|
|
110
153
|
await runPglite();
|
|
111
154
|
}
|
|
112
155
|
|
|
156
|
+
const only = composed ?? [];
|
|
113
157
|
if (phase === 'up') {
|
|
114
|
-
const result = run(['up', '-d', '--wait']);
|
|
158
|
+
const result = run(['up', '-d', '--wait', ...only]);
|
|
115
159
|
if (result.status !== 0) fail(result);
|
|
116
160
|
process.stdout.write('managed infrastructure ready\n');
|
|
117
161
|
} else if (phase === 'status') {
|
|
@@ -119,13 +163,13 @@ if (phase === 'up') {
|
|
|
119
163
|
if (expected.status !== 0) fail(expected);
|
|
120
164
|
const running = run(['ps', '--status', 'running', '--services']);
|
|
121
165
|
if (running.status !== 0) fail(running);
|
|
122
|
-
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
123
|
-
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
166
|
+
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
167
|
+
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
124
168
|
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
125
|
-
process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
|
|
169
|
+
process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
126
170
|
process.exit(1);
|
|
127
171
|
}
|
|
128
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length, connections: infraConnections(
|
|
172
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
129
173
|
} else {
|
|
130
174
|
const result = run(['down', '--remove-orphans']);
|
|
131
175
|
if (result.status !== 0) fail(result);
|