@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.
- package/README.md +323 -21
- package/dist/auth.d.ts +17 -1
- package/dist/auth.js +62 -9
- package/dist/commands/ask.d.ts +5 -0
- package/dist/commands/ask.js +722 -0
- package/dist/commands/config.js +25 -1
- package/dist/commands/docs.d.ts +17 -0
- package/dist/commands/docs.js +147 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +182 -0
- package/dist/commands/iteration.d.ts +5 -1
- package/dist/commands/iteration.js +243 -31
- package/dist/commands/profile.d.ts +5 -0
- package/dist/commands/profile.js +313 -0
- package/dist/commands/source.d.ts +10 -0
- package/dist/commands/source.js +78 -0
- package/dist/commands/study-run.d.ts +11 -0
- package/dist/commands/study-run.js +552 -0
- package/dist/commands/study-tester.d.ts +8 -0
- package/dist/commands/study-tester.js +149 -0
- package/dist/commands/study.js +145 -70
- package/dist/commands/workspace.js +193 -7
- package/dist/config.d.ts +3 -1
- package/dist/config.js +10 -10
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +127 -94
- package/dist/index.js +82 -34
- package/dist/lib/alias-store.d.ts +3 -0
- package/dist/lib/alias-store.js +9 -7
- package/dist/lib/api-client.d.ts +9 -6
- package/dist/lib/api-client.js +87 -26
- package/dist/lib/ask-questions.d.ts +9 -0
- package/dist/lib/ask-questions.js +35 -0
- package/dist/lib/ask-variants.d.ts +48 -0
- package/dist/lib/ask-variants.js +236 -0
- package/dist/lib/auth.d.ts +1 -1
- package/dist/lib/auth.js +24 -8
- package/dist/lib/colors.d.ts +30 -0
- package/dist/lib/colors.js +48 -0
- package/dist/lib/command-helpers.d.ts +74 -0
- package/dist/lib/command-helpers.js +232 -6
- package/dist/lib/docs.d.ts +32 -0
- package/dist/lib/docs.js +930 -0
- package/dist/lib/local-sim/browser.d.ts +0 -1
- package/dist/lib/local-sim/browser.js +0 -2
- package/dist/lib/local-sim/install.d.ts +2 -12
- package/dist/lib/local-sim/install.js +22 -30
- package/dist/lib/output.d.ts +25 -3
- package/dist/lib/output.js +465 -20
- package/dist/lib/paths.d.ts +14 -0
- package/dist/lib/paths.js +36 -0
- package/dist/lib/profile-sources.d.ts +55 -0
- package/dist/lib/profile-sources.js +157 -0
- package/dist/lib/site-access.d.ts +80 -0
- package/dist/lib/site-access.js +188 -0
- package/dist/lib/skill-content.d.ts +31 -0
- package/dist/lib/skill-content.js +462 -0
- package/dist/lib/study-inputs.d.ts +20 -0
- package/dist/lib/study-inputs.js +72 -0
- package/dist/lib/types.d.ts +207 -9
- package/dist/lib/types.js +7 -0
- package/dist/lib/upload.js +2 -2
- package/dist/upgrade.js +11 -1
- package/package.json +3 -2
- package/dist/commands/simulation.d.ts +0 -10
- package/dist/commands/simulation.js +0 -647
- package/dist/commands/tester-profile.d.ts +0 -5
- package/dist/commands/tester-profile.js +0 -109
- package/dist/commands/tester.d.ts +0 -5
- 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
|
|
23
|
-
case "pending": return
|
|
24
|
-
case "completed": return
|
|
25
|
-
case "failed": return
|
|
26
|
-
default: return
|
|
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
|
|
31
|
+
case "Satisfied": return c.green;
|
|
33
32
|
case "Frustrated":
|
|
34
|
-
case "Unsure": return
|
|
35
|
-
default: return
|
|
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
|
|
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 =
|
|
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)}●${
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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}${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
166
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
process.exit(1);
|
|
261
|
+
throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
|
|
245
262
|
}
|
|
246
|
-
//
|
|
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
|
-
|
|
252
|
-
process.exit(1);
|
|
268
|
+
throw new Error('Saved token is invalid. Run "ish login" to re-authenticate.');
|
|
253
269
|
}
|
|
254
|
-
//
|
|
255
|
-
|
|
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
|
-
${
|
|
276
|
+
${c.orange}${c.bold} ██╗███████╗██╗ ██╗
|
|
270
277
|
██║██╔════╝██║ ██║
|
|
271
278
|
██║███████╗███████║
|
|
272
279
|
██║╚════██║██╔══██║
|
|
273
280
|
██║███████║██║ ██║
|
|
274
|
-
╚═╝╚══════╝╚═╝ ╚═╝${
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
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.
|
|
301
|
+
console.error("cloudflared not found. Installing...");
|
|
294
302
|
const url = getCloudflaredDownloadUrl();
|
|
295
303
|
if (!url) {
|
|
296
|
-
|
|
297
|
-
process.exit(1);
|
|
304
|
+
throw new Error(manualInstallInstructions());
|
|
298
305
|
}
|
|
299
306
|
try {
|
|
300
|
-
const
|
|
301
|
-
mkdirSync(
|
|
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 "${
|
|
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(
|
|
316
|
+
writeFileSync(localBin, Buffer.from(await resp.arrayBuffer()));
|
|
310
317
|
}
|
|
311
|
-
chmodSync(
|
|
312
|
-
return
|
|
318
|
+
chmodSync(localBin, 0o755);
|
|
319
|
+
return localBin;
|
|
313
320
|
}
|
|
314
321
|
catch (e) {
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|