@pleri/olam-cli 0.1.66 → 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.
- package/dist/__tests__/audit-publish-deps-contract.test.d.ts +26 -0
- package/dist/__tests__/audit-publish-deps-contract.test.d.ts.map +1 -0
- package/dist/__tests__/audit-publish-deps-contract.test.js +86 -0
- package/dist/__tests__/audit-publish-deps-contract.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +95 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +53 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/repos.d.ts +11 -0
- package/dist/commands/repos.d.ts.map +1 -0
- package/dist/commands/repos.js +92 -0
- package/dist/commands/repos.js.map +1 -0
- package/dist/image-digests.json +5 -5
- package/dist/index.js +407 -118
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.js +453 -172
- package/host-cp/src/listening-server-poller.mjs +141 -0
- package/host-cp/src/port-bridge-manager.mjs +290 -0
- package/host-cp/src/server.mjs +48 -94
- package/package.json +4 -2
|
@@ -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();
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
1611
|
-
// GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world)
|
|
1612
|
-
//
|
|
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
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
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
|
-
], {
|
|
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.
|
|
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",
|