@ishlabs/cli 0.8.1 → 0.8.3

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 (70) hide show
  1. package/README.md +323 -21
  2. package/dist/auth.d.ts +17 -1
  3. package/dist/auth.js +62 -9
  4. package/dist/commands/ask.d.ts +5 -0
  5. package/dist/commands/ask.js +722 -0
  6. package/dist/commands/config.js +25 -1
  7. package/dist/commands/docs.d.ts +17 -0
  8. package/dist/commands/docs.js +147 -0
  9. package/dist/commands/init.d.ts +16 -0
  10. package/dist/commands/init.js +182 -0
  11. package/dist/commands/iteration.d.ts +5 -1
  12. package/dist/commands/iteration.js +243 -31
  13. package/dist/commands/profile.d.ts +5 -0
  14. package/dist/commands/profile.js +313 -0
  15. package/dist/commands/source.d.ts +10 -0
  16. package/dist/commands/source.js +78 -0
  17. package/dist/commands/study-run.d.ts +11 -0
  18. package/dist/commands/study-run.js +552 -0
  19. package/dist/commands/study-tester.d.ts +8 -0
  20. package/dist/commands/study-tester.js +149 -0
  21. package/dist/commands/study.js +145 -70
  22. package/dist/commands/workspace.js +193 -7
  23. package/dist/config.d.ts +3 -1
  24. package/dist/config.js +10 -10
  25. package/dist/connect.d.ts +4 -1
  26. package/dist/connect.js +127 -94
  27. package/dist/index.js +82 -34
  28. package/dist/lib/alias-store.d.ts +3 -0
  29. package/dist/lib/alias-store.js +9 -7
  30. package/dist/lib/api-client.d.ts +9 -6
  31. package/dist/lib/api-client.js +87 -26
  32. package/dist/lib/ask-questions.d.ts +9 -0
  33. package/dist/lib/ask-questions.js +35 -0
  34. package/dist/lib/ask-variants.d.ts +48 -0
  35. package/dist/lib/ask-variants.js +236 -0
  36. package/dist/lib/auth.d.ts +1 -1
  37. package/dist/lib/auth.js +24 -8
  38. package/dist/lib/colors.d.ts +30 -0
  39. package/dist/lib/colors.js +48 -0
  40. package/dist/lib/command-helpers.d.ts +74 -0
  41. package/dist/lib/command-helpers.js +232 -6
  42. package/dist/lib/docs.d.ts +32 -0
  43. package/dist/lib/docs.js +930 -0
  44. package/dist/lib/local-sim/browser.d.ts +0 -1
  45. package/dist/lib/local-sim/browser.js +0 -2
  46. package/dist/lib/local-sim/install.d.ts +2 -12
  47. package/dist/lib/local-sim/install.js +22 -30
  48. package/dist/lib/output.d.ts +25 -3
  49. package/dist/lib/output.js +465 -20
  50. package/dist/lib/paths.d.ts +14 -0
  51. package/dist/lib/paths.js +36 -0
  52. package/dist/lib/profile-sources.d.ts +55 -0
  53. package/dist/lib/profile-sources.js +157 -0
  54. package/dist/lib/site-access.d.ts +80 -0
  55. package/dist/lib/site-access.js +188 -0
  56. package/dist/lib/skill-content.d.ts +31 -0
  57. package/dist/lib/skill-content.js +462 -0
  58. package/dist/lib/study-inputs.d.ts +20 -0
  59. package/dist/lib/study-inputs.js +72 -0
  60. package/dist/lib/types.d.ts +207 -9
  61. package/dist/lib/types.js +7 -0
  62. package/dist/lib/upload.js +2 -2
  63. package/dist/upgrade.js +11 -1
  64. package/package.json +3 -2
  65. package/dist/commands/simulation.d.ts +0 -10
  66. package/dist/commands/simulation.js +0 -647
  67. package/dist/commands/tester-profile.d.ts +0 -5
  68. package/dist/commands/tester-profile.js +0 -109
  69. package/dist/commands/tester.d.ts +0 -5
  70. package/dist/commands/tester.js +0 -73
package/dist/connect.js CHANGED
@@ -2,37 +2,36 @@
2
2
  * Localhost connect CLI — wraps cloudflared and registers with Ish backend.
3
3
  */
4
4
  import { spawn, execSync } from "node:child_process";
5
- import { homedir } from "node:os";
6
- import { join } from "node:path";
7
5
  import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
8
7
  import { loadConfig, saveConfig } from "./config.js";
9
8
  import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
9
+ import { binDir, cloudflaredBin, simulationsDir } from "./lib/paths.js";
10
+ import { c } from "./lib/colors.js";
10
11
  const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
11
12
  const HEARTBEAT_INTERVAL = 10_000;
12
13
  const MAX_HEARTBEAT_FAILURES = 3;
13
14
  const CLOUDFLARED_STARTUP_TIMEOUT = 30_000;
14
15
  const DEFAULT_API_URL = "https://api.ishlabs.io";
15
16
  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
17
  // --- Simulation card rendering ---
19
18
  const CARD_WIDTH = 64;
20
19
  function statusColor(status) {
21
20
  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";
21
+ case "running": return c.green;
22
+ case "pending": return c.yellow;
23
+ case "completed": return c.green;
24
+ case "failed": return c.red;
25
+ default: return c.dim;
27
26
  }
28
27
  }
29
28
  function sentimentColor(sentiment) {
30
29
  switch (sentiment) {
31
30
  case "Excited":
32
- case "Satisfied": return GREEN;
31
+ case "Satisfied": return c.green;
33
32
  case "Frustrated":
34
- case "Unsure": return RED;
35
- default: return DIM; // Neutral
33
+ case "Unsure": return c.red;
34
+ default: return c.dim; // Neutral
36
35
  }
37
36
  }
38
37
  function padVisible(str, len) {
@@ -43,7 +42,7 @@ function truncate(str, maxLen) {
43
42
  return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str;
44
43
  }
45
44
  function row(content, inner) {
46
- return `\x1b[2m│\x1b[0m ${padVisible(content, inner)}\x1b[2m│\x1b[0m`;
45
+ return `${c.dim}│${c.reset} ${padVisible(content, inner)}${c.dim}│${c.reset}`;
47
46
  }
48
47
  function renderCard(sim) {
49
48
  const inner = CARD_WIDTH - 4;
@@ -51,20 +50,20 @@ function renderCard(sim) {
51
50
  // Top border with name
52
51
  const nameSegment = `─ ${name} `;
53
52
  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`;
53
+ const top = `${c.dim}┌${c.reset}${c.orange}${c.bold}${nameSegment}${c.reset}${c.dim}${"─".repeat(Math.max(0, topPad))}┐${c.reset}`;
55
54
  const li = sim.last_interaction;
56
55
  const lines = [top];
57
56
  // Status badge: ● Status (N steps) — right-aligned
58
57
  const titleStatus = sim.status.charAt(0).toUpperCase() + sim.status.slice(1);
59
58
  const steps = `${sim.interaction_count} step${sim.interaction_count !== 1 ? "s" : ""}`;
60
- const badge = `${statusColor(sim.status)}●${RESET} ${titleStatus} ${DIM}(${steps})${RESET}`;
59
+ const badge = `${statusColor(sim.status)}●${c.reset} ${titleStatus} ${c.dim}(${steps})${c.reset}`;
61
60
  const badgePlain = `● ${titleStatus} (${steps})`;
62
61
  if (li) {
63
62
  // Frame name (bold) + status badge right-aligned
64
63
  if (li.current_frame_name) {
65
64
  const frameStr = truncate(li.current_frame_name, inner - badgePlain.length - 2);
66
65
  const gap = inner - frameStr.length - badgePlain.length;
67
- lines.push(row(`${BOLD}${frameStr}${RESET}${" ".repeat(Math.max(1, gap))}${badge}`, inner));
66
+ lines.push(row(`${c.bold}${frameStr}${c.reset}${" ".repeat(Math.max(1, gap))}${badge}`, inner));
68
67
  }
69
68
  else {
70
69
  const pad = inner - badgePlain.length;
@@ -96,7 +95,7 @@ function renderCard(sim) {
96
95
  commentLines[lastIdx] = `${commentLines[lastIdx]}"`;
97
96
  }
98
97
  for (const cl of commentLines) {
99
- lines.push(row(`${DIM}${truncate(cl, inner)}${RESET}`, inner));
98
+ lines.push(row(`${c.dim}${truncate(cl, inner)}${c.reset}`, inner));
100
99
  }
101
100
  }
102
101
  // Action rows — one per action, action_type in cyan
@@ -106,12 +105,12 @@ function renderCard(sim) {
106
105
  const label = action.element_label
107
106
  ? ` ${truncate(action.element_label, inner - action.action_type.length - 2)}`
108
107
  : "";
109
- lines.push(row(`${CYAN}${action.action_type}${RESET}${label}`, inner));
108
+ lines.push(row(`${c.cyan}${action.action_type}${c.reset}${label}`, inner));
110
109
  }
111
110
  // Sentiment badge
112
111
  if (li.sentiment) {
113
112
  const sc = sentimentColor(li.sentiment);
114
- lines.push(row(`${sc}${li.sentiment}${RESET}`, inner));
113
+ lines.push(row(`${sc}${li.sentiment}${c.reset}`, inner));
115
114
  }
116
115
  }
117
116
  else {
@@ -120,7 +119,7 @@ function renderCard(sim) {
120
119
  lines.push(row(`${" ".repeat(Math.max(0, pad))}${badge}`, inner));
121
120
  }
122
121
  // Bottom border
123
- lines.push(`\x1b[2m└${"─".repeat(CARD_WIDTH - 2)}┘\x1b[0m`);
122
+ lines.push(`${c.dim}└${"─".repeat(CARD_WIDTH - 2)}┘${c.reset}`);
124
123
  return lines;
125
124
  }
126
125
  function renderAllCards(simulations) {
@@ -129,7 +128,7 @@ function renderAllCards(simulations) {
129
128
  const lines = [];
130
129
  const ts = new Date().toLocaleTimeString("en-GB", { hour12: false });
131
130
  const study = simulations[0]?.study_name;
132
- lines.push(`\x1b[2m${study ? `${study} · ` : ""}Updated ${ts}\x1b[0m`);
131
+ lines.push(`${c.dim}${study ? `${study} · ` : ""}Updated ${ts}${c.reset}`);
133
132
  lines.push("");
134
133
  for (const sim of simulations) {
135
134
  lines.push(...renderCard(sim));
@@ -140,6 +139,11 @@ function renderAllCards(simulations) {
140
139
  let cardLineCount = 0;
141
140
  function clearCards() {
142
141
  if (cardLineCount > 0) {
142
+ // Cursor manipulation only meaningful when colors/escapes are enabled.
143
+ // When disabled (NO_COLOR, piped output, --no-color), skip the redraw
144
+ // dance — the heartbeat will just append.
145
+ if (!c.reset)
146
+ return;
143
147
  process.stdout.write(`\x1b[${cardLineCount}A`);
144
148
  for (let i = 0; i < cardLineCount; i++) {
145
149
  process.stdout.write("\x1b[2K\n");
@@ -156,14 +160,14 @@ function renderSimulationCards(simulations) {
156
160
  }
157
161
  }
158
162
  // --- Local storage for completed simulations ---
159
- const SIMULATIONS_DIR = join(ISH_DIR, "simulations");
160
163
  const storedTesterIds = new Set();
161
164
  function storeCompletedSimulation(sim) {
162
165
  if (storedTesterIds.has(sim.tester_id))
163
166
  return;
164
167
  storedTesterIds.add(sim.tester_id);
165
- mkdirSync(SIMULATIONS_DIR, { recursive: true });
166
- const logFile = join(SIMULATIONS_DIR, "history.jsonl");
168
+ const dir = simulationsDir();
169
+ mkdirSync(dir, { recursive: true });
170
+ const logFile = join(dir, "history.jsonl");
167
171
  const entry = {
168
172
  tester_id: sim.tester_id,
169
173
  instance_name: sim.instance_name,
@@ -200,15 +204,30 @@ function resolveApiUrl(apiUrlArg) {
200
204
  * Resolve an access token, refreshing if needed.
201
205
  * Returns both the token and a mutable holder for runtime refresh.
202
206
  */
203
- async function resolveToken(tokenArg, apiUrl) {
207
+ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
204
208
  // 1. Explicit token argument
205
209
  if (tokenArg)
206
210
  return { token: tokenArg, refresh: null };
207
- // 2. Environment variable
211
+ // 2. Token file
212
+ if (tokenFileArg) {
213
+ const { readFileSync } = await import("node:fs");
214
+ let content;
215
+ try {
216
+ content = readFileSync(tokenFileArg, "utf-8");
217
+ }
218
+ catch {
219
+ throw new Error(`Cannot read --token-file: ${tokenFileArg}`);
220
+ }
221
+ const token = content.trim();
222
+ if (!token)
223
+ throw new Error(`--token-file is empty: ${tokenFileArg}`);
224
+ return { token, refresh: null };
225
+ }
226
+ // 3. Environment variable
208
227
  const envToken = process.env.ISH_TOKEN;
209
228
  if (envToken)
210
229
  return { token: envToken, refresh: null };
211
- // 3. Saved config with refresh token
230
+ // 4. Saved config with refresh token
212
231
  const config = loadConfig();
213
232
  if (config.access_token && config.refresh_token) {
214
233
  let accessToken = config.access_token;
@@ -222,8 +241,7 @@ async function resolveToken(tokenArg, apiUrl) {
222
241
  saveConfig(config);
223
242
  }
224
243
  catch {
225
- console.error('Session expired. Run "ish login" to re-authenticate.');
226
- process.exit(1);
244
+ throw new Error('Session expired. Run "ish login" to re-authenticate.');
227
245
  }
228
246
  }
229
247
  if (await verifyToken(accessToken, apiUrl)) {
@@ -240,38 +258,27 @@ async function resolveToken(tokenArg, apiUrl) {
240
258
  };
241
259
  return { token: accessToken, refresh: doRefresh };
242
260
  }
243
- console.error('Saved token is invalid. Run "ish login" to re-authenticate.');
244
- process.exit(1);
261
+ throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
245
262
  }
246
- // 4. Legacy saved token (no refresh token)
263
+ // 5. Legacy saved token (no refresh token)
247
264
  if (config.token) {
248
265
  if (await verifyToken(config.token, apiUrl)) {
249
266
  return { token: config.token, refresh: null };
250
267
  }
251
- console.error('Saved token is invalid. Run "ish login" to re-authenticate.');
252
- process.exit(1);
268
+ throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
253
269
  }
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);
270
+ // 6. No valid token found — direct user to login
271
+ throw new Error('No valid token found. Run "ish login" to authenticate.');
257
272
  }
258
273
  // --- 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
274
  function printBanner() {
268
275
  console.log(`
269
- ${ORANGE}${BOLD} ██╗███████╗██╗ ██╗
276
+ ${c.orange}${c.bold} ██╗███████╗██╗ ██╗
270
277
  ██║██╔════╝██║ ██║
271
278
  ██║███████╗███████║
272
279
  ██║╚════██║██╔══██║
273
280
  ██║███████║██║ ██║
274
- ╚═╝╚══════╝╚═╝ ╚═╝${RESET}
281
+ ╚═╝╚══════╝╚═╝ ╚═╝${c.reset}
275
282
 
276
283
  Connected
277
284
  `);
@@ -286,35 +293,34 @@ async function resolveCloudflaredBin() {
286
293
  catch {
287
294
  // Not on PATH
288
295
  }
289
- // 2. Check ~/.ish/bin/cloudflared
290
- if (existsSync(CLOUDFLARED_BIN))
291
- return CLOUDFLARED_BIN;
296
+ // 2. Check the local ish bin dir
297
+ const localBin = cloudflaredBin();
298
+ if (existsSync(localBin))
299
+ return localBin;
292
300
  // 3. Download from Cloudflare releases
293
- console.log("cloudflared not found. Installing...");
301
+ console.error("cloudflared not found. Installing...");
294
302
  const url = getCloudflaredDownloadUrl();
295
303
  if (!url) {
296
- printManualInstallInstructions();
297
- process.exit(1);
304
+ throw new Error(manualInstallInstructions());
298
305
  }
299
306
  try {
300
- const binDir = join(ISH_DIR, "bin");
301
- mkdirSync(binDir, { recursive: true, mode: 0o755 });
307
+ const dir = binDir();
308
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
302
309
  if (url.endsWith(".tgz")) {
303
- execSync(`curl -fsSL "${url}" | tar xz -C "${binDir}" cloudflared`, { stdio: "ignore" });
310
+ execSync(`curl -fsSL "${url}" | tar xz -C "${dir}" cloudflared`, { stdio: "ignore" });
304
311
  }
305
312
  else {
306
313
  const resp = await fetch(url);
307
314
  if (!resp.ok)
308
315
  throw new Error(`HTTP ${resp.status}`);
309
- writeFileSync(CLOUDFLARED_BIN, Buffer.from(await resp.arrayBuffer()));
316
+ writeFileSync(localBin, Buffer.from(await resp.arrayBuffer()));
310
317
  }
311
- chmodSync(CLOUDFLARED_BIN, 0o755);
312
- return CLOUDFLARED_BIN;
318
+ chmodSync(localBin, 0o755);
319
+ return localBin;
313
320
  }
314
321
  catch (e) {
315
- console.error(`Failed to install cloudflared: ${e instanceof Error ? e.message : e}\n`);
316
- printManualInstallInstructions();
317
- process.exit(1);
322
+ const reason = e instanceof Error ? e.message : String(e);
323
+ throw new Error(`Failed to install cloudflared: ${reason}\n${manualInstallInstructions()}`);
318
324
  }
319
325
  }
320
326
  function getCloudflaredDownloadUrl() {
@@ -333,15 +339,16 @@ function getCloudflaredDownloadUrl() {
333
339
  return `${base}/cloudflared-windows-amd64.exe`;
334
340
  return null;
335
341
  }
336
- function printManualInstallInstructions() {
337
- console.error("You can install it manually:\n" +
342
+ function manualInstallInstructions() {
343
+ return ("You can install it manually:\n" +
338
344
  " brew install cloudflare/cloudflare/cloudflared # macOS\n" +
339
345
  " sudo apt install cloudflared # Debian/Ubuntu\n" +
340
346
  "\n Or: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
341
347
  }
342
- function startCloudflared(port, binPath) {
348
+ function startCloudflared(port, binPath, json) {
343
349
  return new Promise((resolve, reject) => {
344
- console.log(`Connecting to localhost:${port}...`);
350
+ if (!json)
351
+ console.log(`Connecting to localhost:${port}...`);
345
352
  const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
346
353
  stdio: ["ignore", "pipe", "pipe"],
347
354
  });
@@ -358,7 +365,6 @@ function startCloudflared(port, binPath) {
358
365
  if (match && !tunnelUrl) {
359
366
  tunnelUrl = match[0];
360
367
  clearTimeout(timeout);
361
- printBanner();
362
368
  resolve({ process: proc, tunnelUrl });
363
369
  }
364
370
  });
@@ -388,14 +394,15 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
388
394
  });
389
395
  if (!resp.ok)
390
396
  throw new Error(`HTTP ${resp.status}`);
391
- // Registration successful — banner already shown
397
+ return true;
392
398
  }
393
399
  catch (e) {
394
400
  console.error(`Warning: Failed to register connection: ${e}`);
395
401
  console.error("Connection is still active — you can retry manually.");
402
+ return false;
396
403
  }
397
404
  }
398
- async function deregisterTunnel(apiUrl, token) {
405
+ async function deregisterTunnel(apiUrl, token, json) {
399
406
  try {
400
407
  const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
401
408
  method: "DELETE",
@@ -404,16 +411,22 @@ async function deregisterTunnel(apiUrl, token) {
404
411
  });
405
412
  if (!resp.ok)
406
413
  throw new Error(`HTTP ${resp.status}`);
407
- console.log("Disconnected");
414
+ if (json) {
415
+ console.log(JSON.stringify({ status: "disconnected" }));
416
+ }
417
+ else {
418
+ console.log("Disconnected");
419
+ }
408
420
  }
409
421
  catch (e) {
410
422
  console.error(`Warning: Failed to deregister connection: ${e}`);
411
423
  }
412
424
  }
413
- function processHeartbeatResponse(resp) {
425
+ function processHeartbeatResponse(resp, renderCards) {
414
426
  resp.json().then((data) => {
415
427
  const sims = data.simulations ?? [];
416
- renderSimulationCards(sims);
428
+ if (renderCards)
429
+ renderSimulationCards(sims);
417
430
  // Store completed simulations locally
418
431
  for (const sim of sims) {
419
432
  if (sim.status === "completed" || sim.status === "failed" || sim.status === "cancelled") {
@@ -424,7 +437,7 @@ function processHeartbeatResponse(resp) {
424
437
  // Non-fatal: response parsing failed, silently continue
425
438
  });
426
439
  }
427
- function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal) {
440
+ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, json) {
428
441
  let consecutiveFailures = 0;
429
442
  let stopped = false;
430
443
  const interval = setInterval(async () => {
@@ -441,7 +454,8 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
441
454
  try {
442
455
  const newToken = await doRefresh();
443
456
  onTokenRefreshed(newToken);
444
- console.log("Token refreshed.");
457
+ if (!json)
458
+ console.log("Token refreshed.");
445
459
  // Retry heartbeat with new token
446
460
  const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
447
461
  method: "POST",
@@ -451,7 +465,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
451
465
  if (!retry.ok)
452
466
  throw new Error(`HTTP ${retry.status}`);
453
467
  consecutiveFailures = 0;
454
- processHeartbeatResponse(retry);
468
+ processHeartbeatResponse(retry, !json);
455
469
  return;
456
470
  }
457
471
  catch (refreshErr) {
@@ -461,7 +475,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
461
475
  if (!resp.ok)
462
476
  throw new Error(`HTTP ${resp.status}`);
463
477
  consecutiveFailures = 0;
464
- processHeartbeatResponse(resp);
478
+ processHeartbeatResponse(resp, !json);
465
479
  }
466
480
  catch (e) {
467
481
  consecutiveFailures++;
@@ -485,7 +499,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal)
485
499
  * Schedule a proactive token refresh before the JWT expires.
486
500
  * Refreshes 10 minutes before expiry.
487
501
  */
488
- function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
502
+ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
489
503
  if (!doRefresh)
490
504
  return { stop: () => { } };
491
505
  const exp = decodeJwtExp(token);
@@ -499,9 +513,10 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
499
513
  try {
500
514
  const newToken = await doRefresh();
501
515
  onTokenRefreshed(newToken);
502
- console.log("Token proactively refreshed.");
516
+ if (!json)
517
+ console.log("Token proactively refreshed.");
503
518
  // Schedule next refresh for the new token
504
- scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed);
519
+ scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
505
520
  }
506
521
  catch (e) {
507
522
  console.error(`Proactive token refresh failed: ${e}`);
@@ -510,12 +525,14 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed) {
510
525
  return { stop: () => clearTimeout(timer) };
511
526
  }
512
527
  // --- Main ---
513
- export async function runTunnel(port, tokenArg, apiUrlArg) {
528
+ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputOpts = {}) {
529
+ const json = outputOpts.json ?? false;
530
+ const quiet = outputOpts.quiet ?? false;
514
531
  const apiUrl = resolveApiUrl(apiUrlArg);
515
- if (apiUrl !== DEFAULT_API_URL) {
516
- console.log(`Using API: ${apiUrl}`);
532
+ if (apiUrl !== DEFAULT_API_URL && !json) {
533
+ console.error(`Using API: ${apiUrl}`);
517
534
  }
518
- const resolved = await resolveToken(tokenArg, apiUrl);
535
+ const resolved = await resolveToken(tokenArg, apiUrl, tokenFileArg);
519
536
  let currentToken = resolved.token;
520
537
  const onTokenRefreshed = (newToken) => {
521
538
  currentToken = newToken;
@@ -533,40 +550,56 @@ export async function runTunnel(port, tokenArg, apiUrlArg) {
533
550
  const cloudflaredPath = await resolveCloudflaredBin();
534
551
  let cfResult;
535
552
  try {
536
- cfResult = await startCloudflared(port, cloudflaredPath);
553
+ cfResult = await startCloudflared(port, cloudflaredPath, json);
537
554
  }
538
555
  catch (e) {
539
- console.error(`Failed to start cloudflared: ${e}`);
540
- process.exit(1);
556
+ throw new Error(`Failed to start cloudflared: ${e instanceof Error ? e.message : e}`);
541
557
  }
542
558
  const { process: cfProcess, tunnelUrl } = cfResult;
543
- await registerTunnel(apiUrl, currentToken, tunnelUrl, port);
559
+ const registered = await registerTunnel(apiUrl, currentToken, tunnelUrl, port);
560
+ // Announce the tunnel URL — this is the load-bearing output of `ish connect`.
561
+ if (json) {
562
+ console.log(JSON.stringify({
563
+ status: "connected",
564
+ tunnel_url: tunnelUrl,
565
+ local_port: port,
566
+ registered,
567
+ }));
568
+ }
569
+ else {
570
+ if (!quiet)
571
+ printBanner();
572
+ console.log(`Tunnel URL: ${tunnelUrl} → http://localhost:${port}\n`);
573
+ }
544
574
  let shuttingDown = false;
545
575
  const heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
546
- await deregisterTunnel(apiUrl, currentToken);
576
+ await deregisterTunnel(apiUrl, currentToken, json);
547
577
  cfProcess.kill();
548
578
  process.exit(1);
549
- });
550
- const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed);
579
+ }, json);
580
+ const proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, json);
551
581
  const shutdown = async () => {
552
582
  if (shuttingDown)
553
583
  process.exit(1);
554
584
  shuttingDown = true;
555
- console.log("\nShutting down...");
585
+ if (!json)
586
+ console.log("\nShutting down...");
556
587
  heartbeat.stop();
557
588
  proactiveRefresh.stop();
558
589
  cfProcess.kill();
559
- await deregisterTunnel(apiUrl, currentToken);
590
+ await deregisterTunnel(apiUrl, currentToken, json);
560
591
  process.exit(0);
561
592
  };
562
593
  process.on("SIGINT", shutdown);
563
594
  process.on("SIGTERM", shutdown);
564
- console.log("\nPress Ctrl+C to disconnect.\n");
595
+ if (!json && !quiet) {
596
+ console.log("Press Ctrl+C to disconnect.\n");
597
+ }
565
598
  cfProcess.on("exit", async () => {
566
599
  if (!shuttingDown) {
567
600
  heartbeat.stop();
568
601
  proactiveRefresh.stop();
569
- await deregisterTunnel(apiUrl, currentToken);
602
+ await deregisterTunnel(apiUrl, currentToken, json);
570
603
  process.exit(0);
571
604
  }
572
605
  });