@pleri/olam-cli 0.1.68 → 0.1.69

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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * audit-publish-deps-contract.test.ts — contractual regression for the fix
3
+ * that prevents the May-2026 release-pipeline wedge.
4
+ *
5
+ * Bug history: PR #375 added `_require('json-source-map')` to
6
+ * src/commands/config.ts but rewrite-publish-package-json.mjs's hand-curated
7
+ * PUBLISH_DEPS list never picked it up. Versions 0.1.66 / 0.1.67 / 0.1.68
8
+ * published to npm without json-source-map declared. host-cp Dockerfile's
9
+ * `npm install -g @pleri/olam-cli@latest && olam --version` crashed with
10
+ * `Cannot find module 'json-source-map'`. Three consecutive release runs
11
+ * failed; pipeline wedged because the host-cp build sat upstream of the
12
+ * commit-and-publish-npm step that would have shipped the fix.
13
+ *
14
+ * Pin the contract:
15
+ * 1. PUBLISH_DEPS must contain json-source-map.
16
+ * 2. EXTERNAL must contain json-source-map (esbuild can't bundle dynamic
17
+ * _require()'d modules; without external, the require survives in the
18
+ * bundle and crashes if not also installed at runtime).
19
+ * 3. The audit-publish-deps.mjs script exists and is wired into prepublishOnly
20
+ * so this drift class fails fast rather than wedging the pipeline.
21
+ * 4. The host-cp Dockerfile installs from a local tarball (cli.tgz), not
22
+ * from npm @latest — eliminates the wedge class permanently.
23
+ * 5. The release.yml workflow stages cli.tgz before the host-cp build.
24
+ */
25
+ export {};
26
+ //# sourceMappingURL=audit-publish-deps-contract.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-publish-deps-contract.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/audit-publish-deps-contract.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * audit-publish-deps-contract.test.ts — contractual regression for the fix
3
+ * that prevents the May-2026 release-pipeline wedge.
4
+ *
5
+ * Bug history: PR #375 added `_require('json-source-map')` to
6
+ * src/commands/config.ts but rewrite-publish-package-json.mjs's hand-curated
7
+ * PUBLISH_DEPS list never picked it up. Versions 0.1.66 / 0.1.67 / 0.1.68
8
+ * published to npm without json-source-map declared. host-cp Dockerfile's
9
+ * `npm install -g @pleri/olam-cli@latest && olam --version` crashed with
10
+ * `Cannot find module 'json-source-map'`. Three consecutive release runs
11
+ * failed; pipeline wedged because the host-cp build sat upstream of the
12
+ * commit-and-publish-npm step that would have shipped the fix.
13
+ *
14
+ * Pin the contract:
15
+ * 1. PUBLISH_DEPS must contain json-source-map.
16
+ * 2. EXTERNAL must contain json-source-map (esbuild can't bundle dynamic
17
+ * _require()'d modules; without external, the require survives in the
18
+ * bundle and crashes if not also installed at runtime).
19
+ * 3. The audit-publish-deps.mjs script exists and is wired into prepublishOnly
20
+ * so this drift class fails fast rather than wedging the pipeline.
21
+ * 4. The host-cp Dockerfile installs from a local tarball (cli.tgz), not
22
+ * from npm @latest — eliminates the wedge class permanently.
23
+ * 5. The release.yml workflow stages cli.tgz before the host-cp build.
24
+ */
25
+ import { describe, it, expect } from 'vitest';
26
+ import { readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { dirname } from 'node:path';
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = dirname(__filename);
32
+ const CLI_ROOT = join(__dirname, '..', '..');
33
+ const REPO_ROOT = join(CLI_ROOT, '..', '..');
34
+ describe('release pipeline wedge — invariants', () => {
35
+ it('PUBLISH_DEPS contains json-source-map', () => {
36
+ const src = readFileSync(join(CLI_ROOT, 'scripts', 'rewrite-publish-package-json.mjs'), 'utf8');
37
+ expect(src).toMatch(/['"]json-source-map['"]\s*:\s*['"]\^?0\.6\./);
38
+ });
39
+ it('bundle-cli.mjs marks json-source-map external (dynamic _require)', () => {
40
+ const src = readFileSync(join(CLI_ROOT, 'scripts', 'bundle-cli.mjs'), 'utf8');
41
+ // Must appear inside the EXTERNAL array literal.
42
+ const externalBlock = src.slice(src.indexOf('const EXTERNAL = ['));
43
+ expect(externalBlock).toMatch(/['"]json-source-map['"]/);
44
+ });
45
+ it('audit-publish-deps.mjs exists and is wired into prepublishOnly', () => {
46
+ const auditScript = readFileSync(join(CLI_ROOT, 'scripts', 'audit-publish-deps.mjs'), 'utf8');
47
+ expect(auditScript).toMatch(/PUBLISH_DEPS/);
48
+ expect(auditScript).toMatch(/EXTERNAL/);
49
+ const pkg = JSON.parse(readFileSync(join(CLI_ROOT, 'package.json'), 'utf8'));
50
+ // prepublishOnly must run audit:publish-deps before rewrite-publish-package-json.
51
+ expect(pkg.scripts.prepublishOnly).toMatch(/audit:publish-deps/);
52
+ // The script itself must exist as an npm script too.
53
+ expect(pkg.scripts['audit:publish-deps']).toBeDefined();
54
+ });
55
+ it('host-cp Dockerfile installs from local cli.tgz, not npm @latest', () => {
56
+ const dockerfile = readFileSync(join(REPO_ROOT, 'packages', 'host-cp', 'Dockerfile'), 'utf8');
57
+ // Must NOT pull from npm @latest — that's the wedge.
58
+ // Match only ACTUAL run-shape lines (npm install at line start or after
59
+ // backslash-continuation), not the comment block that documents the
60
+ // historical bug.
61
+ const runLines = dockerfile
62
+ .split('\n')
63
+ .filter((l) => !l.trim().startsWith('#'))
64
+ .join('\n');
65
+ expect(runLines).not.toMatch(/npm install -g @pleri\/olam-cli@latest/);
66
+ // Must install from the staged tarball.
67
+ expect(dockerfile).toMatch(/COPY cli\.tgz/);
68
+ expect(dockerfile).toMatch(/npm install -g \/tmp\/olam-cli\.tgz/);
69
+ });
70
+ it('release.yml stages cli.tgz before host-cp docker build', () => {
71
+ const workflow = readFileSync(join(REPO_ROOT, '.github', 'workflows', 'release.yml'), 'utf8');
72
+ expect(workflow).toMatch(/scripts\/ci\/stage-host-cp-cli\.sh/);
73
+ // Order matters: stage-host-cp-cli.sh must appear BEFORE the
74
+ // docker/build-push-action@v6 invocation in publish-host-cp.
75
+ const publishHostCpBlock = workflow.slice(workflow.indexOf('publish-host-cp:'));
76
+ const stageIdx = publishHostCpBlock.indexOf('stage-host-cp-cli.sh');
77
+ const buildIdx = publishHostCpBlock.indexOf('docker/build-push-action');
78
+ expect(stageIdx).toBeGreaterThan(0);
79
+ expect(buildIdx).toBeGreaterThan(stageIdx);
80
+ });
81
+ it('ci.yml audit job runs audit:publish-deps', () => {
82
+ const ci = readFileSync(join(REPO_ROOT, '.github', 'workflows', 'ci.yml'), 'utf8');
83
+ expect(ci).toMatch(/audit:publish-deps/);
84
+ });
85
+ });
86
+ //# sourceMappingURL=audit-publish-deps-contract.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-publish-deps-contract.test.js","sourceRoot":"","sources":["../../src/__tests__/audit-publish-deps-contract.test.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,9 @@
1
1
  {
2
- "auth": "sha256:1e6d3574077ba70ba908b5ae343e9ff669937468ff1f48e3e9a449ac38578cc6",
3
- "devbox": "sha256:6de8b15bc7e4b07b84ea10b011e939bd874c0ac5c2fb0c4c53db668c6c53b2b8",
4
- "host-cp": "sha256:0a533a638300bda05a45440c62a673300c6d122be74eb83e9b21a0ea4432f174",
5
- "mcp-auth": "sha256:cccf9fc022a3b58080007761ff10e4820080d26491e7b6e758e3e766c9eac896",
2
+ "auth": "sha256:ab26e55f6b6835720596edcc6e969740298ee6b4f42b0a2070886527d3b8d83b",
3
+ "devbox": "sha256:f1b6e9b92c89bc9d76d83a718f0345e7a9a8484fb8157d2007409c413ae80b35",
4
+ "host-cp": "sha256:60b01e6c33251ab52be32bcfd5cd798dda7f9be5078e6a1cc104737471ed73c6",
5
+ "mcp-auth": "sha256:c8dcaf921b5da00474a3c9585dac9b8a095bd2da60836cbc8637995d62b83f9c",
6
6
  "$schema_version": 1,
7
- "$published_version": "0.1.68",
7
+ "$published_version": "0.1.69",
8
8
  "$registry": "ghcr.io/pleri"
9
9
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * listening-server-poller.mjs
3
+ * Discovers listening TCP ports inside a world's devbox container.
4
+ * Dual-mode: Docker HTTP API (container) vs docker exec CLI (bare-node).
5
+ * Cache TTL: 10s per world.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';
10
+ // Skip well-known infra ports — these are always running and not user servers
11
+ const INFRA_PORTS = new Set([8080, 7681]);
12
+
13
+ // Per-world cache: worldId → { ts, servers, error? }
14
+ const cache = new Map();
15
+ const CACHE_TTL_MS = 10_000;
16
+
17
+ function worldContainerName(worldId) {
18
+ return `olam-${worldId}-devbox`;
19
+ }
20
+
21
+ /**
22
+ * Parse `ss -tlnp` output into server rows.
23
+ * Output format:
24
+ * Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
25
+ * tcp LISTEN 0 128 0.0.0.0:5173 0.0.0.0:* users:(("vite",pid=42,fd=8))
26
+ *
27
+ * @param {string} stdout
28
+ * @returns {Array<{port: number, pid: string, cmd: string}>}
29
+ */
30
+ export function parseSsOutput(stdout) {
31
+ const lines = stdout.trim().split('\n').slice(1); // skip header
32
+ const results = [];
33
+ for (const line of lines) {
34
+ const parts = line.trim().split(/\s+/);
35
+ if (parts.length < 5) continue;
36
+ // parts[3] = Local Address:Port (e.g. "0.0.0.0:5173" or "*:5173" or ":::5173")
37
+ const localAddr = parts[3];
38
+ const colonIdx = localAddr.lastIndexOf(':');
39
+ if (colonIdx === -1) continue;
40
+ const portStr = localAddr.slice(colonIdx + 1);
41
+ const port = parseInt(portStr, 10);
42
+ if (!Number.isFinite(port) || port <= 0) continue;
43
+ if (INFRA_PORTS.has(port)) continue;
44
+
45
+ // Extract pid and cmd from process column: users:(("vite",pid=42,fd=8))
46
+ let pid = '';
47
+ let cmd = '';
48
+ const processCol = parts.slice(4).join(' ');
49
+ const pidMatch = /pid=(\d+)/.exec(processCol);
50
+ if (pidMatch) pid = pidMatch[1];
51
+ const cmdMatch = /"([^"]+)"/.exec(processCol);
52
+ if (cmdMatch) cmd = cmdMatch[1];
53
+
54
+ results.push({ port, pid, cmd });
55
+ }
56
+ return results;
57
+ }
58
+
59
+ /**
60
+ * Fetch listening servers for a world. Returns cached result if <10s old.
61
+ * @param {string} worldId
62
+ * @returns {Promise<{ts: number, servers: Array<{port: number, pid: string, cmd: string}>, error?: string}>}
63
+ */
64
+ export async function getListeningServers(worldId) {
65
+ const cached = cache.get(worldId);
66
+ if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached;
67
+
68
+ const containerName = worldContainerName(worldId);
69
+ try {
70
+ let stdout;
71
+ if (DOCKER_HOST === 'docker-cli') {
72
+ const result = spawnSync(
73
+ 'docker', ['exec', containerName, 'ss', '-tlnp'],
74
+ { encoding: 'utf-8', timeout: 3000 },
75
+ );
76
+ if (result.status !== 0 || result.error) {
77
+ const entry = { ts: Date.now(), servers: [], error: 'container not running' };
78
+ cache.set(worldId, entry);
79
+ return entry;
80
+ }
81
+ stdout = result.stdout ?? '';
82
+ } else {
83
+ const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
84
+ const execCreate = await fetch(
85
+ `${apiBase}/containers/${encodeURIComponent(containerName)}/exec`,
86
+ {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({
90
+ AttachStdout: true,
91
+ AttachStderr: false,
92
+ Cmd: ['ss', '-tlnp'],
93
+ }),
94
+ signal: AbortSignal.timeout(3000),
95
+ },
96
+ );
97
+ if (!execCreate.ok) {
98
+ const entry = { ts: Date.now(), servers: [], error: 'container not running' };
99
+ cache.set(worldId, entry);
100
+ return entry;
101
+ }
102
+ const { Id: execId } = await execCreate.json();
103
+ const execStart = await fetch(`${apiBase}/exec/${execId}/start`, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ Detach: false, Tty: false }),
107
+ signal: AbortSignal.timeout(3000),
108
+ });
109
+ // Docker exec start streams multiplexed output (8-byte header per frame)
110
+ const buf = await execStart.arrayBuffer();
111
+ stdout = demuxDockerStream(Buffer.from(buf));
112
+ }
113
+ const servers = parseSsOutput(stdout);
114
+ const entry = { ts: Date.now(), servers };
115
+ cache.set(worldId, entry);
116
+ return entry;
117
+ } catch {
118
+ const entry = { ts: Date.now(), servers: [], error: 'container not running' };
119
+ cache.set(worldId, entry);
120
+ return entry;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Strip Docker stream multiplexing headers (8 bytes per frame: [stream, 0, 0, 0, size32be]).
126
+ * @param {Buffer} buf
127
+ * @returns {string}
128
+ */
129
+ function demuxDockerStream(buf) {
130
+ let output = '';
131
+ let offset = 0;
132
+ while (offset + 8 <= buf.length) {
133
+ const size = buf.readUInt32BE(offset + 4);
134
+ const payload = buf.slice(offset + 8, offset + 8 + size);
135
+ output += payload.toString('utf-8');
136
+ offset += 8 + size;
137
+ }
138
+ return output;
139
+ }
140
+
141
+ export { parseSsOutput as _parseSsOutputForTests };
@@ -0,0 +1,290 @@
1
+ /**
2
+ * port-bridge-manager.mjs
3
+ * Manages socat sidecar containers that bridge host port → world devbox port.
4
+ * Dual-mode: Docker HTTP API (container) vs docker CLI (bare-node).
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';
12
+ const SOCAT_IMAGE = 'alpine/socat';
13
+ const HOST_PORT_MIN = 25000;
14
+ const HOST_PORT_MAX = 25999;
15
+ const INFRA_PORTS = new Set([8080, 7681]);
16
+
17
+ let BRIDGES_PATH =
18
+ process.env.OLAM_PORT_BRIDGES_PATH ??
19
+ path.join(os.homedir(), '.olam', 'port-bridges.json');
20
+ let HOST_IP = '127.0.0.1';
21
+
22
+ // key: `${worldId}:${containerPort}` → { worldId, containerPort, hostPort, containerId, containerName }
23
+ const registry = new Map();
24
+
25
+ export function configure({ bridgesPath, hostIp }) {
26
+ if (bridgesPath && bridgesPath !== BRIDGES_PATH) {
27
+ BRIDGES_PATH = bridgesPath;
28
+ loadState();
29
+ }
30
+ if (hostIp) HOST_IP = hostIp;
31
+ }
32
+
33
+ function bridgeKey(worldId, containerPort) {
34
+ return `${worldId}:${containerPort}`;
35
+ }
36
+
37
+ function bridgeContainerName(worldId, containerPort) {
38
+ return `olam-${worldId}-bridge-${containerPort}`;
39
+ }
40
+
41
+ function loadState() {
42
+ try {
43
+ if (!fs.existsSync(BRIDGES_PATH)) return;
44
+ const raw = fs.readFileSync(BRIDGES_PATH, 'utf-8');
45
+ const data = JSON.parse(raw);
46
+ if (!data || typeof data !== 'object') return;
47
+ for (const [key, entry] of Object.entries(data)) {
48
+ registry.set(key, entry);
49
+ }
50
+ } catch (err) {
51
+ console.error(`port-bridge-manager: loadState failed: ${err.message}`);
52
+ }
53
+ }
54
+
55
+ function saveState() {
56
+ try {
57
+ const dir = path.dirname(BRIDGES_PATH);
58
+ fs.mkdirSync(dir, { recursive: true });
59
+ const data = {};
60
+ for (const [key, entry] of registry) {
61
+ data[key] = entry;
62
+ }
63
+ const tmp = `${BRIDGES_PATH}.tmp-${process.pid}-${Date.now()}`;
64
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
65
+ fs.renameSync(tmp, BRIDGES_PATH);
66
+ } catch (err) {
67
+ console.error(`port-bridge-manager: saveState failed: ${err.message}`);
68
+ }
69
+ }
70
+
71
+ function allocateHostPort() {
72
+ const used = new Set(Array.from(registry.values()).map((e) => e.hostPort));
73
+ for (let p = HOST_PORT_MIN; p <= HOST_PORT_MAX; p++) {
74
+ if (!used.has(p)) return p;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ async function dockerApiBase() {
80
+ return DOCKER_HOST === 'docker-cli'
81
+ ? null // bare-node: no socket proxy HTTP API
82
+ : DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
83
+ }
84
+
85
+ /**
86
+ * Create and start a socat bridge container. Returns containerId.
87
+ * @param {string} worldId
88
+ * @param {number} containerPort
89
+ * @param {number} hostPort
90
+ * @returns {Promise<string>} containerId
91
+ */
92
+ async function createBridgeContainer(worldId, containerPort, hostPort) {
93
+ const name = bridgeContainerName(worldId, containerPort);
94
+ const networkName = `olam-${worldId}`;
95
+ const devboxName = `olam-${worldId}-devbox`;
96
+ const socatCmd = `TCP-LISTEN:${containerPort},fork,reuseaddr TCP:${devboxName}:${containerPort}`;
97
+
98
+ const apiBase = await dockerApiBase();
99
+
100
+ if (!apiBase) {
101
+ // bare-node: use docker CLI
102
+ const args = [
103
+ 'run', '-d',
104
+ '--name', name,
105
+ '--network', networkName,
106
+ '-p', `${HOST_IP}:${hostPort}:${containerPort}`,
107
+ '--label', `olam.world.id=${worldId}`,
108
+ '--label', 'olam.role=server-bridge',
109
+ '--restart', 'unless-stopped',
110
+ SOCAT_IMAGE,
111
+ 'TCP-LISTEN:' + containerPort + ',fork,reuseaddr',
112
+ 'TCP:' + devboxName + ':' + containerPort,
113
+ ];
114
+ const result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
115
+ if (result.status !== 0) {
116
+ throw new Error(result.stderr?.trim() || 'docker run failed');
117
+ }
118
+ return result.stdout.trim(); // container ID
119
+ }
120
+
121
+ // container mode: Docker HTTP API
122
+ const createBody = {
123
+ Image: SOCAT_IMAGE,
124
+ Cmd: socatCmd.split(' '),
125
+ Labels: {
126
+ 'olam.world.id': worldId,
127
+ 'olam.role': 'server-bridge',
128
+ },
129
+ HostConfig: {
130
+ NetworkMode: networkName,
131
+ PortBindings: {
132
+ [`${containerPort}/tcp`]: [{ HostIp: HOST_IP, HostPort: String(hostPort) }],
133
+ },
134
+ RestartPolicy: { Name: 'unless-stopped' },
135
+ },
136
+ };
137
+
138
+ const createResp = await fetch(
139
+ `${apiBase}/containers/create?name=${encodeURIComponent(name)}`,
140
+ {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ body: JSON.stringify(createBody),
144
+ signal: AbortSignal.timeout(10000),
145
+ },
146
+ );
147
+
148
+ if (!createResp.ok) {
149
+ const body = await createResp.text().catch(() => '');
150
+ // If container already exists (409), try to get its ID
151
+ if (createResp.status === 409) {
152
+ const inspectResp = await fetch(
153
+ `${apiBase}/containers/${encodeURIComponent(name)}/json`,
154
+ { signal: AbortSignal.timeout(3000) },
155
+ );
156
+ if (inspectResp.ok) {
157
+ const info = await inspectResp.json();
158
+ return info.Id;
159
+ }
160
+ }
161
+ throw new Error(`container create failed: ${createResp.status} ${body}`);
162
+ }
163
+
164
+ const { Id: containerId } = await createResp.json();
165
+
166
+ const startResp = await fetch(`${apiBase}/containers/${encodeURIComponent(containerId)}/start`, {
167
+ method: 'POST',
168
+ signal: AbortSignal.timeout(5000),
169
+ });
170
+ if (!startResp.ok && startResp.status !== 304) {
171
+ throw new Error(`container start failed: ${startResp.status}`);
172
+ }
173
+
174
+ return containerId;
175
+ }
176
+
177
+ async function removeBridgeContainer(containerName, containerId) {
178
+ const id = containerId || containerName;
179
+ const apiBase = await dockerApiBase();
180
+
181
+ if (!apiBase) {
182
+ spawnSync('docker', ['rm', '-f', id], { encoding: 'utf-8', timeout: 5000 });
183
+ return;
184
+ }
185
+
186
+ // Force remove (stop + delete in one call)
187
+ await fetch(`${apiBase}/containers/${encodeURIComponent(id)}?force=true`, {
188
+ method: 'DELETE',
189
+ signal: AbortSignal.timeout(5000),
190
+ }).catch(() => { /* best-effort */ });
191
+ }
192
+
193
+ /**
194
+ * Expose a world's container port via a socat bridge.
195
+ * Idempotent: returns existing bridge if already active.
196
+ *
197
+ * @param {string} worldId
198
+ * @param {number} containerPort
199
+ * @returns {Promise<{hostPort: number, containerPort: number, url: string, containerId: string}>}
200
+ */
201
+ export async function exposePort(worldId, containerPort) {
202
+ if (INFRA_PORTS.has(containerPort)) {
203
+ throw new Error(`port ${containerPort} is reserved for infrastructure`);
204
+ }
205
+
206
+ const key = bridgeKey(worldId, containerPort);
207
+ const existing = registry.get(key);
208
+ if (existing) {
209
+ return {
210
+ hostPort: existing.hostPort,
211
+ containerPort: existing.containerPort,
212
+ url: `http://${HOST_IP}:${existing.hostPort}`,
213
+ containerId: existing.containerId,
214
+ };
215
+ }
216
+
217
+ const hostPort = allocateHostPort();
218
+ if (hostPort === null) {
219
+ throw new Error('no host ports available in range 25000–25999');
220
+ }
221
+
222
+ const containerName = bridgeContainerName(worldId, containerPort);
223
+ const containerId = await createBridgeContainer(worldId, containerPort, hostPort);
224
+
225
+ const entry = { worldId, containerPort, hostPort, containerId, containerName };
226
+ registry.set(key, entry);
227
+ saveState();
228
+
229
+ return {
230
+ hostPort,
231
+ containerPort,
232
+ url: `http://${HOST_IP}:${hostPort}`,
233
+ containerId,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Remove a port bridge for a world.
239
+ * No-op if bridge doesn't exist.
240
+ *
241
+ * @param {string} worldId
242
+ * @param {number} containerPort
243
+ */
244
+ export async function removePort(worldId, containerPort) {
245
+ const key = bridgeKey(worldId, containerPort);
246
+ const entry = registry.get(key);
247
+ if (!entry) return;
248
+
249
+ registry.delete(key);
250
+ saveState();
251
+
252
+ await removeBridgeContainer(entry.containerName, entry.containerId);
253
+ }
254
+
255
+ /**
256
+ * Remove all bridges for a world. Called on world destroy.
257
+ * @param {string} worldId
258
+ */
259
+ export async function killWorld(worldId) {
260
+ const toDelete = [];
261
+ for (const [key, entry] of registry) {
262
+ if (entry.worldId === worldId) toDelete.push({ key, entry });
263
+ }
264
+ for (const { key, entry } of toDelete) {
265
+ registry.delete(key);
266
+ await removeBridgeContainer(entry.containerName, entry.containerId).catch(() => {});
267
+ }
268
+ if (toDelete.length > 0) saveState();
269
+ }
270
+
271
+ /**
272
+ * List active bridges for a world.
273
+ * @param {string} worldId
274
+ * @returns {Array<{containerPort: number, hostPort: number, url: string}>}
275
+ */
276
+ export function getWorldBridges(worldId) {
277
+ const result = [];
278
+ for (const entry of registry.values()) {
279
+ if (entry.worldId === worldId) {
280
+ result.push({
281
+ containerPort: entry.containerPort,
282
+ hostPort: entry.hostPort,
283
+ url: `http://${HOST_IP}:${entry.hostPort}`,
284
+ });
285
+ }
286
+ }
287
+ return result;
288
+ }
289
+
290
+ loadState();
@@ -55,13 +55,17 @@ import { composeWorldsSources } from './compose-worlds-sources.mjs';
55
55
  import { createWorldPrStateStore } from './world-pr-state.mjs';
56
56
  import { PlanOrchestrator } from './plan-orchestrator.mjs';
57
57
  import { createPrMergePoller } from './pr-merge-poller.mjs';
58
- import { createPrNanny, defaultConsultCodex, defaultDispatchToWorld } from './pr-nanny.mjs';
59
58
  import { parse as parseYaml } from 'yaml';
60
59
  import { startWorldsDbReconciler } from './worlds-db-source.mjs';
61
60
  import { authSecretHint } from './auth-secret-hint.mjs';
62
61
  import * as tunnelManager from './world-tunnel-manager.mjs';
63
- import { getProcessSnapshot, subscribeToProcesses } from './process-poller.mjs';
62
+ import * as bridgeManager from './port-bridge-manager.mjs';
64
63
  import { buildVersionSnapshot } from './version-status.mjs';
64
+ import {
65
+ handleListProcesses,
66
+ handleListServers,
67
+ handleServerBridges,
68
+ } from './routes/process-port.mjs';
65
69
 
66
70
  // ── Deployment-mode detection ─────────────────────────────────────
67
71
  //
@@ -325,6 +329,7 @@ const prPoller = createPrMergePoller({
325
329
  getGhToken: resolveGhToken,
326
330
  destroyWorld: async (worldId) => {
327
331
  tunnelManager.killWorld(worldId);
332
+ await bridgeManager.killWorld(worldId);
328
333
  const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
329
334
  const containerName = `olam-${worldId}-devbox`;
330
335
  try {
@@ -347,17 +352,6 @@ const prPoller = createPrMergePoller({
347
352
  });
348
353
  prPoller.start();
349
354
 
350
- // ── PR Nanny — watch all open PRs and dispatch fixes ──────────────────────
351
-
352
- const prNanny = createPrNanny({
353
- prStateStore,
354
- getGhToken: resolveGhToken,
355
- dispatchToWorld: defaultDispatchToWorld,
356
- consultCodex: defaultConsultCodex,
357
- pollIntervalMs: parseInt(process.env.OLAM_PR_NANNY_POLL_INTERVAL_MS ?? '60000', 10),
358
- });
359
- prNanny.start();
360
-
361
355
  // ── Worlds-DB reconcile loop ────────────────────────────────────
362
356
  //
363
357
  // When host-cp runs bare-node, the CLI's auto-register may not have fired
@@ -630,11 +624,6 @@ const server = http.createServer(async (req, res) => {
630
624
  pr_number: pr.pr_number ?? null,
631
625
  pr_url: pr.pr_url ?? null,
632
626
  pr_state: prState,
633
- nanny_dispatch_count: pr.nanny_dispatch_count ?? 0,
634
- nanny_paused: pr.nanny_paused ?? false,
635
- nanny_escalated: pr.nanny_escalated ?? false,
636
- nanny_loop_halted: pr.nanny_loop_halted ?? false,
637
- nanny_external_blocker: pr.nanny_external_blocker ?? false,
638
627
  });
639
628
  }
640
629
 
@@ -755,8 +744,9 @@ const server = http.createServer(async (req, res) => {
755
744
  const adminDelete = /^\/api\/admin\/registry\/([^/?#]+)$/.exec(url.pathname);
756
745
  if (adminDelete && req.method === 'DELETE') {
757
746
  const id = decodeURIComponent(adminDelete[1]);
758
- // Kill tunnels before removing from registry so no cloudflared procs orphan.
747
+ // Kill tunnels and port bridges before removing from registry.
759
748
  tunnelManager.killWorld(id);
749
+ void bridgeManager.killWorld(id);
760
750
  if (id in WORLDS) {
761
751
  const next = { ...WORLDS };
762
752
  delete next[id];
@@ -1338,62 +1328,13 @@ const server = http.createServer(async (req, res) => {
1338
1328
  return;
1339
1329
  }
1340
1330
 
1341
- // ── PR Nanny operator overrides ──────────────────────────────────────────
1342
- //
1343
- // POST /api/admin/nanny/:worldId/pause — pause nanny for this world
1344
- // POST /api/admin/nanny/:worldId/resume — resume nanny for this world
1345
- // POST /api/admin/nanny/:worldId/escalate — mark escalated, stop dispatching
1346
- // GET /api/admin/nanny — dump nanny state for all worlds
1347
- //
1348
- // Calling-world identity: X-Olam-World-Id header validated against registry.
1349
-
1350
- const nannyAction = /^\/api\/admin\/nanny\/([^/?#]+)\/(pause|resume|escalate)$/.exec(url.pathname);
1351
- if (nannyAction && req.method === 'POST') {
1352
- if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1353
- const worldId = decodeURIComponent(nannyAction[1]);
1354
- const action = nannyAction[2];
1355
- // Validate calling-world identity when header present
1356
- const callerWorldId = req.headers['x-olam-world-id'];
1357
- if (callerWorldId && !(callerWorldId in WORLDS)) {
1358
- return jsonReply(res, 403, { error: 'caller_world_not_registered' });
1359
- }
1360
- const existing = prStateStore.get(worldId);
1361
- if (!existing) return jsonReply(res, 404, { error: 'world_not_found' });
1362
- if (action === 'pause') {
1363
- prStateStore.set(worldId, { nanny_paused: true, nanny_pause_reason: 'operator' });
1364
- } else if (action === 'resume') {
1365
- prStateStore.set(worldId, { nanny_paused: false, nanny_loop_halted: false, nanny_escalated: false });
1366
- } else if (action === 'escalate') {
1367
- prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'operator' });
1368
- }
1369
- return jsonReply(res, 200, prStateStore.get(worldId));
1370
- }
1371
-
1372
- if (url.pathname === '/api/admin/nanny' && req.method === 'GET') {
1373
- if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1374
- const all = prStateStore.getAll();
1375
- const nannyStates = Object.fromEntries(
1376
- Object.entries(all).map(([id, e]) => [id, {
1377
- nanny_dispatch_count: e.nanny_dispatch_count ?? 0,
1378
- nanny_paused: e.nanny_paused ?? false,
1379
- nanny_escalated: e.nanny_escalated ?? false,
1380
- nanny_loop_halted: e.nanny_loop_halted ?? false,
1381
- nanny_external_blocker: e.nanny_external_blocker ?? false,
1382
- nanny_first_dispatch_at: e.nanny_first_dispatch_at ?? null,
1383
- nanny_last_dispatch_at: e.nanny_last_dispatch_at ?? null,
1384
- pr_url: e.pr_url ?? null,
1385
- pr_state: e.pr_state ?? null,
1386
- }]),
1387
- );
1388
- return jsonReply(res, 200, nannyStates);
1389
- }
1390
-
1391
1331
  // DELETE /api/worlds/:worldId — immediate destroy
1392
1332
  const worldDestroyMatch = /^\/api\/worlds\/([^/?#]+)$/.exec(url.pathname);
1393
1333
  if (worldDestroyMatch && req.method === 'DELETE') {
1394
1334
  if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1395
1335
  const worldId = decodeURIComponent(worldDestroyMatch[1]);
1396
1336
  tunnelManager.killWorld(worldId);
1337
+ await bridgeManager.killWorld(worldId);
1397
1338
  const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
1398
1339
  const containerName = `olam-${worldId}-devbox`;
1399
1340
  try {
@@ -1607,22 +1548,40 @@ const server = http.createServer(async (req, res) => {
1607
1548
  return jsonReply(res, 200, { ok: true });
1608
1549
  }
1609
1550
 
1610
- // GET /api/worlds/:id/processes — JSON snapshot of in-container processes.
1611
- // GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world).
1612
- // Inserted after auth middleware and the /api/worlds/:id/services route.
1551
+ // GET /api/worlds/:id/processes
1552
+ // GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world)
1553
+ // Handler: routes/process-port.mjs handleListProcesses
1613
1554
  const processesMatch = /^\/api\/worlds\/([^/?#]+)\/processes(\/stream)?\/?$/.exec(url.pathname);
1614
1555
  if (processesMatch && req.method === 'GET') {
1615
1556
  const worldId = decodeURIComponent(processesMatch[1]);
1616
1557
  if (!(worldId in WORLDS)) {
1617
1558
  return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
1618
1559
  }
1619
- const isStream = processesMatch[2] === '/stream';
1620
- if (isStream) {
1621
- subscribeToProcesses(worldId, res);
1622
- return;
1560
+ return handleListProcesses(req, res, worldId, processesMatch[2] === '/stream');
1561
+ }
1562
+
1563
+ // GET /api/worlds/:id/servers → handleListServers
1564
+ // GET /api/worlds/:id/server-bridges → handleServerBridges
1565
+ // POST /api/worlds/:id/server-bridges → handleServerBridges
1566
+ // DEL /api/worlds/:id/server-bridges/:port → handleServerBridges
1567
+ // Handler: routes/process-port.mjs
1568
+ const serversMatch = /^\/api\/worlds\/([^/?#]+)\/servers$/.exec(url.pathname);
1569
+ if (serversMatch && req.method === 'GET') {
1570
+ const worldId = decodeURIComponent(serversMatch[1]);
1571
+ if (!(worldId in WORLDS)) {
1572
+ return jsonReply(res, 404, { error: 'unknown_world', worldId });
1573
+ }
1574
+ return handleListServers(req, res, worldId);
1575
+ }
1576
+
1577
+ const bridgesMatch = /^\/api\/worlds\/([^/?#]+)\/server-bridges(\/(\d+))?\/?$/.exec(url.pathname);
1578
+ if (bridgesMatch) {
1579
+ const worldId = decodeURIComponent(bridgesMatch[1]);
1580
+ const portSegment = bridgesMatch[3] ? parseInt(bridgesMatch[3], 10) : null;
1581
+ if (!(worldId in WORLDS)) {
1582
+ return jsonReply(res, 404, { error: 'unknown_world', worldId });
1623
1583
  }
1624
- const snapshot = await getProcessSnapshot(worldId);
1625
- return jsonReply(res, 200, snapshot);
1584
+ return handleServerBridges(req, res, worldId, portSegment);
1626
1585
  }
1627
1586
 
1628
1587
  // GET /api/prs — list recent GitHub PRs for the Cmd+K palette.
@@ -1634,27 +1593,22 @@ const server = http.createServer(async (req, res) => {
1634
1593
  return jsonReply(res, 200, prListCacheEntry.data);
1635
1594
  }
1636
1595
  try {
1637
- // `gh pr list` infers the target repo from `cwd`'s git origin.
1638
- // host-cp's process cwd is `/app` (the install dir) NOT a git
1639
- // working tree so without an explicit cwd, gh exits with
1640
- // `fatal: not a git repository`. Run the command from the
1641
- // bind-mounted operator repo (`/operator-repo` per compose.yaml).
1642
- //
1643
- // Use `||` (not `??`) so empty-string OLAM_REPO_PATH falls back
1644
- // too. compose.yaml passes through `${OLAM_REPO_PATH:-}` which
1645
- // injects an empty string into the container env when the operator
1646
- // hasn't set the var; nullish-coalescing would treat that empty as
1647
- // present and pass `cwd: ""` (defaults to process.cwd() = /app, no
1648
- // .git). The original /api/prs cwd fix landed without catching
1649
- // this — both bug shapes had to be exercised before the gap
1650
- // surfaced.
1651
- const operatorRepoPath = process.env.OLAM_REPO_PATH || '/operator-repo';
1596
+ // cwd is REQUIRED without it gh inherits host-cp's process cwd
1597
+ // (`/app`, the install dir, NOT a git working tree) and exits with
1598
+ // `fatal: not a git repository`. The dashboard's PR filter +
1599
+ // Cmd+K palette then quietly stay empty. Pin to the bind-mounted
1600
+ // operator repo (env-overridable). PR #367 originally added this;
1601
+ // PR #382 accidentally regressed it; api-prs-cwd.test.mjs locks
1602
+ // the contract.
1652
1603
  const { stdout } = await execFileAsync('gh', [
1653
1604
  'pr', 'list',
1654
1605
  '--state', 'all',
1655
1606
  '--limit', '50',
1656
1607
  '--json', 'number,title,state,headRefName',
1657
- ], { timeout: 10_000, cwd: operatorRepoPath });
1608
+ ], {
1609
+ timeout: 10_000,
1610
+ cwd: process.env.OLAM_REPO_PATH || '/operator-repo',
1611
+ });
1658
1612
  const data = JSON.parse(stdout.trim() || '[]');
1659
1613
  prListCacheEntry = { data, fetchedAt: Date.now() };
1660
1614
  return jsonReply(res, 200, data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"
@@ -28,12 +28,14 @@
28
28
  "build": "tsc",
29
29
  "dev": "tsx src/index.ts",
30
30
  "test": "vitest run --passWithNoTests",
31
- "test:ci": "vitest run --reporter=basic --passWithNoTests"
31
+ "test:ci": "vitest run --reporter=basic --passWithNoTests",
32
+ "audit:publish-deps": "node scripts/audit-publish-deps.mjs"
32
33
  },
33
34
  "dependencies": {
34
35
  "better-sqlite3": "^12.0.0",
35
36
  "commander": "^13.0.0",
36
37
  "dockerode": "^4.0.0",
38
+ "json-source-map": "^0.6.1",
37
39
  "ora": "^8.0.0",
38
40
  "picocolors": "^1.1.0",
39
41
  "ssh2": "^1.16.0",