@ishlabs/cli 0.8.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/auth.d.ts +38 -4
- package/dist/auth.js +205 -39
- package/dist/commands/ask.js +28 -2
- package/dist/commands/iteration.js +105 -6
- package/dist/commands/profile.js +25 -12
- package/dist/commands/source.js +24 -2
- package/dist/commands/study.js +14 -6
- package/dist/config.d.ts +4 -0
- package/dist/connect.js +100 -14
- package/dist/index.js +6 -3
- package/dist/lib/auth.js +7 -1
- package/dist/lib/command-helpers.d.ts +37 -0
- package/dist/lib/command-helpers.js +199 -7
- package/dist/lib/docs.js +316 -39
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +133 -2
- package/dist/lib/skill-content.js +120 -6
- package/package.json +3 -3
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/source.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* generation runs, or to inspect processing status.
|
|
8
8
|
*/
|
|
9
9
|
import { withClient, resolveWorkspace } from "../lib/command-helpers.js";
|
|
10
|
-
import { resolveId } from "../lib/alias-store.js";
|
|
11
|
-
import { formatAudienceSource } from "../lib/output.js";
|
|
10
|
+
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
11
|
+
import { formatAudienceSource, output } from "../lib/output.js";
|
|
12
12
|
import { inferSourceKind, uploadAndProcessSource, } from "../lib/profile-sources.js";
|
|
13
13
|
const VALID_KINDS = ["text_file", "audio", "image"];
|
|
14
14
|
export function registerSourceCommands(program) {
|
|
@@ -75,4 +75,26 @@ Examples:
|
|
|
75
75
|
formatAudienceSource(src, globals.json);
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
|
+
source
|
|
79
|
+
.command("delete")
|
|
80
|
+
.description("Delete an audience source plus its uploaded file")
|
|
81
|
+
.argument("<id>", "Source ID or alias")
|
|
82
|
+
.addHelpText("after", `
|
|
83
|
+
Removes both the database row and the underlying uploaded file. Profiles
|
|
84
|
+
already generated from this source remain in place — they don't reference
|
|
85
|
+
the source after generation.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
$ ish source delete tps-3a4`)
|
|
89
|
+
.action(async (id, _opts, cmd) => {
|
|
90
|
+
await withClient(cmd, async (client, globals) => {
|
|
91
|
+
const rid = resolveId(id);
|
|
92
|
+
await client.del(`/tester-profiles/sources/${rid}`);
|
|
93
|
+
output({
|
|
94
|
+
id: rid,
|
|
95
|
+
alias: tagAlias(ALIAS_PREFIX.testerProfileSource, rid),
|
|
96
|
+
message: "Source deleted",
|
|
97
|
+
}, globals.json, { writePath: true });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
78
100
|
}
|
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";
|
|
@@ -81,7 +81,7 @@ Concept pages: ish docs get-page concepts/study
|
|
|
81
81
|
.option("--workspace <id>", "Workspace ID")
|
|
82
82
|
.requiredOption("--name <name>", "Study name")
|
|
83
83
|
.option("--description <description>", "Study description")
|
|
84
|
-
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
|
|
84
|
+
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
|
|
85
85
|
.option("--content-type <type>", "Content type (varies by modality — see examples below)")
|
|
86
86
|
.option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
|
|
87
87
|
.option("--assignments-file <path>", "JSON file with assignments array")
|
|
@@ -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
|
}
|
|
@@ -314,7 +317,7 @@ When no runs have completed, the same envelope is returned with zero counts and
|
|
|
314
317
|
.option("--name <name>", "Study name")
|
|
315
318
|
.option("--description <description>", "Study description")
|
|
316
319
|
.option("--status <status>", "Study status (draft, running, completed)")
|
|
317
|
-
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document)")
|
|
320
|
+
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
|
|
318
321
|
.option("--content-type <type>", "Content type")
|
|
319
322
|
.option("--assignment <name:instructions>", "Replace all assignments with these (repeatable)", collectRepeatable, [])
|
|
320
323
|
.option("--assignments-file <path>", "JSON file with assignments array")
|
|
@@ -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/config.d.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
export interface IshConfig {
|
|
6
6
|
access_token?: string;
|
|
7
7
|
refresh_token?: string;
|
|
8
|
+
/** OAuth client_id minted by Supabase DCR at last login. Required to refresh
|
|
9
|
+
* tokens issued by Supabase OAuth Server (public client → must include
|
|
10
|
+
* client_id on refresh). Absent for legacy/non-OAuth tokens. */
|
|
11
|
+
oauth_client_id?: string;
|
|
8
12
|
token?: string;
|
|
9
13
|
workspace?: string;
|
|
10
14
|
study?: string;
|
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/;
|
|
@@ -233,8 +233,14 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
|
|
|
233
233
|
let accessToken = config.access_token;
|
|
234
234
|
// Refresh if expired or close to expiry
|
|
235
235
|
if (isTokenExpired(accessToken)) {
|
|
236
|
+
if (!config.oauth_client_id) {
|
|
237
|
+
throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
|
|
238
|
+
}
|
|
236
239
|
try {
|
|
237
|
-
const tokens = await refreshTokens(config.refresh_token
|
|
240
|
+
const tokens = await refreshTokens(config.refresh_token, {
|
|
241
|
+
accessToken,
|
|
242
|
+
clientId: config.oauth_client_id,
|
|
243
|
+
});
|
|
238
244
|
accessToken = tokens.accessToken;
|
|
239
245
|
config.access_token = tokens.accessToken;
|
|
240
246
|
config.refresh_token = tokens.refreshToken;
|
|
@@ -250,7 +256,12 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
|
|
|
250
256
|
const cfg = loadConfig();
|
|
251
257
|
if (!cfg.refresh_token)
|
|
252
258
|
throw new Error("No refresh token");
|
|
253
|
-
|
|
259
|
+
if (!cfg.oauth_client_id)
|
|
260
|
+
throw new Error('Missing oauth_client_id; run "ish login" again.');
|
|
261
|
+
const tokens = await refreshTokens(cfg.refresh_token, {
|
|
262
|
+
accessToken: cfg.access_token,
|
|
263
|
+
clientId: cfg.oauth_client_id,
|
|
264
|
+
});
|
|
254
265
|
cfg.access_token = tokens.accessToken;
|
|
255
266
|
cfg.refresh_token = tokens.refreshToken;
|
|
256
267
|
saveConfig(cfg);
|
|
@@ -402,7 +413,14 @@ async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
|
402
413
|
return false;
|
|
403
414
|
}
|
|
404
415
|
}
|
|
405
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Best-effort deregister. When `suppressAuthFailures` is true (we're already
|
|
418
|
+
* shutting down because of an auth-permanent failure), 401/403 errors during
|
|
419
|
+
* deregister are silently swallowed — logging "Failed to deregister: HTTP 401"
|
|
420
|
+
* right after telling the user their auth expired is ironic and noisy.
|
|
421
|
+
* Non-auth failures are still logged.
|
|
422
|
+
*/
|
|
423
|
+
async function deregisterTunnel(apiUrl, token, json, suppressAuthFailures = false) {
|
|
406
424
|
try {
|
|
407
425
|
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
408
426
|
method: "DELETE",
|
|
@@ -419,9 +437,28 @@ async function deregisterTunnel(apiUrl, token, json) {
|
|
|
419
437
|
}
|
|
420
438
|
}
|
|
421
439
|
catch (e) {
|
|
440
|
+
if (suppressAuthFailures && /HTTP (401|403)/.test(String(e)))
|
|
441
|
+
return;
|
|
422
442
|
console.error(`Warning: Failed to deregister connection: ${e}`);
|
|
423
443
|
}
|
|
424
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Classify whether a thrown error from the auth refresh path is permanent.
|
|
447
|
+
* Permanent = "the local config can't recover, user must `ish login` again."
|
|
448
|
+
* Examples: Supabase `refresh_token_not_found`, `invalid_grant`, any 4xx on
|
|
449
|
+
* the refresh endpoint. Network blips and 5xx are NOT permanent.
|
|
450
|
+
*
|
|
451
|
+
* The typed sentinel from `src/auth.ts` is the primary signal. We also string-
|
|
452
|
+
* match legacy error messages in case something throws a plain Error.
|
|
453
|
+
*/
|
|
454
|
+
function isPermanentAuthFailure(err) {
|
|
455
|
+
if (err instanceof AuthRefreshPermanentError)
|
|
456
|
+
return true;
|
|
457
|
+
if (err instanceof Error) {
|
|
458
|
+
return /refresh_token_not_found|invalid_grant|Token refresh failed \(HTTP 4\d\d/i.test(err.message);
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
425
462
|
function processHeartbeatResponse(resp, renderCards) {
|
|
426
463
|
resp.json().then((data) => {
|
|
427
464
|
const sims = data.simulations ?? [];
|
|
@@ -437,7 +474,7 @@ function processHeartbeatResponse(resp, renderCards) {
|
|
|
437
474
|
// Non-fatal: response parsing failed, silently continue
|
|
438
475
|
});
|
|
439
476
|
}
|
|
440
|
-
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, json) {
|
|
477
|
+
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json) {
|
|
441
478
|
let consecutiveFailures = 0;
|
|
442
479
|
let stopped = false;
|
|
443
480
|
const interval = setInterval(async () => {
|
|
@@ -469,6 +506,15 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
|
|
|
469
506
|
return;
|
|
470
507
|
}
|
|
471
508
|
catch (refreshErr) {
|
|
509
|
+
// Permanent refresh failure (refresh_token_not_found etc.) — no
|
|
510
|
+
// amount of retrying will fix this. Bail out immediately with an
|
|
511
|
+
// actionable message; don't waste the 3-strike countdown.
|
|
512
|
+
if (isPermanentAuthFailure(refreshErr)) {
|
|
513
|
+
stopped = true;
|
|
514
|
+
clearInterval(interval);
|
|
515
|
+
await onAuthPermanent(refreshErr);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
472
518
|
console.error(`Token refresh failed: ${refreshErr}`);
|
|
473
519
|
}
|
|
474
520
|
}
|
|
@@ -499,7 +545,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
|
|
|
499
545
|
* Schedule a proactive token refresh before the JWT expires.
|
|
500
546
|
* Refreshes 10 minutes before expiry.
|
|
501
547
|
*/
|
|
502
|
-
function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
|
|
548
|
+
function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, onAuthPermanent, json) {
|
|
503
549
|
if (!doRefresh)
|
|
504
550
|
return { stop: () => { } };
|
|
505
551
|
const exp = decodeJwtExp(token);
|
|
@@ -516,9 +562,15 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
|
|
|
516
562
|
if (!json)
|
|
517
563
|
console.error("Token proactively refreshed.");
|
|
518
564
|
// Schedule next refresh for the new token
|
|
519
|
-
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
|
|
565
|
+
scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, onAuthPermanent, json);
|
|
520
566
|
}
|
|
521
567
|
catch (e) {
|
|
568
|
+
// Permanent refresh failure: short-circuit to the actionable message
|
|
569
|
+
// path instead of letting the next heartbeat eat 3 strikes.
|
|
570
|
+
if (isPermanentAuthFailure(e)) {
|
|
571
|
+
await onAuthPermanent(e);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
522
574
|
console.error(`Proactive token refresh failed: ${e}`);
|
|
523
575
|
}
|
|
524
576
|
}, delay);
|
|
@@ -572,20 +624,54 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
572
624
|
console.log(`Tunnel URL: ${tunnelUrl} → http://localhost:${port}\n`);
|
|
573
625
|
}
|
|
574
626
|
let shuttingDown = false;
|
|
575
|
-
|
|
627
|
+
// Holders for forward references — `onAuthPermanent` needs to stop these
|
|
628
|
+
// timers, but `startHeartbeat`/`scheduleProactiveRefresh` need
|
|
629
|
+
// `onAuthPermanent` to wire up. Declared as nullable, assigned just below.
|
|
630
|
+
let heartbeat = null;
|
|
631
|
+
let proactiveRefresh = null;
|
|
632
|
+
// Fast-fail path for non-recoverable auth failures (e.g. Supabase
|
|
633
|
+
// refresh_token_not_found). Skips the 3-strike heartbeat countdown and
|
|
634
|
+
// gives the user a single actionable next step.
|
|
635
|
+
const onAuthPermanent = async (cause) => {
|
|
636
|
+
if (shuttingDown)
|
|
637
|
+
return;
|
|
638
|
+
shuttingDown = true;
|
|
639
|
+
heartbeat?.stop();
|
|
640
|
+
proactiveRefresh?.stop();
|
|
641
|
+
if (json) {
|
|
642
|
+
console.error(JSON.stringify({
|
|
643
|
+
status: "auth_expired",
|
|
644
|
+
message: `Authentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`,
|
|
645
|
+
detail: "refresh_token expired or revoked; the local config can't recover",
|
|
646
|
+
}));
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
console.error(`\nAuthentication expired. Run \`ish login\` and re-run \`ish connect ${port}\`.`);
|
|
650
|
+
console.error("(refresh_token expired or revoked; the local config can't recover)");
|
|
651
|
+
}
|
|
652
|
+
if (cause instanceof Error && cause.message) {
|
|
653
|
+
// Keep the underlying detail discoverable for log scrapers / agents,
|
|
654
|
+
// without making it the headline.
|
|
655
|
+
console.error(`Cause: ${cause.message}`);
|
|
656
|
+
}
|
|
657
|
+
await deregisterTunnel(apiUrl, currentToken, json, true);
|
|
658
|
+
cfProcess.kill();
|
|
659
|
+
process.exit(3);
|
|
660
|
+
};
|
|
661
|
+
heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
|
|
576
662
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
577
663
|
cfProcess.kill();
|
|
578
664
|
process.exit(1);
|
|
579
|
-
}, json);
|
|
580
|
-
|
|
665
|
+
}, onAuthPermanent, json);
|
|
666
|
+
proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
|
|
581
667
|
const shutdown = async () => {
|
|
582
668
|
if (shuttingDown)
|
|
583
669
|
process.exit(1);
|
|
584
670
|
shuttingDown = true;
|
|
585
671
|
if (!json)
|
|
586
672
|
console.error("\nShutting down...");
|
|
587
|
-
heartbeat
|
|
588
|
-
proactiveRefresh
|
|
673
|
+
heartbeat?.stop();
|
|
674
|
+
proactiveRefresh?.stop();
|
|
589
675
|
cfProcess.kill();
|
|
590
676
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
591
677
|
process.exit(0);
|
|
@@ -597,8 +683,8 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
597
683
|
}
|
|
598
684
|
cfProcess.on("exit", async () => {
|
|
599
685
|
if (!shuttingDown) {
|
|
600
|
-
heartbeat
|
|
601
|
-
proactiveRefresh
|
|
686
|
+
heartbeat?.stop();
|
|
687
|
+
proactiveRefresh?.stop();
|
|
602
688
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
603
689
|
process.exit(0);
|
|
604
690
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program, Option } from "commander";
|
|
3
3
|
import { runTunnel } from "./connect.js";
|
|
4
|
-
import { login,
|
|
4
|
+
import { login, decodeJwtClaims } from "./auth.js";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
6
6
|
import { upgrade } from "./upgrade.js";
|
|
7
7
|
import { registerWorkspaceCommands } from "./commands/workspace.js";
|
|
@@ -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)")
|
|
@@ -80,11 +83,11 @@ program
|
|
|
80
83
|
.description("Authenticate with Ish via your browser")
|
|
81
84
|
.action(async (_opts, cmd) => {
|
|
82
85
|
await runInline(cmd, async (globals) => {
|
|
83
|
-
const
|
|
84
|
-
const tokens = await login(appUrl);
|
|
86
|
+
const tokens = await login();
|
|
85
87
|
const config = loadConfig();
|
|
86
88
|
config.access_token = tokens.accessToken;
|
|
87
89
|
config.refresh_token = tokens.refreshToken;
|
|
90
|
+
config.oauth_client_id = tokens.clientId;
|
|
88
91
|
saveConfig(config);
|
|
89
92
|
output({ message: "Login successful" }, globals.json);
|
|
90
93
|
});
|
package/dist/lib/auth.js
CHANGED
|
@@ -57,8 +57,14 @@ export async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
|
|
|
57
57
|
let accessToken = config.access_token;
|
|
58
58
|
// Refresh if expired or close to expiry
|
|
59
59
|
if (isTokenExpired(accessToken)) {
|
|
60
|
+
if (!config.oauth_client_id) {
|
|
61
|
+
throw new Error('Saved tokens are missing oauth_client_id. Run "ish login" to re-authenticate.');
|
|
62
|
+
}
|
|
60
63
|
try {
|
|
61
|
-
const tokens = await refreshTokens(config.refresh_token, {
|
|
64
|
+
const tokens = await refreshTokens(config.refresh_token, {
|
|
65
|
+
accessToken,
|
|
66
|
+
clientId: config.oauth_client_id,
|
|
67
|
+
});
|
|
62
68
|
accessToken = tokens.accessToken;
|
|
63
69
|
config.access_token = tokens.accessToken;
|
|
64
70
|
config.refresh_token = tokens.refreshToken;
|
|
@@ -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;
|