sema-cli 0.1.0
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/README.md +78 -0
- package/experiments/spike-shell-stream/bin/analytics.js +209 -0
- package/experiments/spike-shell-stream/bin/sema.js +322 -0
- package/experiments/spike-shell-stream/bin/start.js +387 -0
- package/experiments/spike-shell-stream/mac-agent/agent.js +450 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.js +189 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.test.js +307 -0
- package/experiments/spike-shell-stream/mac-agent/session.js +38 -0
- package/experiments/spike-shell-stream/mobile-web/inbox.html +431 -0
- package/experiments/spike-shell-stream/mobile-web/index.html +1093 -0
- package/experiments/spike-shell-stream/mobile-web/landing.html +586 -0
- package/experiments/spike-shell-stream/mobile-web/pair.html +304 -0
- package/experiments/spike-shell-stream/relay-server/server.js +1085 -0
- package/experiments/spike-shell-stream/shared/crypto.js +138 -0
- package/experiments/spike-shell-stream/shared/crypto.test.js +350 -0
- package/package.json +52 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn, fork } = require("node:child_process");
|
|
4
|
+
const http = require("node:http");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { generateKeyPair, exportPublicKey, exportPrivateKey } = require(path.join(__dirname, "..", "shared", "crypto"));
|
|
8
|
+
|
|
9
|
+
const PORT = Number(process.env.PORT || 8787);
|
|
10
|
+
const HOST = process.env.HOST || "0.0.0.0";
|
|
11
|
+
const RELAY_HTTP = `http://127.0.0.1:${PORT}`;
|
|
12
|
+
const SPIKE_DIR = path.resolve(__dirname, "..");
|
|
13
|
+
const RELAY_SCRIPT = path.join(SPIKE_DIR, "relay-server", "server.js");
|
|
14
|
+
const AGENT_SCRIPT = path.join(SPIKE_DIR, "mac-agent", "agent.js");
|
|
15
|
+
|
|
16
|
+
// CLI arguments: one or more commands to run (e.g., "claude", "claude codex")
|
|
17
|
+
const COMMANDS = process.argv.slice(2);
|
|
18
|
+
const commands = COMMANDS.length > 0 ? COMMANDS : [null]; // [null] = default shell
|
|
19
|
+
|
|
20
|
+
let relayProcess = null;
|
|
21
|
+
const agentProcesses = []; // { process, command, label, sessionId }
|
|
22
|
+
let shuttingDown = false;
|
|
23
|
+
|
|
24
|
+
// --- LAN IP Detection ---
|
|
25
|
+
|
|
26
|
+
function detectLanIp() {
|
|
27
|
+
const interfaces = os.networkInterfaces();
|
|
28
|
+
const candidates = [];
|
|
29
|
+
|
|
30
|
+
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
31
|
+
for (const addr of addrs) {
|
|
32
|
+
if (addr.family !== "IPv4" || addr.internal) continue;
|
|
33
|
+
if (addr.address.startsWith("169.254.")) continue; // APIPA
|
|
34
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr.address)) continue; // Docker/VPN private
|
|
35
|
+
candidates.push({ name, address: addr.address });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Prefer en0/en1 (macOS WiFi/Ethernet)
|
|
40
|
+
const preferred = candidates.find(
|
|
41
|
+
(c) => c.name === "en0" || c.name === "en1",
|
|
42
|
+
);
|
|
43
|
+
if (preferred) return preferred.address;
|
|
44
|
+
|
|
45
|
+
if (candidates.length === 1) return candidates[0].address;
|
|
46
|
+
|
|
47
|
+
if (candidates.length > 1) {
|
|
48
|
+
console.log(
|
|
49
|
+
`\x1b[33m Multiple network interfaces found:\x1b[0m`,
|
|
50
|
+
);
|
|
51
|
+
for (const c of candidates) {
|
|
52
|
+
console.log(` ${c.name}: ${c.address}`);
|
|
53
|
+
}
|
|
54
|
+
console.log(` Using: ${candidates[0].address}\n`);
|
|
55
|
+
return candidates[0].address;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return "127.0.0.1";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- HTTP helpers ---
|
|
62
|
+
|
|
63
|
+
function httpGet(url) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const req = http.get(url, { timeout: 2000 }, (res) => {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
res.on("data", (c) => chunks.push(c));
|
|
68
|
+
res.on("end", () =>
|
|
69
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
req.on("error", reject);
|
|
73
|
+
req.on("timeout", () => {
|
|
74
|
+
req.destroy();
|
|
75
|
+
reject(new Error("timeout"));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function httpPost(url, body = null) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const parsedUrl = new URL(url);
|
|
83
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
84
|
+
const req = http.request(
|
|
85
|
+
{
|
|
86
|
+
hostname: parsedUrl.hostname,
|
|
87
|
+
port: parsedUrl.port,
|
|
88
|
+
path: parsedUrl.pathname,
|
|
89
|
+
method: "POST",
|
|
90
|
+
timeout: 5000,
|
|
91
|
+
headers: payload
|
|
92
|
+
? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }
|
|
93
|
+
: {},
|
|
94
|
+
},
|
|
95
|
+
(res) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
res.on("data", (c) => chunks.push(c));
|
|
98
|
+
res.on("end", () => {
|
|
99
|
+
try {
|
|
100
|
+
resolve({
|
|
101
|
+
status: res.statusCode,
|
|
102
|
+
data: JSON.parse(Buffer.concat(chunks).toString()),
|
|
103
|
+
});
|
|
104
|
+
} catch {
|
|
105
|
+
reject(new Error("Invalid JSON from relay"));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
req.on("error", reject);
|
|
111
|
+
req.on("timeout", () => {
|
|
112
|
+
req.destroy();
|
|
113
|
+
reject(new Error("timeout"));
|
|
114
|
+
});
|
|
115
|
+
if (payload) req.write(payload);
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function isRelayRunning() {
|
|
121
|
+
try {
|
|
122
|
+
const res = await httpGet(`${RELAY_HTTP}/health`);
|
|
123
|
+
return res.status === 200;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function waitForRelay(maxWait = 10000) {
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
while (Date.now() - start < maxWait) {
|
|
132
|
+
if (await isRelayRunning()) return true;
|
|
133
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Relay management ---
|
|
139
|
+
|
|
140
|
+
function startRelay() {
|
|
141
|
+
console.log("[start] Starting relay server...");
|
|
142
|
+
|
|
143
|
+
relayProcess = fork(RELAY_SCRIPT, [], {
|
|
144
|
+
env: { ...process.env, PORT: String(PORT), HOST },
|
|
145
|
+
stdio: "pipe",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
relayProcess.stdout.on("data", (data) => {
|
|
149
|
+
const msg = data.toString().trim();
|
|
150
|
+
if (msg) console.log(` [relay] ${msg.replace(/^\[relay\] /, "")}`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
relayProcess.stderr.on("data", (data) => {
|
|
154
|
+
const msg = data.toString().trim();
|
|
155
|
+
if (msg.includes("EADDRINUSE")) {
|
|
156
|
+
console.error(
|
|
157
|
+
`\n\x1b[31m✗ Port ${PORT} is already in use.\x1b[0m`,
|
|
158
|
+
);
|
|
159
|
+
console.error(
|
|
160
|
+
` Run: lsof -ti:${PORT} | xargs kill (or use PORT=<port> to change)`,
|
|
161
|
+
);
|
|
162
|
+
cleanup();
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
if (msg) console.error(` [relay:err] ${msg}`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
relayProcess.on("exit", (code) => {
|
|
169
|
+
if (!shuttingDown) {
|
|
170
|
+
console.error(
|
|
171
|
+
`\n\x1b[31m✗ Relay exited unexpectedly (code: ${code}).\x1b[0m`,
|
|
172
|
+
);
|
|
173
|
+
cleanup();
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Agent management ---
|
|
180
|
+
|
|
181
|
+
function startAgent(sessionId, macToken, macPrivateKey, macPublicKey, command) {
|
|
182
|
+
const label = command || "shell";
|
|
183
|
+
const env = {
|
|
184
|
+
...process.env,
|
|
185
|
+
SESSION_ID: sessionId,
|
|
186
|
+
SESSION_TOKEN: macToken,
|
|
187
|
+
RELAY_URL: `ws://127.0.0.1:${PORT}/ws`,
|
|
188
|
+
MAC_PRIVATE_KEY: macPrivateKey,
|
|
189
|
+
MAC_PUBLIC_KEY: macPublicKey,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (command) {
|
|
193
|
+
env.CLI_COMMAND = command;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const agent = fork(AGENT_SCRIPT, [], {
|
|
197
|
+
env,
|
|
198
|
+
stdio: "pipe",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
agent.stdout.on("data", (data) => {
|
|
202
|
+
const msg = data.toString().trim();
|
|
203
|
+
if (msg) console.log(` [${label}] ${msg.replace(/^\[mac-agent\] /, "")}`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
agent.stderr.on("data", (data) => {
|
|
207
|
+
const msg = data.toString().trim();
|
|
208
|
+
if (msg) console.error(` [${label}:err] ${msg}`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
agent.on("exit", (code) => {
|
|
212
|
+
if (!shuttingDown) {
|
|
213
|
+
console.log(`\n[start] Agent "${label}" exited (code: ${code || 0}).`);
|
|
214
|
+
// Remove from agent list
|
|
215
|
+
const idx = agentProcesses.findIndex((a) => a.process === agent);
|
|
216
|
+
if (idx >= 0) agentProcesses.splice(idx, 1);
|
|
217
|
+
// If no agents left, shut down
|
|
218
|
+
if (agentProcesses.length === 0) {
|
|
219
|
+
console.log("[start] All agents exited. Shutting down.");
|
|
220
|
+
cleanup();
|
|
221
|
+
process.exit(code || 0);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return agent;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Cleanup ---
|
|
230
|
+
|
|
231
|
+
const CLEANUP_KILL_MS = 3000;
|
|
232
|
+
|
|
233
|
+
function cleanup() {
|
|
234
|
+
if (shuttingDown) return;
|
|
235
|
+
shuttingDown = true;
|
|
236
|
+
|
|
237
|
+
// SIGTERM all agents
|
|
238
|
+
for (const { process: agent } of agentProcesses) {
|
|
239
|
+
if (agent && !agent.killed) agent.kill("SIGTERM");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// SIGTERM relay
|
|
243
|
+
if (relayProcess && !relayProcess.killed) {
|
|
244
|
+
relayProcess.kill("SIGTERM");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// SIGKILL after timeout
|
|
248
|
+
setTimeout(() => {
|
|
249
|
+
for (const { process: agent } of agentProcesses) {
|
|
250
|
+
if (agent && !agent.killed) agent.kill("SIGKILL");
|
|
251
|
+
}
|
|
252
|
+
if (relayProcess && !relayProcess.killed) relayProcess.kill("SIGKILL");
|
|
253
|
+
}, CLEANUP_KILL_MS);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
process.on("SIGINT", () => {
|
|
257
|
+
console.log("\n[start] Shutting down...");
|
|
258
|
+
cleanup();
|
|
259
|
+
process.exit(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
process.on("SIGTERM", () => {
|
|
263
|
+
cleanup();
|
|
264
|
+
process.exit(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
process.on("uncaughtException", (err) => {
|
|
268
|
+
console.error(`\n\x1b[31m[start] Uncaught error: ${err.message}\x1b[0m`);
|
|
269
|
+
cleanup();
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// --- Display ---
|
|
274
|
+
|
|
275
|
+
function displayMultiSessionInfo(lanIp, sessionInfos) {
|
|
276
|
+
const qrcode = require("qrcode-terminal");
|
|
277
|
+
const inboxUrl = `http://${lanIp}:${PORT}/inbox.html`;
|
|
278
|
+
|
|
279
|
+
console.log("");
|
|
280
|
+
console.log("┌─────────────────────────────────────┐");
|
|
281
|
+
if (sessionInfos.length === 1) {
|
|
282
|
+
console.log("│ \x1b[1;32mSema Session Ready\x1b[0m │");
|
|
283
|
+
} else {
|
|
284
|
+
console.log(`│ \x1b[1;32mSema — ${sessionInfos.length} Agents Ready\x1b[0m │`);
|
|
285
|
+
}
|
|
286
|
+
console.log("└─────────────────────────────────────┘");
|
|
287
|
+
console.log("");
|
|
288
|
+
|
|
289
|
+
// Single QR code pointing to inbox (works for both single and multi session)
|
|
290
|
+
qrcode.generate(inboxUrl, { small: true }, (qr) => {
|
|
291
|
+
console.log(qr);
|
|
292
|
+
console.log("");
|
|
293
|
+
console.log(` \x1b[2mScan with phone camera, or visit:\x1b[0m`);
|
|
294
|
+
console.log(` \x1b[1;36m${inboxUrl}\x1b[0m`);
|
|
295
|
+
console.log("");
|
|
296
|
+
|
|
297
|
+
// Compact session list
|
|
298
|
+
if (sessionInfos.length > 1) {
|
|
299
|
+
console.log(" Sessions:");
|
|
300
|
+
}
|
|
301
|
+
for (let i = 0; i < sessionInfos.length; i++) {
|
|
302
|
+
const info = sessionInfos[i];
|
|
303
|
+
const prefix = sessionInfos.length > 1 ? ` ${i + 1}. ` : " ";
|
|
304
|
+
const label = info.label.padEnd(12);
|
|
305
|
+
console.log(`${prefix}\x1b[1m${label}\x1b[0m Code: \x1b[1;36m${info.code}\x1b[0m`);
|
|
306
|
+
}
|
|
307
|
+
console.log("");
|
|
308
|
+
console.log(" \x1b[2mPress Ctrl+C to stop.\x1b[0m");
|
|
309
|
+
console.log("");
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- Main ---
|
|
314
|
+
|
|
315
|
+
async function main() {
|
|
316
|
+
console.log("");
|
|
317
|
+
console.log(" \x1b[1mSema\x1b[0m");
|
|
318
|
+
console.log("");
|
|
319
|
+
|
|
320
|
+
// 1. Start relay if not running
|
|
321
|
+
const relayWasRunning = await isRelayRunning();
|
|
322
|
+
if (!relayWasRunning) {
|
|
323
|
+
startRelay();
|
|
324
|
+
} else {
|
|
325
|
+
console.log("[start] Relay already running.");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 2. Wait for relay to be ready
|
|
329
|
+
const ready = await waitForRelay();
|
|
330
|
+
if (!ready) {
|
|
331
|
+
console.error("\x1b[31m✗ Relay did not become ready in time.\x1b[0m");
|
|
332
|
+
cleanup();
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 3. Detect LAN IP
|
|
337
|
+
const lanIp = detectLanIp();
|
|
338
|
+
|
|
339
|
+
// 4. Create sessions + start agents for each command
|
|
340
|
+
const sessionInfos = []; // { label, code, sessionId }
|
|
341
|
+
|
|
342
|
+
for (const command of commands) {
|
|
343
|
+
const label = command || "shell";
|
|
344
|
+
|
|
345
|
+
// Generate E2E keypair
|
|
346
|
+
console.log(`[start] Generating keys for ${label}...`);
|
|
347
|
+
const keyPair = generateKeyPair();
|
|
348
|
+
const macPublicKey = exportPublicKey(keyPair.publicKey);
|
|
349
|
+
const macPrivateKey = exportPrivateKey(keyPair.privateKey);
|
|
350
|
+
|
|
351
|
+
// Create session
|
|
352
|
+
console.log(`[start] Creating session for ${label}...`);
|
|
353
|
+
let sessionData;
|
|
354
|
+
try {
|
|
355
|
+
const res = await httpPost(`${RELAY_HTTP}/api/sessions`, {
|
|
356
|
+
macPublicKey,
|
|
357
|
+
command: command || null,
|
|
358
|
+
});
|
|
359
|
+
if (res.status !== 201) {
|
|
360
|
+
throw new Error(res.data.error || `HTTP ${res.status}`);
|
|
361
|
+
}
|
|
362
|
+
sessionData = res.data;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error(`\x1b[31m✗ Failed to create session for ${label}: ${err.message}\x1b[0m`);
|
|
365
|
+
cleanup();
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
sessionInfos.push({ label, code: sessionData.code, sessionId: sessionData.sessionId });
|
|
370
|
+
|
|
371
|
+
// Start agent
|
|
372
|
+
const agent = startAgent(
|
|
373
|
+
sessionData.sessionId, sessionData.macToken,
|
|
374
|
+
macPrivateKey, macPublicKey, command,
|
|
375
|
+
);
|
|
376
|
+
agentProcesses.push({ process: agent, command, label, sessionId: sessionData.sessionId });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 5. Display QR + session list
|
|
380
|
+
displayMultiSessionInfo(lanIp, sessionInfos);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
main().catch((err) => {
|
|
384
|
+
console.error(`\x1b[31m✗ ${err.message}\x1b[0m`);
|
|
385
|
+
cleanup();
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|