@ishlabs/cli 0.8.4 → 0.8.5
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 +1 -1
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +40 -0
- package/dist/commands/ask.js +28 -2
- package/dist/commands/profile.js +25 -12
- package/dist/commands/study.js +12 -4
- package/dist/connect.js +87 -12
- package/dist/index.js +3 -0
- package/dist/lib/command-helpers.d.ts +37 -0
- package/dist/lib/command-helpers.js +166 -7
- package/dist/lib/docs.js +196 -22
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +133 -2
- package/dist/lib/skill-content.js +119 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Two top-level research primitives, both consume reusable tester profiles:
|
|
|
55
55
|
Workspace (= product, top-level container)
|
|
56
56
|
│
|
|
57
57
|
├── Tester Profiles ← reusable audience personas
|
|
58
|
-
│ └── Audience Sources (
|
|
58
|
+
│ └── Audience Sources (images/PDFs/audio/video/text transcripts that seed generation)
|
|
59
59
|
│
|
|
60
60
|
├── Study ─────────────── "structured research artifact"
|
|
61
61
|
│ ├── modality (interactive | text | video | audio | image | document)
|
package/dist/auth.d.ts
CHANGED
|
@@ -21,6 +21,21 @@ export declare function login(appUrl?: string): Promise<{
|
|
|
21
21
|
accessToken: string;
|
|
22
22
|
refreshToken: string;
|
|
23
23
|
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Thrown by `refreshTokens` when the refresh attempt failed permanently and
|
|
26
|
+
* the local config can't recover (e.g. Supabase `refresh_token_not_found`,
|
|
27
|
+
* `invalid_grant`, or any 400-class response). Callers should treat this as
|
|
28
|
+
* "user must run `ish login` again" — retrying won't help.
|
|
29
|
+
*
|
|
30
|
+
* Transient failures (network errors, 5xx, timeouts) are NOT this error and
|
|
31
|
+
* may be worth retrying.
|
|
32
|
+
*/
|
|
33
|
+
export declare class AuthRefreshPermanentError extends Error {
|
|
34
|
+
readonly httpStatus: number;
|
|
35
|
+
readonly errorCode: string | undefined;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
constructor(httpStatus: number, body: string, errorCode: string | undefined);
|
|
38
|
+
}
|
|
24
39
|
export declare function refreshTokens(refreshToken: string, options?: {
|
|
25
40
|
/** The (possibly expired) access token. Used to pick the correct Supabase project. */
|
|
26
41
|
accessToken?: string;
|
package/dist/auth.js
CHANGED
|
@@ -139,6 +139,43 @@ export async function login(appUrl) {
|
|
|
139
139
|
throw new Error("Login timed out. Please try again.");
|
|
140
140
|
}
|
|
141
141
|
// --- Token refresh ---
|
|
142
|
+
/**
|
|
143
|
+
* Thrown by `refreshTokens` when the refresh attempt failed permanently and
|
|
144
|
+
* the local config can't recover (e.g. Supabase `refresh_token_not_found`,
|
|
145
|
+
* `invalid_grant`, or any 400-class response). Callers should treat this as
|
|
146
|
+
* "user must run `ish login` again" — retrying won't help.
|
|
147
|
+
*
|
|
148
|
+
* Transient failures (network errors, 5xx, timeouts) are NOT this error and
|
|
149
|
+
* may be worth retrying.
|
|
150
|
+
*/
|
|
151
|
+
export class AuthRefreshPermanentError extends Error {
|
|
152
|
+
httpStatus;
|
|
153
|
+
errorCode;
|
|
154
|
+
body;
|
|
155
|
+
constructor(httpStatus, body, errorCode) {
|
|
156
|
+
const detail = errorCode ? ` ${errorCode}` : "";
|
|
157
|
+
super(`Token refresh failed permanently (HTTP ${httpStatus}${detail}): ${body}`);
|
|
158
|
+
this.name = "AuthRefreshPermanentError";
|
|
159
|
+
this.httpStatus = httpStatus;
|
|
160
|
+
this.errorCode = errorCode;
|
|
161
|
+
this.body = body;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function parseRefreshErrorCode(body) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(body);
|
|
167
|
+
if (parsed && typeof parsed === "object") {
|
|
168
|
+
if (typeof parsed.error_code === "string")
|
|
169
|
+
return parsed.error_code;
|
|
170
|
+
if (typeof parsed.error === "string")
|
|
171
|
+
return parsed.error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// not JSON — fall through
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
142
179
|
export async function refreshTokens(refreshToken, options) {
|
|
143
180
|
const project = options?.supabaseUrl && options?.anonKey
|
|
144
181
|
? { url: options.supabaseUrl, anonKey: options.anonKey }
|
|
@@ -154,6 +191,9 @@ export async function refreshTokens(refreshToken, options) {
|
|
|
154
191
|
});
|
|
155
192
|
if (!resp.ok) {
|
|
156
193
|
const body = await resp.text().catch(() => "");
|
|
194
|
+
if (resp.status >= 400 && resp.status < 500) {
|
|
195
|
+
throw new AuthRefreshPermanentError(resp.status, body, parseRefreshErrorCode(body));
|
|
196
|
+
}
|
|
157
197
|
throw new Error(`Token refresh failed (HTTP ${resp.status}): ${body}`);
|
|
158
198
|
}
|
|
159
199
|
const data = await resp.json();
|
package/dist/commands/ask.js
CHANGED
|
@@ -326,6 +326,10 @@ Minimal --questions JSON (server keys: "question" + "type"):
|
|
|
326
326
|
{ "question": "What stood out?", "type": "text" },
|
|
327
327
|
{ "question": "Rate it 1-5", "type": "slider" }
|
|
328
328
|
]
|
|
329
|
+
|
|
330
|
+
Picks come back with a \`pick_confidence\` (0..1) score per tester when
|
|
331
|
+
\`--wants-pick\` is set — read it off \`pick.confidence\` in the response. See
|
|
332
|
+
\`ish docs get-page concepts/ask\` for interpretation.
|
|
329
333
|
`)
|
|
330
334
|
.action(async (opts, cmd) => {
|
|
331
335
|
await withClient(cmd, async (client, globals) => {
|
|
@@ -430,7 +434,14 @@ lookups.`)
|
|
|
430
434
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
431
435
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
432
436
|
.option("--round <n>", "Show only round N (1-indexed; default: all rounds)")
|
|
433
|
-
.addHelpText("after",
|
|
437
|
+
.addHelpText("after", `
|
|
438
|
+
Examples:
|
|
439
|
+
$ ish ask results a-6ec
|
|
440
|
+
$ ish ask results a-6ec --round 1 --json
|
|
441
|
+
|
|
442
|
+
Each pick has a \`pick_confidence\` field (0..1, when --wants-pick was set) —
|
|
443
|
+
the model's self-reported confidence in its variant choice. See
|
|
444
|
+
\`ish docs get-page concepts/ask\` for how to use it for ranking ties.`)
|
|
434
445
|
.action(async (id, opts, cmd) => {
|
|
435
446
|
await withClient(cmd, async (client, globals) => {
|
|
436
447
|
const aid = resolveAsk(pickAskRef(id, opts.ask));
|
|
@@ -539,13 +550,18 @@ lookups.`)
|
|
|
539
550
|
.requiredOption("--round <n|round-id>", "Round number (1-indexed) or round id/alias")
|
|
540
551
|
.requiredOption("--questions <file.json>", `Questions JSON file: [{"question":"...","type":"text"|"slider"|"likert"|"single-choice"|"multiple-choice"|"number"}]`)
|
|
541
552
|
.option("--redispatch-all", "Clear prior phase-1 outputs (comment, pick, ratings) and re-run the entire round from scratch (legacy behavior). Default is additive — only the new questions are answered.", false)
|
|
553
|
+
.option("--wait", "Wait until the round completes (or errors)")
|
|
554
|
+
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
542
555
|
.addHelpText("after", `
|
|
543
556
|
Examples:
|
|
544
557
|
# Additive (default): preserves prior picks/ratings/comments.
|
|
545
558
|
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json
|
|
546
559
|
|
|
560
|
+
# Wait for the round to finish before returning.
|
|
561
|
+
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json --wait
|
|
562
|
+
|
|
547
563
|
# Legacy reset: re-runs the whole round; prior picks may shift.
|
|
548
|
-
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all
|
|
564
|
+
$ ish ask add-questions a-6ec --round 1 --questions ./qs.json --redispatch-all --wait
|
|
549
565
|
|
|
550
566
|
Minimal valid --questions JSON:
|
|
551
567
|
[
|
|
@@ -566,6 +582,16 @@ text, slider, likert, single-choice, multiple-choice, number.`)
|
|
|
566
582
|
...(opts.redispatchAll && { redispatch_all: true }),
|
|
567
583
|
};
|
|
568
584
|
const updated = await client.post(`/asks/${aid}/rounds/${round.id}/questions`, body);
|
|
585
|
+
if (opts.wait) {
|
|
586
|
+
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
587
|
+
await pollUntilRoundDone(client, aid, updated.order_index, timeoutMs, !!globals.quiet);
|
|
588
|
+
const refreshed = await client.get(`/asks/${aid}`);
|
|
589
|
+
const target = refreshed.rounds.find((r) => r.id === updated.id);
|
|
590
|
+
if (target) {
|
|
591
|
+
formatRoundDetail(target, globals.json);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
569
595
|
if (!globals.json || globals.verbose) {
|
|
570
596
|
formatRoundDetail(updated, globals.json);
|
|
571
597
|
return;
|
package/dist/commands/profile.js
CHANGED
|
@@ -40,7 +40,12 @@ Examples:
|
|
|
40
40
|
$ ish profile list
|
|
41
41
|
$ ish profile list --search "engineer" --country US
|
|
42
42
|
$ ish profile list --gender female --gender male --country US --country GB
|
|
43
|
-
$ ish profile list --type all --json
|
|
43
|
+
$ ish profile list --type all --json
|
|
44
|
+
|
|
45
|
+
# Pagination — default --limit is 50; iterate with --offset:
|
|
46
|
+
$ ish profile list --limit 100
|
|
47
|
+
$ ish profile list --limit 100 --offset 100 # next page
|
|
48
|
+
# When more results exist, a stderr hint surfaces the next --offset / --limit.`)
|
|
44
49
|
.action(async (opts, cmd) => {
|
|
45
50
|
await withClient(cmd, async (client, globals) => {
|
|
46
51
|
const params = {
|
|
@@ -63,18 +68,26 @@ Examples:
|
|
|
63
68
|
const data = await client.get("/tester-profiles", params);
|
|
64
69
|
formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
|
|
65
70
|
// Pattern H1: when there's more data, surface a stderr hint so agents
|
|
66
|
-
// know `--limit / --offset` exist without re-reading help.
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
71
|
+
// know `--limit / --offset` exist without re-reading help. Stderr only,
|
|
72
|
+
// so it doesn't pollute machine-readable stdout — that means we DON'T
|
|
73
|
+
// gate on globals.json (auto-flips on pipe and would silence the hint
|
|
74
|
+
// exactly when the agent needs it most). --quiet is the explicit
|
|
75
|
+
// opt-out for progress chatter.
|
|
76
|
+
//
|
|
77
|
+
// The raw `/tester-profiles` envelope is `{items, total, limit, offset}`
|
|
78
|
+
// — `has_more` and `returned` are synthesized client-side by `wrapList`
|
|
79
|
+
// only when the response is rendered. Compute them locally here so the
|
|
80
|
+
// hint actually fires; reading `data.has_more` directly would always
|
|
81
|
+
// see undefined and silently skip.
|
|
82
|
+
if (!globals.quietExplicit && typeof data === "object" && data !== null) {
|
|
70
83
|
const d = data;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
84
|
+
const items = Array.isArray(d.items) ? d.items : [];
|
|
85
|
+
const returned = items.length;
|
|
86
|
+
const total = typeof d.total === "number" ? d.total : returned;
|
|
87
|
+
const offset = typeof d.offset === "number" ? d.offset : parseInt(opts.offset, 10) || 0;
|
|
88
|
+
const hasMore = total > offset + returned;
|
|
89
|
+
if (hasMore && returned > 0) {
|
|
90
|
+
console.error(`\n showing ${offset + 1}–${offset + returned} of ${total}; pass --offset ${offset + returned} --limit ${returned} for more.`);
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
});
|
package/dist/commands/study.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ish study — Manage studies.
|
|
3
3
|
*/
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
-
import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
|
|
5
|
+
import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive } from "../lib/command-helpers.js";
|
|
6
6
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
7
7
|
import { loadConfig, saveConfig } from "../config.js";
|
|
8
8
|
import { formatStudyList, formatStudyDetail, formatStudyResults, output, ValidationError } from "../lib/output.js";
|
|
@@ -152,6 +152,8 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
152
152
|
// or --url is provided, so a single `study create` produces a study
|
|
153
153
|
// that's immediately runnable. Without these flags the backend
|
|
154
154
|
// creates zero iterations and the first `iteration create` becomes A.
|
|
155
|
+
// The backend requires a non-empty `name` on the inline iteration; we
|
|
156
|
+
// default to "A" to match the iteration-naming convention.
|
|
155
157
|
let inlineIteration;
|
|
156
158
|
if (opts.contentText !== undefined) {
|
|
157
159
|
if (opts.modality && opts.modality !== "text") {
|
|
@@ -160,13 +162,14 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
160
162
|
const text = opts.contentText.startsWith("@")
|
|
161
163
|
? readFileSync(opts.contentText.slice(1), "utf8")
|
|
162
164
|
: opts.contentText;
|
|
163
|
-
inlineIteration = { details: { type: "text", content_text: text } };
|
|
165
|
+
inlineIteration = { name: "A", details: { type: "text", content_text: text } };
|
|
164
166
|
}
|
|
165
167
|
else if (opts.url !== undefined) {
|
|
166
168
|
if (opts.modality && opts.modality !== "interactive") {
|
|
167
169
|
throw new Error(`--url is only valid with --modality interactive (got "${opts.modality}").`);
|
|
168
170
|
}
|
|
169
171
|
inlineIteration = {
|
|
172
|
+
name: "A",
|
|
170
173
|
details: { type: "interactive", url: opts.url, platform: "browser" },
|
|
171
174
|
};
|
|
172
175
|
}
|
|
@@ -374,10 +377,15 @@ Examples:
|
|
|
374
377
|
.description("Delete a study")
|
|
375
378
|
.argument("<id>", "Study ID")
|
|
376
379
|
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
377
|
-
.
|
|
378
|
-
.
|
|
380
|
+
.option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
|
|
381
|
+
.addHelpText("after", "\nExamples:\n $ ish study delete <id> # interactive — prompts for confirmation\n $ ish study delete <id> --yes # non-interactive\n $ ish study delete <id> --json --yes")
|
|
382
|
+
.action(async (id, opts, cmd) => {
|
|
379
383
|
await withClient(cmd, async (client, globals) => {
|
|
380
384
|
const rid = resolveId(id);
|
|
385
|
+
await confirmDestructive(`Delete study ${tagAlias(ALIAS_PREFIX.study, rid)}? This cannot be undone.`, {
|
|
386
|
+
yes: opts.yes,
|
|
387
|
+
json: globals.json,
|
|
388
|
+
});
|
|
381
389
|
await client.del(`/studies/${rid}`);
|
|
382
390
|
output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), message: "Study deleted" }, globals.json, { writePath: true });
|
|
383
391
|
});
|
package/dist/connect.js
CHANGED
|
@@ -5,7 +5,7 @@ import { spawn, execSync } from "node:child_process";
|
|
|
5
5
|
import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { loadConfig, saveConfig } from "./config.js";
|
|
8
|
-
import { refreshTokens, isTokenExpired, decodeJwtExp } from "./auth.js";
|
|
8
|
+
import { refreshTokens, isTokenExpired, decodeJwtExp, AuthRefreshPermanentError } from "./auth.js";
|
|
9
9
|
import { binDir, cloudflaredBin, simulationsDir } from "./lib/paths.js";
|
|
10
10
|
import { c } from "./lib/colors.js";
|
|
11
11
|
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
@@ -402,7 +402,14 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
|
402
402
|
return false;
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Best-effort deregister. When `suppressAuthFailures` is true (we're already
|
|
407
|
+
* shutting down because of an auth-permanent failure), 401/403 errors during
|
|
408
|
+
* deregister are silently swallowed — logging "Failed to deregister: HTTP 401"
|
|
409
|
+
* right after telling the user their auth expired is ironic and noisy.
|
|
410
|
+
* Non-auth failures are still logged.
|
|
411
|
+
*/
|
|
412
|
+
async function deregisterTunnel(apiUrl, token, json, suppressAuthFailures = false) {
|
|
406
413
|
try {
|
|
407
414
|
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
408
415
|
method: "DELETE",
|
|
@@ -419,9 +426,28 @@ async function deregisterTunnel(apiUrl, token, json) {
|
|
|
419
426
|
}
|
|
420
427
|
}
|
|
421
428
|
catch (e) {
|
|
429
|
+
if (suppressAuthFailures && /HTTP (401|403)/.test(String(e)))
|
|
430
|
+
return;
|
|
422
431
|
console.error(`Warning: Failed to deregister connection: ${e}`);
|
|
423
432
|
}
|
|
424
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Classify whether a thrown error from the auth refresh path is permanent.
|
|
436
|
+
* Permanent = "the local config can't recover, user must `ish login` again."
|
|
437
|
+
* Examples: Supabase `refresh_token_not_found`, `invalid_grant`, any 4xx on
|
|
438
|
+
* the refresh endpoint. Network blips and 5xx are NOT permanent.
|
|
439
|
+
*
|
|
440
|
+
* The typed sentinel from `src/auth.ts` is the primary signal. We also string-
|
|
441
|
+
* match legacy error messages in case something throws a plain Error.
|
|
442
|
+
*/
|
|
443
|
+
function isPermanentAuthFailure(err) {
|
|
444
|
+
if (err instanceof AuthRefreshPermanentError)
|
|
445
|
+
return true;
|
|
446
|
+
if (err instanceof Error) {
|
|
447
|
+
return /refresh_token_not_found|invalid_grant|Token refresh failed \(HTTP 4\d\d/i.test(err.message);
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
425
451
|
function processHeartbeatResponse(resp, renderCards) {
|
|
426
452
|
resp.json().then((data) => {
|
|
427
453
|
const sims = data.simulations ?? [];
|
|
@@ -437,7 +463,7 @@ function processHeartbeatResponse(resp, renderCards) {
|
|
|
437
463
|
// Non-fatal: response parsing failed, silently continue
|
|
438
464
|
});
|
|
439
465
|
}
|
|
440
|
-
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, json) {
|
|
466
|
+
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json) {
|
|
441
467
|
let consecutiveFailures = 0;
|
|
442
468
|
let stopped = false;
|
|
443
469
|
const interval = setInterval(async () => {
|
|
@@ -469,6 +495,15 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
|
|
|
469
495
|
return;
|
|
470
496
|
}
|
|
471
497
|
catch (refreshErr) {
|
|
498
|
+
// Permanent refresh failure (refresh_token_not_found etc.) — no
|
|
499
|
+
// amount of retrying will fix this. Bail out immediately with an
|
|
500
|
+
// actionable message; don't waste the 3-strike countdown.
|
|
501
|
+
if (isPermanentAuthFailure(refreshErr)) {
|
|
502
|
+
stopped = true;
|
|
503
|
+
clearInterval(interval);
|
|
504
|
+
await onAuthPermanent(refreshErr);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
472
507
|
console.error(`Token refresh failed: ${refreshErr}`);
|
|
473
508
|
}
|
|
474
509
|
}
|
|
@@ -499,7 +534,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
|
|
|
499
534
|
* Schedule a proactive token refresh before the JWT expires.
|
|
500
535
|
* Refreshes 10 minutes before expiry.
|
|
501
536
|
*/
|
|
502
|
-
function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
|
|
537
|
+
function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, onAuthPermanent, json) {
|
|
503
538
|
if (!doRefresh)
|
|
504
539
|
return { stop: () => { } };
|
|
505
540
|
const exp = decodeJwtExp(token);
|
|
@@ -516,9 +551,15 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
|
|
|
516
551
|
if (!json)
|
|
517
552
|
console.error("Token proactively refreshed.");
|
|
518
553
|
// Schedule next refresh for the new token
|
|
519
|
-
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
|
|
554
|
+
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, onAuthPermanent, json);
|
|
520
555
|
}
|
|
521
556
|
catch (e) {
|
|
557
|
+
// Permanent refresh failure: short-circuit to the actionable message
|
|
558
|
+
// path instead of letting the next heartbeat eat 3 strikes.
|
|
559
|
+
if (isPermanentAuthFailure(e)) {
|
|
560
|
+
await onAuthPermanent(e);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
522
563
|
console.error(`Proactive token refresh failed: ${e}`);
|
|
523
564
|
}
|
|
524
565
|
}, delay);
|
|
@@ -572,20 +613,54 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
572
613
|
console.log(`Tunnel URL: ${tunnelUrl} → http://localhost:${port}\n`);
|
|
573
614
|
}
|
|
574
615
|
let shuttingDown = false;
|
|
575
|
-
|
|
616
|
+
// Holders for forward references — `onAuthPermanent` needs to stop these
|
|
617
|
+
// timers, but `startHeartbeat`/`scheduleProactiveRefresh` need
|
|
618
|
+
// `onAuthPermanent` to wire up. Declared as nullable, assigned just below.
|
|
619
|
+
let heartbeat = null;
|
|
620
|
+
let proactiveRefresh = null;
|
|
621
|
+
// Fast-fail path for non-recoverable auth failures (e.g. Supabase
|
|
622
|
+
// refresh_token_not_found). Skips the 3-strike heartbeat countdown and
|
|
623
|
+
// gives the user a single actionable next step.
|
|
624
|
+
const onAuthPermanent = async (cause) => {
|
|
625
|
+
if (shuttingDown)
|
|
626
|
+
return;
|
|
627
|
+
shuttingDown = true;
|
|
628
|
+
heartbeat?.stop();
|
|
629
|
+
proactiveRefresh?.stop();
|
|
630
|
+
if (json) {
|
|
631
|
+
console.error(JSON.stringify({
|
|
632
|
+
status: "auth_expired",
|
|
633
|
+
message: `Authentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`,
|
|
634
|
+
detail: "refresh_token expired or revoked; the local config can't recover",
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
console.error(`\nAuthentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`);
|
|
639
|
+
console.error("(refresh_token expired or revoked; the local config can't recover)");
|
|
640
|
+
}
|
|
641
|
+
if (cause instanceof Error && cause.message) {
|
|
642
|
+
// Keep the underlying detail discoverable for log scrapers / agents,
|
|
643
|
+
// without making it the headline.
|
|
644
|
+
console.error(`Cause: ${cause.message}`);
|
|
645
|
+
}
|
|
646
|
+
await deregisterTunnel(apiUrl, currentToken, json, true);
|
|
647
|
+
cfProcess.kill();
|
|
648
|
+
process.exit(3);
|
|
649
|
+
};
|
|
650
|
+
heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
|
|
576
651
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
577
652
|
cfProcess.kill();
|
|
578
653
|
process.exit(1);
|
|
579
|
-
}, json);
|
|
580
|
-
|
|
654
|
+
}, onAuthPermanent, json);
|
|
655
|
+
proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
|
|
581
656
|
const shutdown = async () => {
|
|
582
657
|
if (shuttingDown)
|
|
583
658
|
process.exit(1);
|
|
584
659
|
shuttingDown = true;
|
|
585
660
|
if (!json)
|
|
586
661
|
console.error("\nShutting down...");
|
|
587
|
-
heartbeat
|
|
588
|
-
proactiveRefresh
|
|
662
|
+
heartbeat?.stop();
|
|
663
|
+
proactiveRefresh?.stop();
|
|
589
664
|
cfProcess.kill();
|
|
590
665
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
591
666
|
process.exit(0);
|
|
@@ -597,8 +672,8 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
597
672
|
}
|
|
598
673
|
cfProcess.on("exit", async () => {
|
|
599
674
|
if (!shuttingDown) {
|
|
600
|
-
heartbeat
|
|
601
|
-
proactiveRefresh
|
|
675
|
+
heartbeat?.stop();
|
|
676
|
+
proactiveRefresh?.stop();
|
|
602
677
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
603
678
|
process.exit(0);
|
|
604
679
|
}
|
package/dist/index.js
CHANGED
|
@@ -69,7 +69,10 @@ program
|
|
|
69
69
|
.option("--token-file <path>", "Read auth token from a file (preferred over --token / ISH_TOKEN)")
|
|
70
70
|
.option("--api-url <url>", "Backend API URL (default: ISH_API_URL or https://api.ishlabs.io)")
|
|
71
71
|
.addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
|
|
72
|
+
.option("--workspace <id>", "Default workspace ID; per-subcommand --workspace overrides")
|
|
72
73
|
.option("--json", "Output as JSON (auto-enabled when piped)")
|
|
74
|
+
.option("--get <field>", "Extract a single field from the JSON response and print only its value (implies --json internally; supports dotted paths e.g. tester_profile.name)")
|
|
75
|
+
.option("--human", "Force human-readable output even when stdout is piped (overrides JSON-when-piped auto-detection)")
|
|
73
76
|
.option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. alias,name,status)")
|
|
74
77
|
.option("--verbose", "Include full UUIDs and timestamps in JSON output")
|
|
75
78
|
.option("--no-color", "Disable colored output (also honored: NO_COLOR env var)")
|
|
@@ -58,6 +58,33 @@ export interface GlobalOpts {
|
|
|
58
58
|
quiet: boolean;
|
|
59
59
|
color: boolean;
|
|
60
60
|
fields?: string[];
|
|
61
|
+
/**
|
|
62
|
+
* --get <field>: capture mode. Extracts a single field from the JSON
|
|
63
|
+
* response and prints its bare value. Implies --json internally so the
|
|
64
|
+
* renderer always has structured data to extract from.
|
|
65
|
+
*/
|
|
66
|
+
get?: string;
|
|
67
|
+
/**
|
|
68
|
+
* --human: forces the human renderer regardless of TTY/pipe state.
|
|
69
|
+
* Mutually exclusive with --get (capture vs display).
|
|
70
|
+
*/
|
|
71
|
+
human?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Program-level --workspace from the root flag. Subcommand-level --workspace
|
|
74
|
+
* still wins via Commander's optsWithGlobals merge (subcommand opts override
|
|
75
|
+
* parent), so this is effectively the "default workspace for the invocation"
|
|
76
|
+
* agents reflexively pass at the program root.
|
|
77
|
+
*/
|
|
78
|
+
workspace?: string;
|
|
79
|
+
/**
|
|
80
|
+
* True only when the user explicitly passed `--quiet` / `-q` (or `--get`).
|
|
81
|
+
* Distinct from `quiet`, which also flips on for auto-quiet (the
|
|
82
|
+
* piped-stdout/auto-JSON path). Use this for actionable hints (e.g. the
|
|
83
|
+
* `profile list` pagination hint) that should still surface when an agent
|
|
84
|
+
* pipes output but hasn't asked for silence — auto-quiet is for progress
|
|
85
|
+
* chatter, not diagnostic signals.
|
|
86
|
+
*/
|
|
87
|
+
quietExplicit: boolean;
|
|
61
88
|
}
|
|
62
89
|
export declare function getGlobals(cmd: Command): GlobalOpts;
|
|
63
90
|
/**
|
|
@@ -83,6 +110,16 @@ export declare function runInline(cmd: Command, fn: (globals: GlobalOpts) => Pro
|
|
|
83
110
|
export declare function getWebUrl(globals: GlobalOpts, path: string): string;
|
|
84
111
|
export declare function terminalLink(url: string, text: string): string;
|
|
85
112
|
export declare function readJsonFileOrStdin(filePath?: string): Promise<unknown>;
|
|
113
|
+
/**
|
|
114
|
+
* Prompt for confirmation of a destructive action, or short-circuit when
|
|
115
|
+
* `--yes` is set. In `--json` mode without `--yes` we refuse with a usage
|
|
116
|
+
* error rather than silently proceeding or hanging on a prompt — agents
|
|
117
|
+
* piping through CLI must be explicit about destructive intent.
|
|
118
|
+
*/
|
|
119
|
+
export declare function confirmDestructive(prompt: string, opts: {
|
|
120
|
+
yes?: boolean;
|
|
121
|
+
json?: boolean;
|
|
122
|
+
}): Promise<void>;
|
|
86
123
|
export declare function resolveWorkspace(explicit?: string): string;
|
|
87
124
|
export declare function resolveStudy(explicit?: string): string;
|
|
88
125
|
export declare function resolveAsk(explicit?: string): string;
|