@pleri/olam-cli 0.1.7
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/__tests__/auth-status.test.d.ts +2 -0
- package/dist/__tests__/auth-status.test.d.ts.map +1 -0
- package/dist/__tests__/auth-status.test.js +290 -0
- package/dist/__tests__/auth-status.test.js.map +1 -0
- package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
- package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/auth-upgrade.test.js +161 -0
- package/dist/__tests__/auth-upgrade.test.js.map +1 -0
- package/dist/__tests__/create-app-urls.test.d.ts +2 -0
- package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-urls.test.js +102 -0
- package/dist/__tests__/create-app-urls.test.js.map +1 -0
- package/dist/__tests__/enter.test.d.ts +2 -0
- package/dist/__tests__/enter.test.d.ts.map +1 -0
- package/dist/__tests__/enter.test.js +90 -0
- package/dist/__tests__/enter.test.js.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.js +119 -0
- package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
- package/dist/__tests__/host-cp.test.d.ts +9 -0
- package/dist/__tests__/host-cp.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp.test.js +254 -0
- package/dist/__tests__/host-cp.test.js.map +1 -0
- package/dist/__tests__/keys.test.d.ts +9 -0
- package/dist/__tests__/keys.test.d.ts.map +1 -0
- package/dist/__tests__/keys.test.js +145 -0
- package/dist/__tests__/keys.test.js.map +1 -0
- package/dist/__tests__/logs.test.d.ts +9 -0
- package/dist/__tests__/logs.test.d.ts.map +1 -0
- package/dist/__tests__/logs.test.js +124 -0
- package/dist/__tests__/logs.test.js.map +1 -0
- package/dist/__tests__/ps.test.d.ts +2 -0
- package/dist/__tests__/ps.test.d.ts.map +1 -0
- package/dist/__tests__/ps.test.js +172 -0
- package/dist/__tests__/ps.test.js.map +1 -0
- package/dist/__tests__/status-app-urls.test.d.ts +2 -0
- package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/status-app-urls.test.js +125 -0
- package/dist/__tests__/status-app-urls.test.js.map +1 -0
- package/dist/__tests__/upgrade.test.d.ts +9 -0
- package/dist/__tests__/upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade.test.js +262 -0
- package/dist/__tests__/upgrade.test.js.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
- package/dist/commands/__tests__/refresh.test.d.ts +13 -0
- package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
- package/dist/commands/__tests__/refresh.test.js +170 -0
- package/dist/commands/__tests__/refresh.test.js.map +1 -0
- package/dist/commands/auth-status.d.ts +43 -0
- package/dist/commands/auth-status.d.ts.map +1 -0
- package/dist/commands/auth-status.js +208 -0
- package/dist/commands/auth-status.js.map +1 -0
- package/dist/commands/auth-upgrade.d.ts +47 -0
- package/dist/commands/auth-upgrade.d.ts.map +1 -0
- package/dist/commands/auth-upgrade.js +277 -0
- package/dist/commands/auth-upgrade.js.map +1 -0
- package/dist/commands/auth.d.ts +16 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +283 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +512 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/crystallize.d.ts +8 -0
- package/dist/commands/crystallize.d.ts.map +1 -0
- package/dist/commands/crystallize.js +101 -0
- package/dist/commands/crystallize.js.map +1 -0
- package/dist/commands/destroy.d.ts +6 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/destroy.js +54 -0
- package/dist/commands/destroy.js.map +1 -0
- package/dist/commands/dispatch.d.ts +9 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +94 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/enter.d.ts +63 -0
- package/dist/commands/enter.d.ts.map +1 -0
- package/dist/commands/enter.js +206 -0
- package/dist/commands/enter.js.map +1 -0
- package/dist/commands/host-cp.d.ts +191 -0
- package/dist/commands/host-cp.d.ts.map +1 -0
- package/dist/commands/host-cp.js +797 -0
- package/dist/commands/host-cp.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +143 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +22 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +203 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/keys.d.ts +26 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +151 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/lanes.d.ts +18 -0
- package/dist/commands/lanes.d.ts.map +1 -0
- package/dist/commands/lanes.js +122 -0
- package/dist/commands/lanes.js.map +1 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +39 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +38 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +177 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/observe.d.ts +9 -0
- package/dist/commands/observe.d.ts.map +1 -0
- package/dist/commands/observe.js +34 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/commands/policy-check.d.ts +14 -0
- package/dist/commands/policy-check.d.ts.map +1 -0
- package/dist/commands/policy-check.js +76 -0
- package/dist/commands/policy-check.js.map +1 -0
- package/dist/commands/pr.d.ts +17 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +148 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/ps.d.ts +25 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +164 -0
- package/dist/commands/ps.js.map +1 -0
- package/dist/commands/refresh-helpers.d.ts +25 -0
- package/dist/commands/refresh-helpers.d.ts.map +1 -0
- package/dist/commands/refresh-helpers.js +56 -0
- package/dist/commands/refresh-helpers.js.map +1 -0
- package/dist/commands/refresh.d.ts +23 -0
- package/dist/commands/refresh.d.ts.map +1 -0
- package/dist/commands/refresh.js +237 -0
- package/dist/commands/refresh.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/upgrade.d.ts +67 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +358 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/workspace.d.ts +23 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +198 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/commands/world-snapshot.d.ts +18 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -0
- package/dist/commands/world-snapshot.js +327 -0
- package/dist/commands/world-snapshot.js.map +1 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +51 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18007 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.js +32236 -0
- package/dist/output.d.ts +10 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +31 -0
- package/dist/output.js.map +1 -0
- package/host-cp/compose.yaml +126 -0
- package/host-cp/src/auth-secret-hint.mjs +45 -0
- package/host-cp/src/auth.mjs +155 -0
- package/host-cp/src/compose-worlds-sources.mjs +170 -0
- package/host-cp/src/container-secret-fetcher.mjs +163 -0
- package/host-cp/src/docker-events.mjs +184 -0
- package/host-cp/src/local-worlds-source.mjs +83 -0
- package/host-cp/src/plan-orchestrator.mjs +829 -0
- package/host-cp/src/plan-progress.mjs +282 -0
- package/host-cp/src/pr-cache.mjs +201 -0
- package/host-cp/src/pr-merge-poller.mjs +154 -0
- package/host-cp/src/process-poller.mjs +250 -0
- package/host-cp/src/proxy.mjs +245 -0
- package/host-cp/src/pylon-worlds-source.mjs +68 -0
- package/host-cp/src/redact.mjs +67 -0
- package/host-cp/src/secret-cache.mjs +104 -0
- package/host-cp/src/server.mjs +2215 -0
- package/host-cp/src/sse-gate.mjs +117 -0
- package/host-cp/src/version-status.mjs +209 -0
- package/host-cp/src/workspace-catalog.mjs +149 -0
- package/host-cp/src/world-names-store.mjs +176 -0
- package/host-cp/src/world-pr-state.mjs +97 -0
- package/host-cp/src/world-progress.mjs +322 -0
- package/host-cp/src/world-tunnel-manager.mjs +288 -0
- package/host-cp/src/worlds-db-source.mjs +191 -0
- package/host-cp/src/worlds-source.mjs +59 -0
- package/package.json +38 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Phase F-2-B (B3): fetch a per-world container's X-Olam-Secret via the
|
|
2
|
+
// docker-socket-proxy sidecar (container mode) OR via `docker exec` (bare-
|
|
3
|
+
// node mode — host-cp running as a plain Node process on the host).
|
|
4
|
+
//
|
|
5
|
+
// The secret lives at `/tmp/olam-container-secret` inside the world's
|
|
6
|
+
// devbox container. Phase E init wrote it (`chmod 400` owned by root —
|
|
7
|
+
// world-app user has no write permission, T9 mitigation) and the
|
|
8
|
+
// per-world CP's `requireAuth` middleware compares against it. Host CP
|
|
9
|
+
// reads the secret server-side and injects `X-Olam-Secret` on proxied
|
|
10
|
+
// requests, so the browser never sees the secret directly.
|
|
11
|
+
//
|
|
12
|
+
// Container mode (`dockerHost = 'tcp://docker-socket-proxy:2375'`):
|
|
13
|
+
// 1. POST /containers/<name>/exec
|
|
14
|
+
// body: { Cmd: ['cat', '/tmp/olam-container-secret'], AttachStdout: true, AttachStderr: true }
|
|
15
|
+
// → { Id: '<exec-id>' }
|
|
16
|
+
// 2. POST /exec/<exec-id>/start
|
|
17
|
+
// body: { Detach: false, Tty: false }
|
|
18
|
+
// → response stream containing the file bytes (raw multiplexed
|
|
19
|
+
// stdout/stderr per Docker exec protocol)
|
|
20
|
+
//
|
|
21
|
+
// The exec endpoint is whitelisted in the socket-proxy (EXEC=1).
|
|
22
|
+
//
|
|
23
|
+
// Bare-node mode (`dockerHost = 'docker-cli'`):
|
|
24
|
+
// Spawn `docker exec <containerName> cat /tmp/olam-container-secret` via
|
|
25
|
+
// child_process. No socket-proxy on the host; the docker CLI on the
|
|
26
|
+
// operator's $PATH is the canonical access path. Same `olam-<id>-devbox`
|
|
27
|
+
// naming convention applies. ~10 ms of process-spawn overhead per miss
|
|
28
|
+
// is fine because the secret is cached for OLAM_SECRET_CACHE_TTL_SEC
|
|
29
|
+
// (default 300 s).
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read /tmp/olam-container-secret from a world's devbox container.
|
|
35
|
+
* Throws on any non-2xx response from the socket-proxy or on the
|
|
36
|
+
* file being empty (the world's CP is misconfigured if it is).
|
|
37
|
+
*
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {string} args.worldId
|
|
40
|
+
* @param {string} args.dockerHost Either `tcp://...` for socket-proxy
|
|
41
|
+
* mode or the sentinel `'docker-cli'` for bare-node mode.
|
|
42
|
+
* @param {(host: string, init: RequestInit) => Promise<Response>} [args.fetchImpl]
|
|
43
|
+
* injectable for tests; defaults to global fetch (Node 22+)
|
|
44
|
+
* @returns {Promise<string>} the secret (trimmed of trailing whitespace)
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchContainerSecret({ worldId, dockerHost, fetchImpl = globalThis.fetch }) {
|
|
47
|
+
// Container naming convention: docker provider creates containers as
|
|
48
|
+
// `olam-${worldId}-devbox` (see packages/adapters/src/docker/container.ts).
|
|
49
|
+
// Phase F-2-D dogfood revealed the original `${worldId}-devbox` was
|
|
50
|
+
// missing the `olam-` prefix.
|
|
51
|
+
const containerName = `olam-${worldId}-devbox`;
|
|
52
|
+
|
|
53
|
+
// Bare-node mode: shell out to docker exec directly. Operator's docker
|
|
54
|
+
// CLI on $PATH is the canonical access path; no socket-proxy needed.
|
|
55
|
+
if (dockerHost === 'docker-cli') {
|
|
56
|
+
const result = spawnSync(
|
|
57
|
+
'docker',
|
|
58
|
+
['exec', containerName, 'cat', '/tmp/olam-container-secret'],
|
|
59
|
+
{ encoding: 'utf-8' },
|
|
60
|
+
);
|
|
61
|
+
if (result.error) {
|
|
62
|
+
throw new Error(`docker exec ${containerName} cat ... failed: ${result.error.message}`);
|
|
63
|
+
}
|
|
64
|
+
if (result.status !== 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`docker exec ${containerName} cat ... exit ${result.status}: ${(result.stderr || '').trim()}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const secret = (result.stdout || '').trim();
|
|
70
|
+
if (!secret) {
|
|
71
|
+
throw new Error(`/tmp/olam-container-secret empty in container ${containerName}`);
|
|
72
|
+
}
|
|
73
|
+
return secret;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Container mode: HTTP via the docker-socket-proxy sidecar.
|
|
77
|
+
// Docker API: tcp://host:port → http://host:port
|
|
78
|
+
const apiBase = dockerHost.replace(/^tcp:\/\//, 'http://');
|
|
79
|
+
|
|
80
|
+
// Step 1: create exec instance
|
|
81
|
+
const createUrl = `${apiBase}/containers/${encodeURIComponent(containerName)}/exec`;
|
|
82
|
+
const createRes = await fetchImpl(createUrl, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
Cmd: ['cat', '/tmp/olam-container-secret'],
|
|
87
|
+
AttachStdout: true,
|
|
88
|
+
AttachStderr: true,
|
|
89
|
+
Tty: false,
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
if (!createRes.ok) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`socket-proxy POST /containers/${containerName}/exec failed: ${createRes.status} ${createRes.statusText}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const createBody = await createRes.json();
|
|
98
|
+
const execId = createBody.Id;
|
|
99
|
+
if (!execId) {
|
|
100
|
+
throw new Error(`socket-proxy /exec did not return Id: ${JSON.stringify(createBody)}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Step 2: start exec, read stdout. The response is Docker's
|
|
104
|
+
// multiplexed exec stream: 8-byte header per frame + payload bytes.
|
|
105
|
+
// Header byte 0 = stream id (1=stdout, 2=stderr), bytes 4-7 = payload
|
|
106
|
+
// length (big-endian uint32). For `cat <smallfile>` we expect a single
|
|
107
|
+
// frame on stream 1.
|
|
108
|
+
const startUrl = `${apiBase}/exec/${execId}/start`;
|
|
109
|
+
const startRes = await fetchImpl(startUrl, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ Detach: false, Tty: false }),
|
|
113
|
+
});
|
|
114
|
+
if (!startRes.ok) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`socket-proxy POST /exec/${execId}/start failed: ${startRes.status} ${startRes.statusText}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const buf = new Uint8Array(await startRes.arrayBuffer());
|
|
120
|
+
|
|
121
|
+
// Decode the multiplexed stream. Skip stderr frames; concatenate
|
|
122
|
+
// stdout payloads. Empty file → throw (per-world CP is broken).
|
|
123
|
+
const stdoutBytes = decodeDockerExecStream(buf);
|
|
124
|
+
const secret = new TextDecoder('utf-8').decode(stdoutBytes).trim();
|
|
125
|
+
if (!secret) {
|
|
126
|
+
throw new Error(`/tmp/olam-container-secret empty in container ${containerName}`);
|
|
127
|
+
}
|
|
128
|
+
return secret;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decode Docker's multiplexed exec stream — keep only stdout (stream id 1).
|
|
133
|
+
* Stream format: each frame is 8-byte header + payload. Header byte 0
|
|
134
|
+
* is the stream id (0=stdin, 1=stdout, 2=stderr); bytes 4-7 are the
|
|
135
|
+
* payload length as big-endian uint32. Bytes 1-3 are reserved (zero).
|
|
136
|
+
*
|
|
137
|
+
* @param {Uint8Array} buf
|
|
138
|
+
* @returns {Uint8Array}
|
|
139
|
+
*/
|
|
140
|
+
export function decodeDockerExecStream(buf) {
|
|
141
|
+
const out = [];
|
|
142
|
+
let i = 0;
|
|
143
|
+
while (i + 8 <= buf.byteLength) {
|
|
144
|
+
const streamId = buf[i];
|
|
145
|
+
// Big-endian uint32 at offset i+4..i+8
|
|
146
|
+
const len = (buf[i + 4] << 24) | (buf[i + 5] << 16) | (buf[i + 6] << 8) | buf[i + 7];
|
|
147
|
+
const payload = buf.subarray(i + 8, i + 8 + len);
|
|
148
|
+
if (streamId === 1) {
|
|
149
|
+
out.push(payload);
|
|
150
|
+
}
|
|
151
|
+
i += 8 + len;
|
|
152
|
+
}
|
|
153
|
+
// Concatenate.
|
|
154
|
+
let total = 0;
|
|
155
|
+
for (const p of out) total += p.byteLength;
|
|
156
|
+
const merged = new Uint8Array(total);
|
|
157
|
+
let off = 0;
|
|
158
|
+
for (const p of out) {
|
|
159
|
+
merged.set(p, off);
|
|
160
|
+
off += p.byteLength;
|
|
161
|
+
}
|
|
162
|
+
return merged;
|
|
163
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Phase F-2-B (B3): subscribe to docker events stream and invalidate
|
|
2
|
+
// the secret cache on `restart` / `stop` events for known worlds.
|
|
3
|
+
//
|
|
4
|
+
// M2 ship gate: `docker restart <world>; within 10s, proxy call returns
|
|
5
|
+
// 200 not 401`. The 10s budget is dominated by docker-events latency
|
|
6
|
+
// (events fire ~1s after the docker daemon emits them) + JSON parse +
|
|
7
|
+
// cache invalidate (<100ms). 10s is conservative.
|
|
8
|
+
//
|
|
9
|
+
// Stream format: Docker sends NDJSON — newline-delimited JSON events.
|
|
10
|
+
// Each event has shape:
|
|
11
|
+
// {"Type":"container","Action":"restart","Actor":{"Attributes":{"name":"<container-name>"}},...}
|
|
12
|
+
// We filter `Type === 'container'` && `Action ∈ {restart, stop, die}` and
|
|
13
|
+
// extract the container name to compute worldId.
|
|
14
|
+
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe to docker events. Returns a stop function. Auto-reconnects
|
|
20
|
+
* on transient errors (the events stream is long-lived; a daemon
|
|
21
|
+
* restart breaks the connection but the function recovers).
|
|
22
|
+
*
|
|
23
|
+
* @param {object} args
|
|
24
|
+
* @param {string} args.dockerHost Either `tcp://...` for socket-proxy
|
|
25
|
+
* mode or the sentinel `'docker-cli'` for bare-node mode (spawns
|
|
26
|
+
* `docker events --format json` via child_process).
|
|
27
|
+
* @param {(worldId: string) => void} args.onWorldRestart
|
|
28
|
+
* called when a known world restarts/stops/dies
|
|
29
|
+
* @param {(message: string) => void} [args.log]
|
|
30
|
+
* @returns {() => void} stop function
|
|
31
|
+
*/
|
|
32
|
+
export function subscribeDockerEvents({ dockerHost, onWorldRestart, log = console.log }) {
|
|
33
|
+
let stopped = false;
|
|
34
|
+
let activeReq = null;
|
|
35
|
+
let activeProc = null;
|
|
36
|
+
let reconnectTimer = null;
|
|
37
|
+
|
|
38
|
+
// Bare-node mode: shell out to `docker events --format json` and parse
|
|
39
|
+
// its stdout as NDJSON. Same semantic as the HTTP path; different
|
|
40
|
+
// transport. Eliminates the `tcp://docker-cli` URL-construction crash.
|
|
41
|
+
function connectViaCli() {
|
|
42
|
+
if (stopped) return;
|
|
43
|
+
const filters = ['--filter', 'type=container'];
|
|
44
|
+
log('docker-events: spawning `docker events --format json`');
|
|
45
|
+
const child = spawn(
|
|
46
|
+
'docker',
|
|
47
|
+
['events', '--format', '{{json .}}', ...filters],
|
|
48
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
49
|
+
);
|
|
50
|
+
activeProc = child;
|
|
51
|
+
let buf = '';
|
|
52
|
+
child.stdout.setEncoding('utf-8');
|
|
53
|
+
child.stdout.on('data', (chunk) => {
|
|
54
|
+
buf += chunk;
|
|
55
|
+
let nl;
|
|
56
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
57
|
+
const line = buf.slice(0, nl);
|
|
58
|
+
buf = buf.slice(nl + 1);
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
try {
|
|
61
|
+
const event = JSON.parse(line);
|
|
62
|
+
// CLI shape uses `status` instead of HTTP API's `Action`; normalize.
|
|
63
|
+
if (event.status && !event.Action) event.Action = event.status;
|
|
64
|
+
if (event.Type === undefined && event.Type !== 'container') event.Type = 'container';
|
|
65
|
+
handleEvent(event, { onWorldRestart, log });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
log(`docker-events: parse error on line: ${line.slice(0, 120)} (${err.message})`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
child.stderr.on('data', (chunk) => {
|
|
72
|
+
const text = String(chunk).trim();
|
|
73
|
+
if (text) log(`docker-events: stderr: ${text}`);
|
|
74
|
+
});
|
|
75
|
+
child.on('exit', (code, signal) => {
|
|
76
|
+
activeProc = null;
|
|
77
|
+
log(`docker-events: child exited code=${code} signal=${signal}; reconnecting`);
|
|
78
|
+
scheduleReconnect();
|
|
79
|
+
});
|
|
80
|
+
child.on('error', (err) => {
|
|
81
|
+
log(`docker-events: spawn error: ${err.message}; reconnecting`);
|
|
82
|
+
scheduleReconnect();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function connect() {
|
|
87
|
+
if (stopped) return;
|
|
88
|
+
if (dockerHost === 'docker-cli') {
|
|
89
|
+
return connectViaCli();
|
|
90
|
+
}
|
|
91
|
+
// Docker Engine API: GET /events?filters=...
|
|
92
|
+
// Filter: type=container AND event=restart|stop|die
|
|
93
|
+
// (Note: `event` filter takes a JSON-stringified array.)
|
|
94
|
+
const filters = JSON.stringify({
|
|
95
|
+
type: ['container'],
|
|
96
|
+
event: ['restart', 'stop', 'die'],
|
|
97
|
+
});
|
|
98
|
+
const url = new URL('/events', dockerHost.replace(/^tcp:\/\//, 'http://'));
|
|
99
|
+
url.searchParams.set('filters', filters);
|
|
100
|
+
|
|
101
|
+
log(`docker-events: connecting to ${url.origin}/events`);
|
|
102
|
+
activeReq = http.get(url, (res) => {
|
|
103
|
+
if (res.statusCode !== 200) {
|
|
104
|
+
log(`docker-events: unexpected status ${res.statusCode}; will retry`);
|
|
105
|
+
scheduleReconnect();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let buf = '';
|
|
109
|
+
res.setEncoding('utf-8');
|
|
110
|
+
res.on('data', (chunk) => {
|
|
111
|
+
buf += chunk;
|
|
112
|
+
// NDJSON: split on newlines; last fragment may be partial.
|
|
113
|
+
let nl;
|
|
114
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
115
|
+
const line = buf.slice(0, nl);
|
|
116
|
+
buf = buf.slice(nl + 1);
|
|
117
|
+
if (!line.trim()) continue;
|
|
118
|
+
try {
|
|
119
|
+
handleEvent(JSON.parse(line), { onWorldRestart, log });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
log(`docker-events: parse error on line: ${line.slice(0, 120)} (${err.message})`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
res.on('end', () => {
|
|
126
|
+
log('docker-events: stream closed; reconnecting');
|
|
127
|
+
scheduleReconnect();
|
|
128
|
+
});
|
|
129
|
+
res.on('error', (err) => {
|
|
130
|
+
log(`docker-events: stream error: ${err.message}; reconnecting`);
|
|
131
|
+
scheduleReconnect();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
activeReq.on('error', (err) => {
|
|
135
|
+
log(`docker-events: connect error: ${err.message}; reconnecting`);
|
|
136
|
+
scheduleReconnect();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scheduleReconnect() {
|
|
141
|
+
if (stopped) return;
|
|
142
|
+
if (reconnectTimer) return;
|
|
143
|
+
reconnectTimer = setTimeout(() => {
|
|
144
|
+
reconnectTimer = null;
|
|
145
|
+
connect();
|
|
146
|
+
}, 2000); // 2s backoff
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
connect();
|
|
150
|
+
|
|
151
|
+
return function stop() {
|
|
152
|
+
stopped = true;
|
|
153
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
154
|
+
if (activeReq) activeReq.destroy();
|
|
155
|
+
if (activeProc) {
|
|
156
|
+
try { activeProc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
157
|
+
activeProc = null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Inspect a docker event and call onWorldRestart if it matches a
|
|
164
|
+
* world container. Container naming convention: `<worldId>-devbox`
|
|
165
|
+
* (per packages/adapters/src/docker/container.ts).
|
|
166
|
+
*
|
|
167
|
+
* Exported for unit testing.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ Type?: string, Action?: string, Actor?: { Attributes?: { name?: string } } }} event
|
|
170
|
+
* @param {{ onWorldRestart: (worldId: string) => void, log: (m: string) => void }} ctx
|
|
171
|
+
*/
|
|
172
|
+
export function handleEvent(event, { onWorldRestart, log }) {
|
|
173
|
+
if (event?.Type !== 'container') return;
|
|
174
|
+
if (!['restart', 'stop', 'die'].includes(event.Action ?? '')) return;
|
|
175
|
+
const name = event.Actor?.Attributes?.name;
|
|
176
|
+
if (!name) return;
|
|
177
|
+
// Strip leading slash that Docker sometimes prepends to container names.
|
|
178
|
+
const cleanName = name.startsWith('/') ? name.slice(1) : name;
|
|
179
|
+
const m = /^(.+)-devbox$/.exec(cleanName);
|
|
180
|
+
if (!m) return;
|
|
181
|
+
const worldId = m[1];
|
|
182
|
+
log(`docker-events: ${event.Action} on ${cleanName} → invalidating ${worldId}`);
|
|
183
|
+
onWorldRestart(worldId);
|
|
184
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase E2 (olam-dogfood-vision): LocalWorldsSource implementation.
|
|
3
|
+
*
|
|
4
|
+
* Wraps host-cp's existing dockerode-driven world enumeration in a
|
|
5
|
+
* WorldsSource-shaped object so E4's composition layer can fan out
|
|
6
|
+
* across multiple sources (local + future Pylon cloud) and merge.
|
|
7
|
+
*
|
|
8
|
+
* The class deliberately takes its dependencies via factory function
|
|
9
|
+
* injection rather than reaching into server.mjs's module-level state
|
|
10
|
+
* directly. Two reasons:
|
|
11
|
+
* 1. Testability — vitest can pass mocked getWorldsRegistry +
|
|
12
|
+
* fetchWorldServices without spinning up the full host-cp
|
|
13
|
+
* server.mjs.
|
|
14
|
+
* 2. Module-cycle avoidance — server.mjs imports this module, so
|
|
15
|
+
* this module CANNOT import server.mjs back without a cycle.
|
|
16
|
+
*
|
|
17
|
+
* Returns the same shape as the pre-E2 GET /api/worlds response with
|
|
18
|
+
* a single addition: `source: 'local'` on every entry.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {import('./worlds-source.mjs').WorldsSource} WorldsSource
|
|
21
|
+
* @typedef {import('./worlds-source.mjs').WorldSummary} WorldSummary
|
|
22
|
+
* @typedef {import('./worlds-source.mjs').ServiceInfo} ServiceInfo
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} LocalWorldsSourceDeps
|
|
27
|
+
* @property {() => Record<string, number>} getWorldsRegistry
|
|
28
|
+
* Returns current WORLDS map (worldId → host_port). Called fresh
|
|
29
|
+
* per list() so post-list registry mutations are visible immediately.
|
|
30
|
+
* @property {(worldId: string) => string | null} getWorldName
|
|
31
|
+
* Returns the operator-set friendly name OR null if absent.
|
|
32
|
+
* @property {(worldId: string) => Promise<ServiceInfo[]>} fetchWorldServices
|
|
33
|
+
* Probes per-world services (atlas-core, diner-app, ttyd, per-world CP).
|
|
34
|
+
* Same function the pre-E2 handler called inline.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {LocalWorldsSourceDeps} deps
|
|
39
|
+
* @returns {WorldsSource}
|
|
40
|
+
*/
|
|
41
|
+
export function createLocalWorldsSource(deps) {
|
|
42
|
+
return {
|
|
43
|
+
name: 'local',
|
|
44
|
+
async list() {
|
|
45
|
+
const registry = deps.getWorldsRegistry();
|
|
46
|
+
const entries = Object.entries(registry);
|
|
47
|
+
const worlds = await Promise.all(
|
|
48
|
+
entries.map(async ([id, host_port]) => {
|
|
49
|
+
const services = await deps.fetchWorldServices(id);
|
|
50
|
+
// World status mirrors pre-E2 behavior:
|
|
51
|
+
// - running: >=1 service responds to a probe
|
|
52
|
+
// - starting: container has port bindings but nothing answers
|
|
53
|
+
// - unknown: no port bindings at all (container down/missing)
|
|
54
|
+
const liveCount = services.filter((s) => s.live).length;
|
|
55
|
+
/** @type {'running' | 'starting' | 'unknown'} */
|
|
56
|
+
const status =
|
|
57
|
+
services.length === 0
|
|
58
|
+
? 'unknown'
|
|
59
|
+
: liveCount > 0
|
|
60
|
+
? 'running'
|
|
61
|
+
: 'starting';
|
|
62
|
+
/** @type {WorldSummary} */
|
|
63
|
+
const summary = {
|
|
64
|
+
id,
|
|
65
|
+
name: deps.getWorldName(id),
|
|
66
|
+
status,
|
|
67
|
+
services,
|
|
68
|
+
source: 'local',
|
|
69
|
+
};
|
|
70
|
+
// Preserve the pre-E2 host_port field so SPA + CLI consumers
|
|
71
|
+
// that depend on it don't break. WorldSummary type doesn't
|
|
72
|
+
// declare host_port (it's local-source-specific metadata),
|
|
73
|
+
// but extra fields on the object are tolerated by the type.
|
|
74
|
+
return /** @type {WorldSummary & {host_port: number}} */ ({
|
|
75
|
+
...summary,
|
|
76
|
+
host_port,
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
return worlds;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|