@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.
Files changed (196) hide show
  1. package/dist/__tests__/auth-status.test.d.ts +2 -0
  2. package/dist/__tests__/auth-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/auth-status.test.js +290 -0
  4. package/dist/__tests__/auth-status.test.js.map +1 -0
  5. package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
  6. package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-upgrade.test.js +161 -0
  8. package/dist/__tests__/auth-upgrade.test.js.map +1 -0
  9. package/dist/__tests__/create-app-urls.test.d.ts +2 -0
  10. package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
  11. package/dist/__tests__/create-app-urls.test.js +102 -0
  12. package/dist/__tests__/create-app-urls.test.js.map +1 -0
  13. package/dist/__tests__/enter.test.d.ts +2 -0
  14. package/dist/__tests__/enter.test.d.ts.map +1 -0
  15. package/dist/__tests__/enter.test.js +90 -0
  16. package/dist/__tests__/enter.test.js.map +1 -0
  17. package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
  18. package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
  19. package/dist/__tests__/host-cp-gh-token.test.js +119 -0
  20. package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
  21. package/dist/__tests__/host-cp.test.d.ts +9 -0
  22. package/dist/__tests__/host-cp.test.d.ts.map +1 -0
  23. package/dist/__tests__/host-cp.test.js +254 -0
  24. package/dist/__tests__/host-cp.test.js.map +1 -0
  25. package/dist/__tests__/keys.test.d.ts +9 -0
  26. package/dist/__tests__/keys.test.d.ts.map +1 -0
  27. package/dist/__tests__/keys.test.js +145 -0
  28. package/dist/__tests__/keys.test.js.map +1 -0
  29. package/dist/__tests__/logs.test.d.ts +9 -0
  30. package/dist/__tests__/logs.test.d.ts.map +1 -0
  31. package/dist/__tests__/logs.test.js +124 -0
  32. package/dist/__tests__/logs.test.js.map +1 -0
  33. package/dist/__tests__/ps.test.d.ts +2 -0
  34. package/dist/__tests__/ps.test.d.ts.map +1 -0
  35. package/dist/__tests__/ps.test.js +172 -0
  36. package/dist/__tests__/ps.test.js.map +1 -0
  37. package/dist/__tests__/status-app-urls.test.d.ts +2 -0
  38. package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
  39. package/dist/__tests__/status-app-urls.test.js +125 -0
  40. package/dist/__tests__/status-app-urls.test.js.map +1 -0
  41. package/dist/__tests__/upgrade.test.d.ts +9 -0
  42. package/dist/__tests__/upgrade.test.d.ts.map +1 -0
  43. package/dist/__tests__/upgrade.test.js +262 -0
  44. package/dist/__tests__/upgrade.test.js.map +1 -0
  45. package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
  46. package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
  47. package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
  48. package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
  49. package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
  50. package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
  51. package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
  52. package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
  53. package/dist/commands/__tests__/refresh.test.d.ts +13 -0
  54. package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
  55. package/dist/commands/__tests__/refresh.test.js +170 -0
  56. package/dist/commands/__tests__/refresh.test.js.map +1 -0
  57. package/dist/commands/auth-status.d.ts +43 -0
  58. package/dist/commands/auth-status.d.ts.map +1 -0
  59. package/dist/commands/auth-status.js +208 -0
  60. package/dist/commands/auth-status.js.map +1 -0
  61. package/dist/commands/auth-upgrade.d.ts +47 -0
  62. package/dist/commands/auth-upgrade.d.ts.map +1 -0
  63. package/dist/commands/auth-upgrade.js +277 -0
  64. package/dist/commands/auth-upgrade.js.map +1 -0
  65. package/dist/commands/auth.d.ts +16 -0
  66. package/dist/commands/auth.d.ts.map +1 -0
  67. package/dist/commands/auth.js +283 -0
  68. package/dist/commands/auth.js.map +1 -0
  69. package/dist/commands/create.d.ts +8 -0
  70. package/dist/commands/create.d.ts.map +1 -0
  71. package/dist/commands/create.js +512 -0
  72. package/dist/commands/create.js.map +1 -0
  73. package/dist/commands/crystallize.d.ts +8 -0
  74. package/dist/commands/crystallize.d.ts.map +1 -0
  75. package/dist/commands/crystallize.js +101 -0
  76. package/dist/commands/crystallize.js.map +1 -0
  77. package/dist/commands/destroy.d.ts +6 -0
  78. package/dist/commands/destroy.d.ts.map +1 -0
  79. package/dist/commands/destroy.js +54 -0
  80. package/dist/commands/destroy.js.map +1 -0
  81. package/dist/commands/dispatch.d.ts +9 -0
  82. package/dist/commands/dispatch.d.ts.map +1 -0
  83. package/dist/commands/dispatch.js +94 -0
  84. package/dist/commands/dispatch.js.map +1 -0
  85. package/dist/commands/enter.d.ts +63 -0
  86. package/dist/commands/enter.d.ts.map +1 -0
  87. package/dist/commands/enter.js +206 -0
  88. package/dist/commands/enter.js.map +1 -0
  89. package/dist/commands/host-cp.d.ts +191 -0
  90. package/dist/commands/host-cp.d.ts.map +1 -0
  91. package/dist/commands/host-cp.js +797 -0
  92. package/dist/commands/host-cp.js.map +1 -0
  93. package/dist/commands/init.d.ts +9 -0
  94. package/dist/commands/init.d.ts.map +1 -0
  95. package/dist/commands/init.js +143 -0
  96. package/dist/commands/init.js.map +1 -0
  97. package/dist/commands/install.d.ts +22 -0
  98. package/dist/commands/install.d.ts.map +1 -0
  99. package/dist/commands/install.js +203 -0
  100. package/dist/commands/install.js.map +1 -0
  101. package/dist/commands/keys.d.ts +26 -0
  102. package/dist/commands/keys.d.ts.map +1 -0
  103. package/dist/commands/keys.js +151 -0
  104. package/dist/commands/keys.js.map +1 -0
  105. package/dist/commands/lanes.d.ts +18 -0
  106. package/dist/commands/lanes.d.ts.map +1 -0
  107. package/dist/commands/lanes.js +122 -0
  108. package/dist/commands/lanes.js.map +1 -0
  109. package/dist/commands/list.d.ts +6 -0
  110. package/dist/commands/list.d.ts.map +1 -0
  111. package/dist/commands/list.js +39 -0
  112. package/dist/commands/list.js.map +1 -0
  113. package/dist/commands/logs.d.ts +38 -0
  114. package/dist/commands/logs.d.ts.map +1 -0
  115. package/dist/commands/logs.js +177 -0
  116. package/dist/commands/logs.js.map +1 -0
  117. package/dist/commands/observe.d.ts +9 -0
  118. package/dist/commands/observe.d.ts.map +1 -0
  119. package/dist/commands/observe.js +34 -0
  120. package/dist/commands/observe.js.map +1 -0
  121. package/dist/commands/policy-check.d.ts +14 -0
  122. package/dist/commands/policy-check.d.ts.map +1 -0
  123. package/dist/commands/policy-check.js +76 -0
  124. package/dist/commands/policy-check.js.map +1 -0
  125. package/dist/commands/pr.d.ts +17 -0
  126. package/dist/commands/pr.d.ts.map +1 -0
  127. package/dist/commands/pr.js +148 -0
  128. package/dist/commands/pr.js.map +1 -0
  129. package/dist/commands/ps.d.ts +25 -0
  130. package/dist/commands/ps.d.ts.map +1 -0
  131. package/dist/commands/ps.js +164 -0
  132. package/dist/commands/ps.js.map +1 -0
  133. package/dist/commands/refresh-helpers.d.ts +25 -0
  134. package/dist/commands/refresh-helpers.d.ts.map +1 -0
  135. package/dist/commands/refresh-helpers.js +56 -0
  136. package/dist/commands/refresh-helpers.js.map +1 -0
  137. package/dist/commands/refresh.d.ts +23 -0
  138. package/dist/commands/refresh.d.ts.map +1 -0
  139. package/dist/commands/refresh.js +237 -0
  140. package/dist/commands/refresh.js.map +1 -0
  141. package/dist/commands/status.d.ts +6 -0
  142. package/dist/commands/status.d.ts.map +1 -0
  143. package/dist/commands/status.js +51 -0
  144. package/dist/commands/status.js.map +1 -0
  145. package/dist/commands/upgrade.d.ts +67 -0
  146. package/dist/commands/upgrade.d.ts.map +1 -0
  147. package/dist/commands/upgrade.js +358 -0
  148. package/dist/commands/upgrade.js.map +1 -0
  149. package/dist/commands/workspace.d.ts +23 -0
  150. package/dist/commands/workspace.d.ts.map +1 -0
  151. package/dist/commands/workspace.js +198 -0
  152. package/dist/commands/workspace.js.map +1 -0
  153. package/dist/commands/world-snapshot.d.ts +18 -0
  154. package/dist/commands/world-snapshot.d.ts.map +1 -0
  155. package/dist/commands/world-snapshot.js +327 -0
  156. package/dist/commands/world-snapshot.js.map +1 -0
  157. package/dist/context.d.ts +26 -0
  158. package/dist/context.d.ts.map +1 -0
  159. package/dist/context.js +51 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/index.d.ts +9 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +18007 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/mcp-server.js +32236 -0
  166. package/dist/output.d.ts +10 -0
  167. package/dist/output.d.ts.map +1 -0
  168. package/dist/output.js +31 -0
  169. package/dist/output.js.map +1 -0
  170. package/host-cp/compose.yaml +126 -0
  171. package/host-cp/src/auth-secret-hint.mjs +45 -0
  172. package/host-cp/src/auth.mjs +155 -0
  173. package/host-cp/src/compose-worlds-sources.mjs +170 -0
  174. package/host-cp/src/container-secret-fetcher.mjs +163 -0
  175. package/host-cp/src/docker-events.mjs +184 -0
  176. package/host-cp/src/local-worlds-source.mjs +83 -0
  177. package/host-cp/src/plan-orchestrator.mjs +829 -0
  178. package/host-cp/src/plan-progress.mjs +282 -0
  179. package/host-cp/src/pr-cache.mjs +201 -0
  180. package/host-cp/src/pr-merge-poller.mjs +154 -0
  181. package/host-cp/src/process-poller.mjs +250 -0
  182. package/host-cp/src/proxy.mjs +245 -0
  183. package/host-cp/src/pylon-worlds-source.mjs +68 -0
  184. package/host-cp/src/redact.mjs +67 -0
  185. package/host-cp/src/secret-cache.mjs +104 -0
  186. package/host-cp/src/server.mjs +2215 -0
  187. package/host-cp/src/sse-gate.mjs +117 -0
  188. package/host-cp/src/version-status.mjs +209 -0
  189. package/host-cp/src/workspace-catalog.mjs +149 -0
  190. package/host-cp/src/world-names-store.mjs +176 -0
  191. package/host-cp/src/world-pr-state.mjs +97 -0
  192. package/host-cp/src/world-progress.mjs +322 -0
  193. package/host-cp/src/world-tunnel-manager.mjs +288 -0
  194. package/host-cp/src/worlds-db-source.mjs +191 -0
  195. package/host-cp/src/worlds-source.mjs +59 -0
  196. 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
+ }