@ishlabs/cli 0.8.1

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.
Files changed (57) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +69 -0
  3. package/dist/auth.d.ts +17 -0
  4. package/dist/auth.js +102 -0
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +82 -0
  7. package/dist/commands/iteration.d.ts +5 -0
  8. package/dist/commands/iteration.js +134 -0
  9. package/dist/commands/simulation.d.ts +10 -0
  10. package/dist/commands/simulation.js +647 -0
  11. package/dist/commands/study.d.ts +5 -0
  12. package/dist/commands/study.js +283 -0
  13. package/dist/commands/tester-profile.d.ts +5 -0
  14. package/dist/commands/tester-profile.js +109 -0
  15. package/dist/commands/tester.d.ts +5 -0
  16. package/dist/commands/tester.js +73 -0
  17. package/dist/commands/workspace.d.ts +5 -0
  18. package/dist/commands/workspace.js +133 -0
  19. package/dist/config.d.ts +13 -0
  20. package/dist/config.js +25 -0
  21. package/dist/connect.d.ts +4 -0
  22. package/dist/connect.js +573 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +89 -0
  25. package/dist/lib/alias-store.d.ts +49 -0
  26. package/dist/lib/alias-store.js +138 -0
  27. package/dist/lib/api-client.d.ts +58 -0
  28. package/dist/lib/api-client.js +177 -0
  29. package/dist/lib/auth.d.ts +8 -0
  30. package/dist/lib/auth.js +73 -0
  31. package/dist/lib/command-helpers.d.ts +28 -0
  32. package/dist/lib/command-helpers.js +131 -0
  33. package/dist/lib/local-sim/actions.d.ts +22 -0
  34. package/dist/lib/local-sim/actions.js +379 -0
  35. package/dist/lib/local-sim/browser.d.ts +63 -0
  36. package/dist/lib/local-sim/browser.js +332 -0
  37. package/dist/lib/local-sim/debug-report.d.ts +21 -0
  38. package/dist/lib/local-sim/debug-report.js +186 -0
  39. package/dist/lib/local-sim/debug.d.ts +44 -0
  40. package/dist/lib/local-sim/debug.js +103 -0
  41. package/dist/lib/local-sim/install.d.ts +25 -0
  42. package/dist/lib/local-sim/install.js +72 -0
  43. package/dist/lib/local-sim/loop.d.ts +60 -0
  44. package/dist/lib/local-sim/loop.js +526 -0
  45. package/dist/lib/local-sim/types.d.ts +232 -0
  46. package/dist/lib/local-sim/types.js +8 -0
  47. package/dist/lib/local-sim/upload.d.ts +6 -0
  48. package/dist/lib/local-sim/upload.js +24 -0
  49. package/dist/lib/output.d.ts +34 -0
  50. package/dist/lib/output.js +675 -0
  51. package/dist/lib/types.d.ts +179 -0
  52. package/dist/lib/types.js +12 -0
  53. package/dist/lib/upload.d.ts +47 -0
  54. package/dist/lib/upload.js +178 -0
  55. package/dist/upgrade.d.ts +1 -0
  56. package/dist/upgrade.js +94 -0
  57. package/package.json +43 -0
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Localhost connect CLI — wraps cloudflared and registers with Ish backend.
3
+ */
4
+ import { spawn, execSync } from "node:child_process";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
8
+ import { loadConfig, saveConfig } from "./config.js";
9
+ import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
10
+ const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
11
+ const HEARTBEAT_INTERVAL = 10_000;
12
+ const MAX_HEARTBEAT_FAILURES = 3;
13
+ const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
14
+ const DEFAULT_API_URL = "https://api.ishlabs.io";
15
+ const API_BASE = "/api/v1";
16
+ const ISH_DIR = join(homedir(), ".ish");
17
+ const CLOUDFLARED_BIN = join(ISH_DIR, "bin", process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
18
+ // --- Simulation card rendering ---
19
+ const CARD_WIDTH = 64;
20
+ function statusColor(status) {
21
+ switch (status) {
22
+ case "running": return "\x1b[32m";
23
+ case "pending": return "\x1b[33m";
24
+ case "completed": return "\x1b[32m";
25
+ case "failed": return "\x1b[31m";
26
+ default: return "\x1b[2m";
27
+ }
28
+ }
29
+ function sentimentColor(sentiment) {
30
+ switch (sentiment) {
31
+ case "Excited":
32
+ case "Satisfied": return GREEN;
33
+ case "Frustrated":
34
+ case "Unsure": return RED;
35
+ default: return DIM; // Neutral
36
+ }
37
+ }
38
+ function padVisible(str, len) {
39
+ const visible = str.replace(/\x1b\[[0-9;]*m/g, "").length;
40
+ return visible >= len ? str : str + " ".repeat(len - visible);
41
+ }
42
+ function truncate(str, maxLen) {
43
+ return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str;
44
+ }
45
+ function row(content, inner) {
46
+ return `\x1b[2m│\x1b[0m ${padVisible(content, inner)}\x1b[2m│\x1b[0m`;
47
+ }
48
+ function renderCard(sim) {
49
+ const inner = CARD_WIDTH - 4;
50
+ const name = truncate(sim.tester_name ?? sim.instance_name ?? sim.tester_id.slice(0, 8), inner - 2);
51
+ // Top border with name
52
+ const nameSegment = `─ ${name} `;
53
+ const topPad = CARD_WIDTH - 2 - nameSegment.length;
54
+ const top = `\x1b[2m┌\x1b[0m${ORANGE}${BOLD}${nameSegment}${RESET}\x1b[2m${"─".repeat(Math.max(0, topPad))}┐\x1b[0m`;
55
+ const li = sim.last_interaction;
56
+ const lines = [top];
57
+ // Status badge: ● Status (N steps) — right-aligned
58
+ const titleStatus = sim.status.charAt(0).toUpperCase() + sim.status.slice(1);
59
+ const steps = `${sim.interaction_count} step${sim.interaction_count !== 1 ? "s" : ""}`;
60
+ const badge = `${statusColor(sim.status)}●${RESET} ${titleStatus} ${DIM}(${steps})${RESET}`;
61
+ const badgePlain = `● ${titleStatus} (${steps})`;
62
+ if (li) {
63
+ // Frame name (bold) + status badge right-aligned
64
+ if (li.current_frame_name) {
65
+ const frameStr = truncate(li.current_frame_name, inner - badgePlain.length - 2);
66
+ const gap = inner - frameStr.length - badgePlain.length;
67
+ lines.push(row(`${BOLD}${frameStr}${RESET}${" ".repeat(Math.max(1, gap))}${badge}`, inner));
68
+ }
69
+ else {
70
+ const pad = inner - badgePlain.length;
71
+ lines.push(row(`${" ".repeat(Math.max(0, pad))}${badge}`, inner));
72
+ }
73
+ // Comment (dim, italic quotes — up to 3 lines)
74
+ if (li.comment) {
75
+ const maxChars = inner - 3;
76
+ const words = li.comment.split(" ");
77
+ const commentLines = [];
78
+ let current = "";
79
+ for (const word of words) {
80
+ const next = current ? `${current} ${word}` : word;
81
+ if (next.length > maxChars && current) {
82
+ commentLines.push(current);
83
+ current = word;
84
+ }
85
+ else {
86
+ current = next;
87
+ }
88
+ if (commentLines.length === 3)
89
+ break;
90
+ }
91
+ if (current && commentLines.length < 3)
92
+ commentLines.push(current);
93
+ if (commentLines.length > 0) {
94
+ commentLines[0] = `"${commentLines[0]}`;
95
+ const lastIdx = commentLines.length - 1;
96
+ commentLines[lastIdx] = `${commentLines[lastIdx]}"`;
97
+ }
98
+ for (const cl of commentLines) {
99
+ lines.push(row(`${DIM}${truncate(cl, inner)}${RESET}`, inner));
100
+ }
101
+ }
102
+ // Action rows — one per action, action_type in cyan
103
+ for (const action of li.actions) {
104
+ if (!action.action_type)
105
+ continue;
106
+ const label = action.element_label
107
+ ? ` ${truncate(action.element_label, inner - action.action_type.length - 2)}`
108
+ : "";
109
+ lines.push(row(`${CYAN}${action.action_type}${RESET}${label}`, inner));
110
+ }
111
+ // Sentiment badge
112
+ if (li.sentiment) {
113
+ const sc = sentimentColor(li.sentiment);
114
+ lines.push(row(`${sc}${li.sentiment}${RESET}`, inner));
115
+ }
116
+ }
117
+ else {
118
+ // No interaction — just show status badge
119
+ const pad = inner - badgePlain.length;
120
+ lines.push(row(`${" ".repeat(Math.max(0, pad))}${badge}`, inner));
121
+ }
122
+ // Bottom border
123
+ lines.push(`\x1b[2m└${"─".repeat(CARD_WIDTH - 2)}┘\x1b[0m`);
124
+ return lines;
125
+ }
126
+ function renderAllCards(simulations) {
127
+ if (simulations.length === 0)
128
+ return [];
129
+ const lines = [];
130
+ const ts = new Date().toLocaleTimeString("en-GB", { hour12: false });
131
+ const study = simulations[0]?.study_name;
132
+ lines.push(`\x1b[2m${study ? `${study} · ` : ""}Updated ${ts}\x1b[0m`);
133
+ lines.push("");
134
+ for (const sim of simulations) {
135
+ lines.push(...renderCard(sim));
136
+ lines.push("");
137
+ }
138
+ return lines;
139
+ }
140
+ let cardLineCount = 0;
141
+ function clearCards() {
142
+ if (cardLineCount > 0) {
143
+ process.stdout.write(`\x1b[${cardLineCount}A`);
144
+ for (let i = 0; i < cardLineCount; i++) {
145
+ process.stdout.write("\x1b[2K\n");
146
+ }
147
+ process.stdout.write(`\x1b[${cardLineCount}A`);
148
+ }
149
+ }
150
+ function renderSimulationCards(simulations) {
151
+ clearCards();
152
+ const lines = renderAllCards(simulations);
153
+ cardLineCount = lines.length;
154
+ if (lines.length > 0) {
155
+ process.stdout.write(lines.join("\n") + "\n");
156
+ }
157
+ }
158
+ // --- Local storage for completed simulations ---
159
+ const SIMULATIONS_DIR = join(ISH_DIR, "simulations");
160
+ const storedTesterIds = new Set();
161
+ function storeCompletedSimulation(sim) {
162
+ if (storedTesterIds.has(sim.tester_id))
163
+ return;
164
+ storedTesterIds.add(sim.tester_id);
165
+ mkdirSync(SIMULATIONS_DIR, { recursive: true });
166
+ const logFile = join(SIMULATIONS_DIR, "history.jsonl");
167
+ const entry = {
168
+ tester_id: sim.tester_id,
169
+ instance_name: sim.instance_name,
170
+ status: sim.status,
171
+ study_name: sim.study_name,
172
+ interaction_count: sim.interaction_count,
173
+ last_interaction: sim.last_interaction,
174
+ completed_at: new Date().toISOString(),
175
+ };
176
+ writeFileSync(logFile, JSON.stringify(entry) + "\n", { flag: "a" });
177
+ }
178
+ // --- Token resolution ---
179
+ async function verifyToken(token, apiUrl) {
180
+ try {
181
+ const resp = await fetch(`${apiUrl}${API_BASE}/connect/active`, {
182
+ headers: { Authorization: `Bearer ${token}` },
183
+ signal: AbortSignal.timeout(10_000),
184
+ });
185
+ // 404 = valid token, no connection (expected). 401/403 = bad token.
186
+ return resp.status !== 401 && resp.status !== 403;
187
+ }
188
+ catch {
189
+ // Network error — can't verify, assume ok
190
+ console.error("Warning: Could not verify token (network error). Proceeding anyway.");
191
+ return true;
192
+ }
193
+ }
194
+ function resolveApiUrl(apiUrlArg) {
195
+ if (apiUrlArg)
196
+ return apiUrlArg;
197
+ return process.env.ISH_API_URL ?? DEFAULT_API_URL;
198
+ }
199
+ /**
200
+ * Resolve an access token, refreshing if needed.
201
+ * Returns both the token and a mutable holder for runtime refresh.
202
+ */
203
+ async function resolveToken(tokenArg, apiUrl) {
204
+ // 1. Explicit token argument
205
+ if (tokenArg)
206
+ return { token: tokenArg, refresh: null };
207
+ // 2. Environment variable
208
+ const envToken = process.env.ISH_TOKEN;
209
+ if (envToken)
210
+ return { token: envToken, refresh: null };
211
+ // 3. Saved config with refresh token
212
+ const config = loadConfig();
213
+ if (config.access_token && config.refresh_token) {
214
+ let accessToken = config.access_token;
215
+ // Refresh if expired or close to expiry
216
+ if (isTokenExpired(accessToken)) {
217
+ try {
218
+ const tokens = await refreshTokens(config.refresh_token);
219
+ accessToken = tokens.accessToken;
220
+ config.access_token = tokens.accessToken;
221
+ config.refresh_token = tokens.refreshToken;
222
+ saveConfig(config);
223
+ }
224
+ catch {
225
+ console.error('Session expired. Run "ish login" to re-authenticate.');
226
+ process.exit(1);
227
+ }
228
+ }
229
+ if (await verifyToken(accessToken, apiUrl)) {
230
+ // Return with refresh capability for long-running tunnel
231
+ const doRefresh = async () => {
232
+ const cfg = loadConfig();
233
+ if (!cfg.refresh_token)
234
+ throw new Error("No refresh token");
235
+ const tokens = await refreshTokens(cfg.refresh_token);
236
+ cfg.access_token = tokens.accessToken;
237
+ cfg.refresh_token = tokens.refreshToken;
238
+ saveConfig(cfg);
239
+ return tokens.accessToken;
240
+ };
241
+ return { token: accessToken, refresh: doRefresh };
242
+ }
243
+ console.error('Saved token is invalid. Run "ish login" to re-authenticate.');
244
+ process.exit(1);
245
+ }
246
+ // 4. Legacy saved token (no refresh token)
247
+ if (config.token) {
248
+ if (await verifyToken(config.token, apiUrl)) {
249
+ return { token: config.token, refresh: null };
250
+ }
251
+ console.error('Saved token is invalid. Run "ish login" to re-authenticate.');
252
+ process.exit(1);
253
+ }
254
+ // 5. No valid token found — direct user to login
255
+ console.error('No valid token found. Run "ish login" to authenticate.');
256
+ process.exit(1);
257
+ }
258
+ // --- Branding ---
259
+ const RESET = "\x1b[0m";
260
+ const ORANGE = "\x1b[38;2;212;117;78m";
261
+ const BOLD = "\x1b[1m";
262
+ const DIM = "\x1b[2m";
263
+ const GREEN = "\x1b[32m";
264
+ const RED = "\x1b[31m";
265
+ const YELLOW = "\x1b[33m";
266
+ const CYAN = "\x1b[36m";
267
+ function printBanner() {
268
+ console.log(`
269
+ ${ORANGE}${BOLD} ██╗███████╗██╗ ██╗
270
+ ██║██╔════╝██║ ██║
271
+ ██║███████╗███████║
272
+ ██║╚════██║██╔══██║
273
+ ██║███████║██║ ██║
274
+ ╚═╝╚══════╝╚═╝ ╚═╝${RESET}
275
+
276
+ Connected
277
+ `);
278
+ }
279
+ // --- Cloudflared ---
280
+ async function resolveCloudflaredBin() {
281
+ // 1. Prefer system-installed cloudflared
282
+ try {
283
+ execSync(process.platform === "win32" ? "where cloudflared" : "which cloudflared", { stdio: "ignore" });
284
+ return "cloudflared";
285
+ }
286
+ catch {
287
+ // Not on PATH
288
+ }
289
+ // 2. Check ~/.ish/bin/cloudflared
290
+ if (existsSync(CLOUDFLARED_BIN))
291
+ return CLOUDFLARED_BIN;
292
+ // 3. Download from Cloudflare releases
293
+ console.log("cloudflared not found. Installing...");
294
+ const url = getCloudflaredDownloadUrl();
295
+ if (!url) {
296
+ printManualInstallInstructions();
297
+ process.exit(1);
298
+ }
299
+ try {
300
+ const binDir = join(ISH_DIR, "bin");
301
+ mkdirSync(binDir, { recursive: true, mode: 0o755 });
302
+ if (url.endsWith(".tgz")) {
303
+ execSync(`curl -fsSL "${url}" | tar xz -C "${binDir}" cloudflared`, { stdio: "ignore" });
304
+ }
305
+ else {
306
+ const resp = await fetch(url);
307
+ if (!resp.ok)
308
+ throw new Error(`HTTP ${resp.status}`);
309
+ writeFileSync(CLOUDFLARED_BIN, Buffer.from(await resp.arrayBuffer()));
310
+ }
311
+ chmodSync(CLOUDFLARED_BIN, 0o755);
312
+ return CLOUDFLARED_BIN;
313
+ }
314
+ catch (e) {
315
+ console.error(`Failed to install cloudflared: ${e instanceof Error ? e.message : e}\n`);
316
+ printManualInstallInstructions();
317
+ process.exit(1);
318
+ }
319
+ }
320
+ function getCloudflaredDownloadUrl() {
321
+ const base = "https://github.com/cloudflare/cloudflared/releases/latest/download";
322
+ const platform = process.platform;
323
+ const arch = process.arch;
324
+ if (platform === "darwin" && arch === "arm64")
325
+ return `${base}/cloudflared-darwin-arm64.tgz`;
326
+ if (platform === "darwin" && arch === "x64")
327
+ return `${base}/cloudflared-darwin-amd64.tgz`;
328
+ if (platform === "linux" && arch === "x64")
329
+ return `${base}/cloudflared-linux-amd64`;
330
+ if (platform === "linux" && arch === "arm64")
331
+ return `${base}/cloudflared-linux-arm64`;
332
+ if (platform === "win32" && arch === "x64")
333
+ return `${base}/cloudflared-windows-amd64.exe`;
334
+ return null;
335
+ }
336
+ function printManualInstallInstructions() {
337
+ console.error("You can install it manually:\n" +
338
+ " brew install cloudflare/cloudflare/cloudflared # macOS\n" +
339
+ " sudo apt install cloudflared # Debian/Ubuntu\n" +
340
+ "\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
341
+ }
342
+ function startCloudflared(port, binPath) {
343
+ return new Promise((resolve, reject) => {
344
+ console.log(`Connecting to localhost:${port}...`);
345
+ const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
346
+ stdio: ["ignore", "pipe", "pipe"],
347
+ });
348
+ let tunnelUrl = null;
349
+ const timeout = setTimeout(() => {
350
+ if (!tunnelUrl) {
351
+ proc.kill();
352
+ reject(new Error("Failed to get tunnel URL within timeout."));
353
+ }
354
+ }, CLOUDFLARED_STARTUP_TIMEOUT);
355
+ proc.stderr?.on("data", (data) => {
356
+ const line = data.toString("utf-8");
357
+ const match = line.match(TUNNEL_URL_PATTERN);
358
+ if (match && !tunnelUrl) {
359
+ tunnelUrl = match[0];
360
+ clearTimeout(timeout);
361
+ printBanner();
362
+ resolve({ process: proc, tunnelUrl });
363
+ }
364
+ });
365
+ proc.on("exit", (code) => {
366
+ clearTimeout(timeout);
367
+ if (!tunnelUrl) {
368
+ reject(new Error("cloudflared exited unexpectedly."));
369
+ }
370
+ });
371
+ proc.on("error", (err) => {
372
+ clearTimeout(timeout);
373
+ reject(err);
374
+ });
375
+ });
376
+ }
377
+ // --- API calls ---
378
+ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
379
+ try {
380
+ const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
381
+ method: "POST",
382
+ headers: {
383
+ Authorization: `Bearer ${token}`,
384
+ "Content-Type": "application/json",
385
+ },
386
+ body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
387
+ signal: AbortSignal.timeout(10_000),
388
+ });
389
+ if (!resp.ok)
390
+ throw new Error(`HTTP ${resp.status}`);
391
+ // Registration successful — banner already shown
392
+ }
393
+ catch (e) {
394
+ console.error(`Warning: Failed to register connection: ${e}`);
395
+ console.error("Connection is still active — you can retry manually.");
396
+ }
397
+ }
398
+ async function deregisterTunnel(apiUrl, token) {
399
+ try {
400
+ const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
401
+ method: "DELETE",
402
+ headers: { Authorization: `Bearer ${token}` },
403
+ signal: AbortSignal.timeout(2_000),
404
+ });
405
+ if (!resp.ok)
406
+ throw new Error(`HTTP ${resp.status}`);
407
+ console.log("Disconnected");
408
+ }
409
+ catch (e) {
410
+ console.error(`Warning: Failed to deregister connection: ${e}`);
411
+ }
412
+ }
413
+ function processHeartbeatResponse(resp) {
414
+ resp.json().then((data) => {
415
+ const sims = data.simulations ?? [];
416
+ renderSimulationCards(sims);
417
+ // Store completed simulations locally
418
+ for (const sim of sims) {
419
+ if (sim.status === "completed" || sim.status === "failed" || sim.status === "cancelled") {
420
+ storeCompletedSimulation(sim);
421
+ }
422
+ }
423
+ }).catch(() => {
424
+ // Non-fatal: response parsing failed, silently continue
425
+ });
426
+ }
427
+ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal) {
428
+ let consecutiveFailures = 0;
429
+ let stopped = false;
430
+ const interval = setInterval(async () => {
431
+ if (stopped)
432
+ return;
433
+ try {
434
+ const resp = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
435
+ method: "POST",
436
+ headers: { Authorization: `Bearer ${getToken()}` },
437
+ signal: AbortSignal.timeout(10_000),
438
+ });
439
+ // If 401 and we can refresh, try once
440
+ if (resp.status === 401 && doRefresh) {
441
+ try {
442
+ const newToken = await doRefresh();
443
+ onTokenRefreshed(newToken);
444
+ console.log("Token refreshed.");
445
+ // Retry heartbeat with new token
446
+ const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
447
+ method: "POST",
448
+ headers: { Authorization: `Bearer ${newToken}` },
449
+ signal: AbortSignal.timeout(10_000),
450
+ });
451
+ if (!retry.ok)
452
+ throw new Error(`HTTP ${retry.status}`);
453
+ consecutiveFailures = 0;
454
+ processHeartbeatResponse(retry);
455
+ return;
456
+ }
457
+ catch (refreshErr) {
458
+ console.error(`Token refresh failed: ${refreshErr}`);
459
+ }
460
+ }
461
+ if (!resp.ok)
462
+ throw new Error(`HTTP ${resp.status}`);
463
+ consecutiveFailures = 0;
464
+ processHeartbeatResponse(resp);
465
+ }
466
+ catch (e) {
467
+ consecutiveFailures++;
468
+ console.error(`Heartbeat failed (${consecutiveFailures}/${MAX_HEARTBEAT_FAILURES}): ${e}`);
469
+ if (consecutiveFailures >= MAX_HEARTBEAT_FAILURES) {
470
+ console.error("Lost connection to Ish backend. Shutting down.");
471
+ stopped = true;
472
+ clearInterval(interval);
473
+ onFatal();
474
+ }
475
+ }
476
+ }, HEARTBEAT_INTERVAL);
477
+ return {
478
+ stop: () => {
479
+ stopped = true;
480
+ clearInterval(interval);
481
+ },
482
+ };
483
+ }
484
+ /**
485
+ * Schedule a proactive token refresh before the JWT expires.
486
+ * Refreshes 10 minutes before expiry.
487
+ */
488
+ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
489
+ if (!doRefresh)
490
+ return { stop: () => { } };
491
+ const exp = decodeJwtExp(token);
492
+ if (!exp)
493
+ return { stop: () => { } };
494
+ const refreshAt = (exp - 600) * 1000; // 10 min before expiry
495
+ const delay = refreshAt - Date.now();
496
+ if (delay <= 0)
497
+ return { stop: () => { } };
498
+ const timer = setTimeout(async () => {
499
+ try {
500
+ const newToken = await doRefresh();
501
+ onTokenRefreshed(newToken);
502
+ console.log("Token proactively refreshed.");
503
+ // Schedule next refresh for the new token
504
+ scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed);
505
+ }
506
+ catch (e) {
507
+ console.error(`Proactive token refresh failed: ${e}`);
508
+ }
509
+ }, delay);
510
+ return { stop: () => clearTimeout(timer) };
511
+ }
512
+ // --- Main ---
513
+ export async function runTunnel(port, tokenArg, apiUrlArg) {
514
+ const apiUrl = resolveApiUrl(apiUrlArg);
515
+ if (apiUrl !== DEFAULT_API_URL) {
516
+ console.log(`Using API: ${apiUrl}`);
517
+ }
518
+ const resolved = await resolveToken(tokenArg, apiUrl);
519
+ let currentToken = resolved.token;
520
+ const onTokenRefreshed = (newToken) => {
521
+ currentToken = newToken;
522
+ };
523
+ // Serialize refresh calls to prevent concurrent use of single-use refresh tokens
524
+ let refreshInFlight = null;
525
+ const serializedRefresh = resolved.refresh
526
+ ? async () => {
527
+ if (refreshInFlight)
528
+ return refreshInFlight;
529
+ refreshInFlight = resolved.refresh().finally(() => { refreshInFlight = null; });
530
+ return refreshInFlight;
531
+ }
532
+ : null;
533
+ const cloudflaredPath = await resolveCloudflaredBin();
534
+ let cfResult;
535
+ try {
536
+ cfResult = await startCloudflared(port, cloudflaredPath);
537
+ }
538
+ catch (e) {
539
+ console.error(`Failed to start cloudflared: ${e}`);
540
+ process.exit(1);
541
+ }
542
+ const { process: cfProcess, tunnelUrl } = cfResult;
543
+ await registerTunnel(apiUrl, currentToken, tunnelUrl, port);
544
+ let shuttingDown = false;
545
+ const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
546
+ await deregisterTunnel(apiUrl, currentToken);
547
+ cfProcess.kill();
548
+ process.exit(1);
549
+ });
550
+ const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed);
551
+ const shutdown = async () => {
552
+ if (shuttingDown)
553
+ process.exit(1);
554
+ shuttingDown = true;
555
+ console.log("\nShutting down...");
556
+ heartbeat.stop();
557
+ proactiveRefresh.stop();
558
+ cfProcess.kill();
559
+ await deregisterTunnel(apiUrl, currentToken);
560
+ process.exit(0);
561
+ };
562
+ process.on("SIGINT", shutdown);
563
+ process.on("SIGTERM", shutdown);
564
+ console.log("\nPress Ctrl+C to disconnect.\n");
565
+ cfProcess.on("exit", async () => {
566
+ if (!shuttingDown) {
567
+ heartbeat.stop();
568
+ proactiveRefresh.stop();
569
+ await deregisterTunnel(apiUrl, currentToken);
570
+ process.exit(0);
571
+ }
572
+ });
573
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ import { program, Option } from "commander";
3
+ import { runTunnel } from "./connect.js";
4
+ import { login, getAppUrl } from "./auth.js";
5
+ import { loadConfig, saveConfig } from "./config.js";
6
+ import { upgrade } from "./upgrade.js";
7
+ import { registerWorkspaceCommands } from "./commands/workspace.js";
8
+ import { registerStudyCommands } from "./commands/study.js";
9
+ import { registerIterationCommands } from "./commands/iteration.js";
10
+ import { registerTesterProfileCommands } from "./commands/tester-profile.js";
11
+ import { registerTesterCommands } from "./commands/tester.js";
12
+ import { registerSimulationCommands } from "./commands/simulation.js";
13
+ import { registerConfigCommands } from "./commands/config.js";
14
+ import pkg from "../package.json" with { type: "json" };
15
+ const { version } = pkg;
16
+ program
17
+ .name("ish")
18
+ .description("Ish CLI — manage workspaces, studies, simulations, and more")
19
+ .version(version);
20
+ // Global options
21
+ program
22
+ .option("-t, --token <token>", "Auth token (or set ISH_TOKEN env var)")
23
+ .option("--api-url <url>", "Backend API URL (default: ISH_API_URL or https://api.ishlabs.io)")
24
+ .addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
25
+ .option("--json", "Output as JSON (auto-enabled when piped)")
26
+ .option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. alias,name,status)")
27
+ .option("--verbose", "Include full UUIDs and timestamps in JSON output")
28
+ .option("-q, --quiet", "Suppress progress messages on stderr");
29
+ // --- Inline commands (from upstream) ---
30
+ program
31
+ .command("login")
32
+ .description("Authenticate with Ish via your browser")
33
+ .action(async (_opts, cmd) => {
34
+ try {
35
+ const globals = cmd.optsWithGlobals();
36
+ const appUrl = globals.dev ? "http://localhost:3000" : getAppUrl();
37
+ const tokens = await login(appUrl);
38
+ const config = loadConfig();
39
+ config.access_token = tokens.accessToken;
40
+ config.refresh_token = tokens.refreshToken;
41
+ saveConfig(config);
42
+ console.log("\nLogin successful!");
43
+ }
44
+ catch (e) {
45
+ console.error(`Login failed: ${e instanceof Error ? e.message : e}`);
46
+ process.exit(1);
47
+ }
48
+ });
49
+ program
50
+ .command("logout")
51
+ .description("Remove saved authentication credentials")
52
+ .action(() => {
53
+ const config = loadConfig();
54
+ delete config.access_token;
55
+ delete config.refresh_token;
56
+ delete config.token;
57
+ saveConfig(config);
58
+ console.log("Logged out.");
59
+ });
60
+ program
61
+ .command("connect")
62
+ .description("Expose your localhost to Ish via a Cloudflare tunnel")
63
+ .argument("<port>", "Local port to connect (e.g. 3000)")
64
+ .action(async (port, _opts, cmd) => {
65
+ const portNum = parseInt(port, 10);
66
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
67
+ console.error(`Invalid port: ${port}`);
68
+ process.exit(1);
69
+ }
70
+ const globals = cmd.optsWithGlobals();
71
+ const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
72
+ await runTunnel(portNum, globals.token, apiUrl);
73
+ });
74
+ // --- Modular command groups ---
75
+ registerWorkspaceCommands(program);
76
+ registerStudyCommands(program);
77
+ registerIterationCommands(program);
78
+ registerTesterProfileCommands(program);
79
+ registerTesterCommands(program);
80
+ registerSimulationCommands(program);
81
+ registerConfigCommands(program);
82
+ program
83
+ .command("upgrade")
84
+ .description("Update ish to the latest version")
85
+ .option("--version <version>", "Install a specific version")
86
+ .action(async (options) => {
87
+ await upgrade(version, options.version);
88
+ });
89
+ program.parse();