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.
@@ -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
+ });