@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,797 @@
1
+ /**
2
+ * Phase F-2-D (D1+D2+D3): `olam host-cp <subcommand>` — operator-facing
3
+ * lifecycle for the host CP container.
4
+ *
5
+ * Subcommands:
6
+ * start — generate token, audit port 19000, docker compose up, write PID
7
+ * stop — docker compose down, remove PID + token
8
+ * status — diagnostic probe (with optional --json flag)
9
+ *
10
+ * Wraps the compose stack at `packages/host-cp/compose.yaml`. Token
11
+ * lives at `~/.olam/host-cp.token` (chmod 600). PID file at
12
+ * `~/.olam/host-cp.pid`. Both are operator-state files, not repo state.
13
+ */
14
+ import * as crypto from 'node:crypto';
15
+ import * as fs from 'node:fs';
16
+ import * as os from 'node:os';
17
+ import * as path from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+ import Dockerode from 'dockerode';
20
+ import { auditPortsForZombies, PortHeldByZombieError } from '@olam/adapters';
21
+ import { printError, printSuccess, printInfo, printHeader, printWarning } from '../output.js';
22
+ // ── Constants ─────────────────────────────────────────────────────
23
+ /** Default host CP port. Matches packages/host-cp/compose.yaml. */
24
+ const HOST_CP_PORT = 19000;
25
+ /** Path to the compose stack. Checked in order:
26
+ * 1. Bundled-package path — used when installed via `npm install -g @pleri/olam-cli`.
27
+ * 2. Monorepo cwd — used in source-mode dev (`pnpm dev:olam`).
28
+ * 3. Monorepo parent cwd — fallback for nested cwd inside the repo.
29
+ */
30
+ function findComposeFile() {
31
+ const candidates = [
32
+ // Bundled path: dist/index.js lives at <pkg>/dist/; host-cp/ is a sibling of dist/
33
+ path.resolve(path.dirname(new URL(import.meta.url).pathname), '../host-cp/compose.yaml'),
34
+ // Source-mode: cwd is monorepo root
35
+ path.resolve(process.cwd(), 'packages/host-cp/compose.yaml'),
36
+ // Source-mode: cwd is one level inside the monorepo
37
+ path.resolve(process.cwd(), '../packages/host-cp/compose.yaml'),
38
+ ];
39
+ for (const c of candidates) {
40
+ if (fs.existsSync(c))
41
+ return c;
42
+ }
43
+ // Last resort: let docker compose surface the error.
44
+ return path.resolve(process.cwd(), 'packages/host-cp/compose.yaml');
45
+ }
46
+ /** Operator-state directory: `~/.olam/`. Honors OLAM_HOME for tests. */
47
+ function olamHome() {
48
+ return process.env.OLAM_HOME ?? path.join(os.homedir(), '.olam');
49
+ }
50
+ function tokenPath() {
51
+ return path.join(olamHome(), 'host-cp.token');
52
+ }
53
+ function pidPath() {
54
+ return path.join(olamHome(), 'host-cp.pid');
55
+ }
56
+ /**
57
+ * Path to the operator-state shared auth-service secret.
58
+ *
59
+ * Created by `olam auth up` when the auth-service container first
60
+ * starts. Both the auth-service container and host-cp container must
61
+ * use the same secret value as their `X-Olam-Secret` so host-cp's
62
+ * `/credentials/*` calls authenticate correctly.
63
+ *
64
+ * Honors OLAM_HOME for tests (matches `olamHome()`).
65
+ */
66
+ export function authSecretPath() {
67
+ return path.join(olamHome(), 'auth-secret');
68
+ }
69
+ /**
70
+ * Read the shared auth-service secret from `~/.olam/auth-secret`.
71
+ *
72
+ * Returns null when the file is missing (auth-service was never started
73
+ * or the file was deleted). Returns null instead of throwing because
74
+ * `host-cp start` should still bring host-cp up — but it then logs a
75
+ * loud warning so the operator notices that credential surfaces will
76
+ * 401 until they run `olam auth up` to regenerate the secret.
77
+ *
78
+ * Empty/whitespace files are treated as "not set" (null) — a 0-byte
79
+ * file would otherwise round-trip to compose as an empty env var,
80
+ * which is exactly the silent-failure mode this whole helper exists
81
+ * to prevent.
82
+ */
83
+ export function readAuthSecret() {
84
+ const filePath = authSecretPath();
85
+ if (!fs.existsSync(filePath))
86
+ return null;
87
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
88
+ return raw.length > 0 ? raw : null;
89
+ }
90
+ /** Path to `~/.olam/r2-credentials.json`. Honors OLAM_HOME for tests. */
91
+ export function r2CredentialsPath() {
92
+ return path.join(olamHome(), 'r2-credentials.json');
93
+ }
94
+ /**
95
+ * Read R2 credentials from `~/.olam/r2-credentials.json`.
96
+ *
97
+ * Returns null when the file is missing — callers should print a clear
98
+ * "operator must configure" error rather than throwing. Returns null for
99
+ * malformed JSON so callers can surface a useful message instead of a
100
+ * raw parse stack trace.
101
+ */
102
+ export function readR2Credentials() {
103
+ const filePath = r2CredentialsPath();
104
+ if (!fs.existsSync(filePath))
105
+ return null;
106
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
107
+ if (raw.length === 0)
108
+ return null;
109
+ try {
110
+ const parsed = JSON.parse(raw);
111
+ if (typeof parsed !== 'object' || parsed === null)
112
+ return null;
113
+ const creds = parsed;
114
+ if (typeof creds.account_id !== 'string' ||
115
+ typeof creds.bucket !== 'string' ||
116
+ typeof creds.access_key_id !== 'string' ||
117
+ typeof creds.secret_access_key !== 'string' ||
118
+ typeof creds.public_url_base !== 'string') {
119
+ return null;
120
+ }
121
+ return {
122
+ account_id: creds.account_id,
123
+ bucket: creds.bucket,
124
+ access_key_id: creds.access_key_id,
125
+ secret_access_key: creds.secret_access_key,
126
+ public_url_base: creds.public_url_base,
127
+ };
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ // ── Token + PID file helpers (pure; no docker calls) ──────────────
134
+ /**
135
+ * Generate a fresh 32-byte hex token and write it to `tokenPath()` with
136
+ * mode 0600. Creates the parent directory if missing. Idempotent: if a
137
+ * token already exists, this overwrites it (each `start` regenerates).
138
+ */
139
+ export function writeToken() {
140
+ const token = crypto.randomBytes(32).toString('hex');
141
+ const filePath = tokenPath();
142
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
143
+ fs.writeFileSync(filePath, token, { mode: 0o600 });
144
+ return token;
145
+ }
146
+ export function readToken() {
147
+ const filePath = tokenPath();
148
+ if (!fs.existsSync(filePath))
149
+ return null;
150
+ return fs.readFileSync(filePath, 'utf-8').trim();
151
+ }
152
+ export function removeToken() {
153
+ const filePath = tokenPath();
154
+ if (!fs.existsSync(filePath))
155
+ return false;
156
+ fs.unlinkSync(filePath);
157
+ return true;
158
+ }
159
+ export function writePid(pid) {
160
+ const filePath = pidPath();
161
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
162
+ fs.writeFileSync(filePath, String(pid), { mode: 0o644 });
163
+ }
164
+ export function readPid() {
165
+ const filePath = pidPath();
166
+ if (!fs.existsSync(filePath))
167
+ return null;
168
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
169
+ const n = parseInt(raw, 10);
170
+ return Number.isFinite(n) ? n : null;
171
+ }
172
+ export function removePid() {
173
+ const filePath = pidPath();
174
+ if (!fs.existsSync(filePath))
175
+ return false;
176
+ fs.unlinkSync(filePath);
177
+ return true;
178
+ }
179
+ /**
180
+ * Find the host-cp container by name. Returns null if absent.
181
+ */
182
+ export async function findHostCpContainer() {
183
+ const docker = new Dockerode();
184
+ const containers = await docker.listContainers({ all: true });
185
+ for (const c of containers) {
186
+ const names = (c.Names ?? []).map((n) => n.replace(/^\//, ''));
187
+ if (names.includes('olam-host-cp')) {
188
+ return {
189
+ id: c.Id.slice(0, 12),
190
+ name: 'olam-host-cp',
191
+ state: c.State,
192
+ status: c.Status,
193
+ };
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+ /**
199
+ * Phase host-cp-reliability: Probe for a running host CP, supporting
200
+ * both bare-node (process on host) and container deployments.
201
+ *
202
+ * Strategy:
203
+ * 1. HTTP probe: try http://127.0.0.1:19000/api/bootstrap. If the
204
+ * token file exists and the endpoint returns 200, host-cp is running.
205
+ * Mode is 'bare' if no docker container found, 'container' otherwise.
206
+ * 2. Docker fallback: if HTTP probe fails, look for the docker container.
207
+ * If found+running, probe its port.
208
+ *
209
+ * Returns null if both fail, along with probe diagnostics in the
210
+ * returned ProbeFailure for fail-loud error messages.
211
+ *
212
+ * @returns {Promise<{ url: string; mode: 'bare' | 'container' } | null>}
213
+ * Result on success, null on failure.
214
+ */
215
+ export async function probeHostCp() {
216
+ const candidateUrl = `http://127.0.0.1:${HOST_CP_PORT}`;
217
+ // Step 1: HTTP probe (works for both bare-node and container, since
218
+ // compose.yaml binds 127.0.0.1:19000. The /api/bootstrap endpoint is
219
+ // unauthed and always returns 200 if host-cp is up.)
220
+ let httpOk = false;
221
+ try {
222
+ const res = await fetch(`${candidateUrl}/api/bootstrap`, {
223
+ signal: AbortSignal.timeout(2000),
224
+ });
225
+ httpOk = res.ok;
226
+ }
227
+ catch {
228
+ httpOk = false;
229
+ }
230
+ if (httpOk) {
231
+ // Determine mode: if docker container exists, it's container mode.
232
+ let mode = 'bare';
233
+ try {
234
+ const container = await findHostCpContainer();
235
+ if (container && container.state === 'running') {
236
+ mode = 'container';
237
+ }
238
+ }
239
+ catch {
240
+ // Docker not available — must be bare mode.
241
+ }
242
+ return { url: candidateUrl, mode };
243
+ }
244
+ // Step 2: Docker container fallback. Handles the case where host-cp is
245
+ // a container on a non-default port.
246
+ try {
247
+ const container = await findHostCpContainer();
248
+ if (container && container.state === 'running') {
249
+ // Probe the same 127.0.0.1:19000 (compose always binds there).
250
+ try {
251
+ const res = await fetch(`${candidateUrl}/api/bootstrap`, {
252
+ signal: AbortSignal.timeout(2000),
253
+ });
254
+ if (res.ok) {
255
+ return { url: candidateUrl, mode: 'container' };
256
+ }
257
+ }
258
+ catch {
259
+ // Container found but not responding on 19000 — fall through to null.
260
+ }
261
+ }
262
+ }
263
+ catch {
264
+ // Docker not available — can't check containers.
265
+ }
266
+ return null;
267
+ }
268
+ /**
269
+ * Gather probe failure diagnostics for fail-loud error messages.
270
+ * Returns structured info about what was tried and why it failed.
271
+ */
272
+ export async function gatherProbeFailureDiagnostics() {
273
+ let bootstrapStatus = 'no response';
274
+ try {
275
+ const res = await fetch(`http://127.0.0.1:${HOST_CP_PORT}/api/bootstrap`, {
276
+ signal: AbortSignal.timeout(2000),
277
+ });
278
+ bootstrapStatus = `HTTP ${res.status}`;
279
+ }
280
+ catch (err) {
281
+ bootstrapStatus = err instanceof Error ? err.message : 'connection refused';
282
+ }
283
+ let containerStatus = 'not found';
284
+ try {
285
+ const container = await findHostCpContainer();
286
+ if (!container) {
287
+ containerStatus = 'not found';
288
+ }
289
+ else {
290
+ containerStatus = `found (state: ${container.state})`;
291
+ }
292
+ }
293
+ catch {
294
+ containerStatus = 'docker not available';
295
+ }
296
+ return { bootstrapStatus, containerStatus };
297
+ }
298
+ /**
299
+ * Probe `http://127.0.0.1:19000/health`. Returns the parsed JSON or
300
+ * null on connection error / non-2xx.
301
+ */
302
+ async function probeHealth() {
303
+ try {
304
+ const res = await fetch(`http://127.0.0.1:${HOST_CP_PORT}/health`, {
305
+ signal: AbortSignal.timeout(2000),
306
+ });
307
+ if (!res.ok)
308
+ return null;
309
+ return (await res.json());
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ export function runCompose(args, composeFile, extraEnv = {}) {
316
+ // compose.yaml interpolates `${OLAM_AUTH_SECRET:-}` from the shell
317
+ // env. When the operator's shell didn't export it, the container
318
+ // came up with an empty secret and every host-cp → auth-service
319
+ // call 401'd silently — the SPA showed "0 credentials" even though
320
+ // the vault was intact. We now thread a merged env explicitly so
321
+ // `olam host-cp start` is self-sufficient and doesn't depend on
322
+ // shell state.
323
+ const result = spawnSync('docker', ['compose', '-f', composeFile, ...args], {
324
+ encoding: 'utf-8',
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ env: { ...process.env, ...extraEnv },
327
+ });
328
+ return {
329
+ ok: result.status === 0,
330
+ stdout: result.stdout ?? '',
331
+ stderr: result.stderr ?? '',
332
+ };
333
+ }
334
+ /**
335
+ * Build the extra env vars to pass to `docker compose up`. Pure helper
336
+ * extracted so the env-merge contract is unit-testable without spawning
337
+ * docker. Returns an object with only the keys we set; absent values
338
+ * are omitted so process.env defaults can win when the secret file is
339
+ * missing (and the boot warning surfaces the gap).
340
+ *
341
+ * Exported for tests.
342
+ */
343
+ export function buildComposeEnv(authSecret, ghToken) {
344
+ const env = {};
345
+ if (authSecret !== null && authSecret.length > 0) {
346
+ env.OLAM_AUTH_SECRET = authSecret;
347
+ }
348
+ if (ghToken != null && ghToken.length > 0) {
349
+ env.GH_TOKEN = ghToken;
350
+ }
351
+ return env;
352
+ }
353
+ /**
354
+ * Capture the operator's GitHub token by running `gh auth token` on the
355
+ * host. Returns the token string on success, null when gh is not installed
356
+ * or the operator is not authenticated.
357
+ *
358
+ * Never throws — callers that need a token should check for null and warn,
359
+ * but must not block host-cp from starting.
360
+ *
361
+ * Exported for tests.
362
+ */
363
+ export function captureGhToken() {
364
+ try {
365
+ const result = spawnSync('gh', ['auth', 'token'], {
366
+ encoding: 'utf-8',
367
+ stdio: ['ignore', 'pipe', 'pipe'],
368
+ });
369
+ if (result.status === 0) {
370
+ const token = (result.stdout ?? '').trim();
371
+ return token.length > 0 ? token : null;
372
+ }
373
+ return null;
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ }
379
+ // ── start ─────────────────────────────────────────────────────────
380
+ async function handleStart(opts) {
381
+ // 1. Idempotency check: if container already running, surface URL + exit.
382
+ const existing = await findHostCpContainer();
383
+ if (existing && existing.state === 'running') {
384
+ const health = await probeHealth();
385
+ if (health) {
386
+ printSuccess(`Host CP already running at http://127.0.0.1:${HOST_CP_PORT}`);
387
+ printInfo('Container', existing.id);
388
+ printInfo('Uptime', String(health['uptime_seconds'] ?? 'unknown') + 's');
389
+ return;
390
+ }
391
+ // Container exists but health endpoint isn't responding — possibly
392
+ // mid-boot. Tell the operator + don't restart.
393
+ printWarning('Host CP container running but /health not responding. Wait a few seconds and retry, or stop+start.');
394
+ return;
395
+ }
396
+ // 2. Port-zombie audit. If a stopped container holds :19000, fail
397
+ // fast with the clean error from PortHeldByZombieError.
398
+ try {
399
+ const docker = new Dockerode();
400
+ await auditPortsForZombies(docker, [HOST_CP_PORT]);
401
+ }
402
+ catch (err) {
403
+ if (err instanceof PortHeldByZombieError) {
404
+ printError(`Port ${HOST_CP_PORT} held by zombie container "${err.containerName}" (state: ${err.state}).`);
405
+ printError(`Run: docker rm ${err.containerName}`);
406
+ process.exitCode = 1;
407
+ return;
408
+ }
409
+ throw err;
410
+ }
411
+ // 3. Generate fresh token. Each `start` regenerates so rotated tokens
412
+ // don't linger; old SPA sessions are invalidated.
413
+ const token = writeToken();
414
+ // 4. docker compose up -d.
415
+ const composeFile = findComposeFile();
416
+ if (!fs.existsSync(composeFile)) {
417
+ printError(`compose.yaml not found at ${composeFile}. Run from the olam project root.`);
418
+ removeToken();
419
+ process.exitCode = 1;
420
+ return;
421
+ }
422
+ // Auto-load the shared auth-service secret from ~/.olam/auth-secret
423
+ // so the operator's shell doesn't need to export OLAM_AUTH_SECRET.
424
+ // Missing/empty file → warn now (NOT fatal — host-cp still boots, the
425
+ // SPA still serves; the operator just sees an empty credentials list
426
+ // until they run `olam auth up` to regenerate the secret).
427
+ const authSecret = readAuthSecret();
428
+ if (authSecret === null) {
429
+ printWarning(`${authSecretPath()} not found or empty. host-cp will boot, but ` +
430
+ 'credential surfaces (auth fleet, hotswap) will fail with 401 until ' +
431
+ 'you run `olam auth up` to (re)generate the shared secret.');
432
+ }
433
+ // Capture GH_TOKEN from the host's gh CLI so host-cp's pr-merge-poller
434
+ // and /api/prs endpoint can authenticate against the GitHub API.
435
+ // Missing token is non-fatal — host-cp boots fine, just without PR badges.
436
+ const ghToken = captureGhToken();
437
+ if (ghToken === null) {
438
+ printWarning('GitHub CLI not authenticated; PR badges will not appear in the inbox. ' +
439
+ 'Run `gh auth login` then `olam host-cp restart`.');
440
+ }
441
+ const result = runCompose(['up', '-d', '--build'], composeFile, buildComposeEnv(authSecret, ghToken));
442
+ if (!result.ok) {
443
+ printError('docker compose up failed');
444
+ process.stderr.write(result.stderr);
445
+ removeToken();
446
+ process.exitCode = 1;
447
+ return;
448
+ }
449
+ // 5. Wait briefly for /health to come up (max 10s).
450
+ const deadline = Date.now() + 10_000;
451
+ let healthy = false;
452
+ while (Date.now() < deadline) {
453
+ const h = await probeHealth();
454
+ if (h) {
455
+ healthy = true;
456
+ break;
457
+ }
458
+ await new Promise((r) => setTimeout(r, 500));
459
+ }
460
+ if (!healthy) {
461
+ printWarning('Host CP started but /health did not respond within 10s. Check `docker compose logs host-cp`.');
462
+ }
463
+ // 6. Resolve container PID for the marker file.
464
+ const container = await findHostCpContainer();
465
+ if (container) {
466
+ // We can't easily get the host-side PID without docker inspect; the
467
+ // file primarily marks "started by olam host-cp start" presence, not
468
+ // a true OS PID. Use 1 as a sentinel; D2 doesn't depend on the value.
469
+ writePid(1);
470
+ }
471
+ // 7. Surface the URL + token.
472
+ printSuccess(`Host CP running at http://127.0.0.1:${HOST_CP_PORT}`);
473
+ if (opts.showToken) {
474
+ printInfo('Token', token);
475
+ }
476
+ else {
477
+ printInfo('Token', `(written to ${tokenPath()}; pass --show-token to print)`);
478
+ }
479
+ printInfo('Open', `http://127.0.0.1:${HOST_CP_PORT}`);
480
+ }
481
+ // ── stop ──────────────────────────────────────────────────────────
482
+ async function handleStop() {
483
+ const composeFile = findComposeFile();
484
+ if (!fs.existsSync(composeFile)) {
485
+ printWarning(`compose.yaml not found at ${composeFile}. Cleaning up token + PID anyway.`);
486
+ removeToken();
487
+ removePid();
488
+ return;
489
+ }
490
+ const existing = await findHostCpContainer();
491
+ if (!existing) {
492
+ printInfo('Host CP', 'not running');
493
+ removeToken();
494
+ removePid();
495
+ return;
496
+ }
497
+ const result = runCompose(['down'], composeFile);
498
+ if (!result.ok) {
499
+ printError('docker compose down failed');
500
+ process.stderr.write(result.stderr);
501
+ process.exitCode = 1;
502
+ return;
503
+ }
504
+ removeToken();
505
+ removePid();
506
+ printSuccess('Host CP stopped');
507
+ }
508
+ async function buildStatusReport() {
509
+ const container = await findHostCpContainer();
510
+ const health = await probeHealth();
511
+ const tokenFile = tokenPath();
512
+ const tokenPresent = fs.existsSync(tokenFile);
513
+ let tokenModeOk = false;
514
+ if (tokenPresent) {
515
+ const mode = fs.statSync(tokenFile).mode & 0o777;
516
+ tokenModeOk = mode === 0o600;
517
+ }
518
+ const pidPresent = fs.existsSync(pidPath());
519
+ let stack;
520
+ if (!container) {
521
+ stack = 'not_started';
522
+ }
523
+ else if (container.state === 'running' && health) {
524
+ stack = 'running';
525
+ }
526
+ else if (container.state === 'running') {
527
+ stack = 'partial';
528
+ }
529
+ else {
530
+ stack = 'stopped';
531
+ }
532
+ return {
533
+ stack,
534
+ container,
535
+ health,
536
+ token_present: tokenPresent,
537
+ token_mode_ok: tokenModeOk,
538
+ pid_present: pidPresent,
539
+ url: `http://127.0.0.1:${HOST_CP_PORT}`,
540
+ };
541
+ }
542
+ async function handleStatus(opts) {
543
+ const report = await buildStatusReport();
544
+ if (opts.json) {
545
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
546
+ process.exitCode = report.stack === 'running' ? 0 : 1;
547
+ return;
548
+ }
549
+ printHeader('Host CP Status');
550
+ printInfo('Stack', report.stack);
551
+ printInfo('URL', report.url);
552
+ if (report.container) {
553
+ printInfo('Container', `${report.container.id} (${report.container.state})`);
554
+ printInfo('Status line', report.container.status);
555
+ }
556
+ else {
557
+ printInfo('Container', 'not found (run `olam host-cp start`)');
558
+ }
559
+ if (report.health) {
560
+ printInfo('Health', 'ok');
561
+ printInfo('Uptime', String(report.health['uptime_seconds'] ?? 'unknown') + 's');
562
+ const cache = report.health['cache'];
563
+ if (cache) {
564
+ printInfo('Cached worlds', String(cache.worlds?.length ?? 0));
565
+ printInfo('Cache TTL', `${cache.ttl_sec ?? 'unknown'}s`);
566
+ }
567
+ const sse = report.health['sse'];
568
+ if (sse) {
569
+ printInfo('SSE active', `${sse.active ?? 0} / ${sse.cap ?? 0}`);
570
+ }
571
+ }
572
+ else {
573
+ printInfo('Health', 'not responding');
574
+ }
575
+ printInfo('Token file', report.token_present ? (report.token_mode_ok ? 'present (mode 600)' : 'present (BAD MODE — should be 600)') : 'absent');
576
+ printInfo('PID file', report.pid_present ? 'present' : 'absent');
577
+ process.exitCode = report.stack === 'running' ? 0 : 1;
578
+ }
579
+ // ── Register ──────────────────────────────────────────────────────
580
+ export function registerHostCp(program) {
581
+ const hostCp = program
582
+ .command('host-cp')
583
+ .description('Manage the Olam host control plane container');
584
+ hostCp
585
+ .command('start')
586
+ .description('Start the host CP container (token regenerated each call)')
587
+ .option('--show-token', 'Print the generated token to stdout (default: hide)')
588
+ .action(async (opts) => {
589
+ await handleStart({ showToken: opts.showToken === true });
590
+ });
591
+ hostCp
592
+ .command('stop')
593
+ .description('Stop the host CP container + remove token + PID files')
594
+ .action(async () => {
595
+ await handleStop();
596
+ });
597
+ hostCp
598
+ .command('status')
599
+ .description('Show host CP container + health diagnostics')
600
+ .option('--json', 'Output as JSON (machine-parseable; sets exit code)')
601
+ .action(async (opts) => {
602
+ await handleStatus({ json: opts.json === true });
603
+ });
604
+ hostCp
605
+ .command('register')
606
+ .description('Register a world with the running host CP so it appears in the unified UI')
607
+ .requiredOption('--world <id>', 'World id (the docker container suffix, e.g. gold-arc-1454)')
608
+ .option('--port <port>', 'Override per-world CP port; default: discovered from `olam list`')
609
+ .action(async (opts) => {
610
+ await handleRegister({ world: opts.world, port: opts.port });
611
+ });
612
+ hostCp
613
+ .command('deregister')
614
+ .description('Remove a world from the host CP registry (does NOT destroy the world)')
615
+ .requiredOption('--world <id>', 'World id to remove')
616
+ .action(async (opts) => {
617
+ await handleDeregister({ world: opts.world });
618
+ });
619
+ }
620
+ // ── Register / Deregister handlers ────────────────────────────────
621
+ async function discoverWorldPort(worldId) {
622
+ try {
623
+ const { loadContext } = await import('../context.js');
624
+ const { ctx } = await loadContext();
625
+ if (!ctx)
626
+ return null;
627
+ const world = await ctx.worldManager.getWorld(worldId);
628
+ if (!world)
629
+ return null;
630
+ // Per-world CP base port = 19080. Per-world host port = base + offset.
631
+ // Matches the docker provider's port allocation in
632
+ // packages/adapters/src/docker/container.ts (search for 19080).
633
+ return 19080 + world.portOffset;
634
+ }
635
+ catch {
636
+ return null;
637
+ }
638
+ }
639
+ async function readHostCpToken() {
640
+ const tp = tokenPath();
641
+ if (!fs.existsSync(tp))
642
+ return null;
643
+ return fs.readFileSync(tp, 'utf-8').trim();
644
+ }
645
+ /**
646
+ * Phase D6 (olam-dogfood-vision): open a Host CP URL in the operator's
647
+ * default browser.
648
+ *
649
+ * D-phase audit follow-up (HIGH-1): the helper now lives in
650
+ * `@olam/core/src/util/open-url.ts` so both this CLI and the MCP
651
+ * server can import it statically. Earlier shape duplicated the
652
+ * helper here and required @olam/mcp-server to dynamic-import the
653
+ * compiled CLI dist — fragile under stale-build conditions. Static
654
+ * import via @olam/core (already a declared dep on both packages)
655
+ * removes the cross-package dynamic-import + as-never-cast pattern.
656
+ *
657
+ * Re-exported here so existing callers don't need to update their
658
+ * imports.
659
+ */
660
+ export { openUrl as openHostCpUrl } from '@olam/core/src/util/open-url.js';
661
+ /**
662
+ * Phase C8 (olam-dogfood-vision): generic host-cp proxy caller for
663
+ * `/api/world/<id>/*` routes. Used by `olam lanes` subcommands to
664
+ * reach the per-world CP through host-cp's auth-injecting proxy
665
+ * (host-cp injects X-Olam-Secret server-side; the operator only
666
+ * sees the Bearer token).
667
+ *
668
+ * Returns the parsed JSON body on success, or {ok:false, error} on
669
+ * any failure (token missing, network error, non-2xx response).
670
+ *
671
+ * @param method
672
+ * @param worldId
673
+ * @param path the per-world CP path including leading slash, e.g.
674
+ * '/lanes', '/lanes/foo', '/lanes/foo/dispatch'
675
+ * @param body optional JSON body for POST/DELETE
676
+ */
677
+ export async function callHostCpProxy(method, worldId, path, body) {
678
+ const token = await readHostCpToken();
679
+ if (!token)
680
+ return { ok: false, status: 0, error: 'no token (host CP not started)' };
681
+ const url = `http://127.0.0.1:${HOST_CP_PORT}/api/world/${encodeURIComponent(worldId)}${path}`;
682
+ try {
683
+ const headers = {
684
+ Authorization: `Bearer ${token}`,
685
+ };
686
+ if (body !== undefined)
687
+ headers['Content-Type'] = 'application/json';
688
+ const res = await fetch(url, {
689
+ method,
690
+ headers,
691
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
692
+ });
693
+ if (!res.ok) {
694
+ const text = await res.text().catch(() => '');
695
+ // Try to parse the error body as JSON so callers can surface
696
+ // structured errors (e.g., 409 conflict with currentTask).
697
+ let errMsg = text || `HTTP ${res.status}`;
698
+ try {
699
+ const parsed = JSON.parse(text);
700
+ if (parsed && typeof parsed === 'object' && 'error' in parsed) {
701
+ errMsg = String(parsed.error);
702
+ }
703
+ }
704
+ catch { /* keep raw text */ }
705
+ return { ok: false, status: res.status, error: errMsg };
706
+ }
707
+ const data = await res.json().catch(() => null);
708
+ return { ok: true, status: res.status, data };
709
+ }
710
+ catch (err) {
711
+ return {
712
+ ok: false,
713
+ status: 0,
714
+ error: err instanceof Error ? err.message : String(err),
715
+ };
716
+ }
717
+ }
718
+ /**
719
+ * POST/DELETE the host-cp admin registry endpoint. Exported so other
720
+ * commands (create, destroy) can auto-register without re-implementing
721
+ * the fetch dance.
722
+ */
723
+ export async function callHostCpRegistry(method, body) {
724
+ const token = await readHostCpToken();
725
+ if (!token)
726
+ return { ok: false, status: 0, error: 'no token (host CP not started)' };
727
+ const url = method === 'DELETE'
728
+ ? `http://127.0.0.1:${HOST_CP_PORT}/api/admin/registry/${encodeURIComponent(body.id)}`
729
+ : `http://127.0.0.1:${HOST_CP_PORT}/api/admin/registry`;
730
+ try {
731
+ const res = await fetch(url, {
732
+ method,
733
+ headers: {
734
+ Authorization: `Bearer ${token}`,
735
+ ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
736
+ },
737
+ ...(method === 'POST' ? { body: JSON.stringify(body) } : {}),
738
+ });
739
+ if (!res.ok) {
740
+ const text = await res.text().catch(() => '');
741
+ return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
742
+ }
743
+ return { ok: true, status: res.status };
744
+ }
745
+ catch (err) {
746
+ return {
747
+ ok: false,
748
+ status: 0,
749
+ error: err instanceof Error ? err.message : String(err),
750
+ };
751
+ }
752
+ }
753
+ async function handleRegister(opts) {
754
+ printHeader('Register world with host CP');
755
+ let port = null;
756
+ if (opts.port) {
757
+ port = parseInt(opts.port, 10);
758
+ if (!Number.isFinite(port) || port <= 0) {
759
+ printError(`Invalid --port value: ${opts.port}`);
760
+ process.exitCode = 1;
761
+ return;
762
+ }
763
+ }
764
+ else {
765
+ port = await discoverWorldPort(opts.world);
766
+ if (port === null) {
767
+ printError(`Could not discover port for world ${opts.world}. Pass --port explicitly or check that the world exists in \`olam list\`.`);
768
+ process.exitCode = 1;
769
+ return;
770
+ }
771
+ }
772
+ const result = await callHostCpRegistry('POST', { id: opts.world, port });
773
+ if (!result.ok) {
774
+ printError(`Register failed: ${result.error}`);
775
+ if (result.status === 0) {
776
+ printInfo('Hint', 'Is host CP running? `olam host-cp status`');
777
+ }
778
+ process.exitCode = 1;
779
+ return;
780
+ }
781
+ printSuccess(`Registered ${opts.world} → :${port}`);
782
+ printInfo('UI', `http://127.0.0.1:${HOST_CP_PORT}/world/${encodeURIComponent(opts.world)}`);
783
+ }
784
+ async function handleDeregister(opts) {
785
+ printHeader('Deregister world from host CP');
786
+ const result = await callHostCpRegistry('DELETE', { id: opts.world });
787
+ if (!result.ok) {
788
+ printError(`Deregister failed: ${result.error}`);
789
+ if (result.status === 0) {
790
+ printInfo('Hint', 'Is host CP running? `olam host-cp status`');
791
+ }
792
+ process.exitCode = 1;
793
+ return;
794
+ }
795
+ printSuccess(`Deregistered ${opts.world}`);
796
+ }
797
+ //# sourceMappingURL=host-cp.js.map