@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,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
|