@ishlabs/cli 0.8.5 → 0.10.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 +55 -6
- package/dist/auth.d.ts +23 -4
- package/dist/auth.js +165 -39
- package/dist/commands/ask.d.ts +12 -0
- package/dist/commands/ask.js +127 -2
- package/dist/commands/chat.d.ts +17 -0
- package/dist/commands/chat.js +589 -0
- package/dist/commands/iteration.js +232 -13
- package/dist/commands/secret.d.ts +20 -0
- package/dist/commands/secret.js +246 -0
- package/dist/commands/source.js +24 -2
- package/dist/commands/study-run.d.ts +38 -0
- package/dist/commands/study-run.js +199 -80
- package/dist/commands/study-tester.js +17 -2
- package/dist/commands/study.js +311 -39
- package/dist/commands/workspace.js +81 -0
- package/dist/config.d.ts +7 -0
- package/dist/connect.d.ts +3 -0
- package/dist/connect.js +359 -24
- package/dist/index.js +67 -9
- package/dist/lib/alias-hydrate.d.ts +42 -0
- package/dist/lib/alias-hydrate.js +175 -0
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +28 -1
- package/dist/lib/auth.js +11 -3
- package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
- package/dist/lib/chat-endpoint-formatters.js +104 -0
- package/dist/lib/command-helpers.d.ts +18 -0
- package/dist/lib/command-helpers.js +188 -53
- package/dist/lib/docs.js +662 -34
- package/dist/lib/modality.d.ts +42 -0
- package/dist/lib/modality.js +192 -0
- package/dist/lib/output.d.ts +41 -0
- package/dist/lib/output.js +453 -19
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +183 -13
- package/dist/lib/types.d.ts +15 -0
- package/package.json +3 -3
package/dist/connect.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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 { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, chmodSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { loadConfig, saveConfig } from "./config.js";
|
|
8
8
|
import { refreshTokens, isTokenExpired, decodeJwtExp, AuthRefreshPermanentError } from "./auth.js";
|
|
9
|
-
import { binDir, cloudflaredBin, simulationsDir } from "./lib/paths.js";
|
|
9
|
+
import { binDir, cloudflaredBin, simulationsDir, connectLockPath, ishDir } from "./lib/paths.js";
|
|
10
10
|
import { c } from "./lib/colors.js";
|
|
11
11
|
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
12
12
|
const HEARTBEAT_INTERVAL = 10_000;
|
|
@@ -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);
|
|
@@ -382,25 +393,48 @@ function startCloudflared(port, binPath, json) {
|
|
|
382
393
|
}
|
|
383
394
|
// --- API calls ---
|
|
384
395
|
async function registerTunnel(apiUrl, token, tunnelUrl, port) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
396
|
+
// M6 / Pattern G: registration sometimes 5xxs / 404s on the first POST
|
|
397
|
+
// after a backend bounce or a routing-cache miss; the previous
|
|
398
|
+
// single-shot attempt left `--detach` clients writing the lock with
|
|
399
|
+
// `registered: false`, then the heartbeat would 404 and burn through
|
|
400
|
+
// the 3-strike countdown. Retry up to 4 times with exponential backoff
|
|
401
|
+
// (~0.5s + 1s + 2s + 4s = ~7.5s of wall time worst-case) before
|
|
402
|
+
// giving up. 4xx other than 404/408/429 fail fast — they're permanent.
|
|
403
|
+
const maxAttempts = 4;
|
|
404
|
+
let lastErr;
|
|
405
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
406
|
+
if (attempt > 0) {
|
|
407
|
+
const delayMs = Math.min(4_000, 500 * Math.pow(2, attempt - 1));
|
|
408
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
headers: {
|
|
414
|
+
Authorization: `Bearer ${token}`,
|
|
415
|
+
"Content-Type": "application/json",
|
|
416
|
+
},
|
|
417
|
+
body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
|
|
418
|
+
signal: AbortSignal.timeout(10_000),
|
|
419
|
+
});
|
|
420
|
+
if (resp.ok)
|
|
421
|
+
return true;
|
|
422
|
+
// 4xx (except 404/408/429) are permanent — don't keep retrying.
|
|
423
|
+
if (resp.status >= 400 && resp.status < 500
|
|
424
|
+
&& resp.status !== 404 && resp.status !== 408 && resp.status !== 429) {
|
|
425
|
+
console.error(`Warning: Failed to register connection: HTTP ${resp.status}`);
|
|
426
|
+
console.error("Connection is still active — you can retry manually.");
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
lastErr = new Error(`HTTP ${resp.status}`);
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
lastErr = e;
|
|
433
|
+
}
|
|
403
434
|
}
|
|
435
|
+
console.error(`Warning: Failed to register connection after ${maxAttempts} attempts: ${lastErr}`);
|
|
436
|
+
console.error("Connection is still active — you can retry manually.");
|
|
437
|
+
return false;
|
|
404
438
|
}
|
|
405
439
|
/**
|
|
406
440
|
* Best-effort deregister. When `suppressAuthFailures` is true (we're already
|
|
@@ -463,9 +497,21 @@ function processHeartbeatResponse(resp, renderCards) {
|
|
|
463
497
|
// Non-fatal: response parsing failed, silently continue
|
|
464
498
|
});
|
|
465
499
|
}
|
|
466
|
-
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json) {
|
|
500
|
+
function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json, reRegister) {
|
|
467
501
|
let consecutiveFailures = 0;
|
|
468
502
|
let stopped = false;
|
|
503
|
+
// D1 / PC-M2: track repeated 404→re-register cycles. If the backend
|
|
504
|
+
// keeps forgetting the registration between heartbeats (route bug,
|
|
505
|
+
// misconfigured DB, racing supervisor, etc.) we'd loop forever
|
|
506
|
+
// because every successful re-register resets the strike counter.
|
|
507
|
+
// After REREGISTER_CYCLE_BUDGET cycles, log a clear diagnostic and
|
|
508
|
+
// stop the strike countdown — the tunnel itself is still healthy,
|
|
509
|
+
// it just isn't being recognised by the backend's heartbeat path.
|
|
510
|
+
// The user / agent gets a single actionable message instead of a
|
|
511
|
+
// loop spamming "Tunnel registration restored." every 10s.
|
|
512
|
+
let reRegisterCycles = 0;
|
|
513
|
+
let reRegisterDiagnosticPrinted = false;
|
|
514
|
+
const REREGISTER_CYCLE_BUDGET = 6;
|
|
469
515
|
const interval = setInterval(async () => {
|
|
470
516
|
if (stopped)
|
|
471
517
|
return;
|
|
@@ -475,6 +521,34 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
|
|
|
475
521
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
476
522
|
signal: AbortSignal.timeout(10_000),
|
|
477
523
|
});
|
|
524
|
+
// M6 / Pattern G: a 404 here means the backend forgot the
|
|
525
|
+
// tunnel registration (cache miss, restart, or the original
|
|
526
|
+
// POST /connect raced with a backend bounce). Re-register and
|
|
527
|
+
// reset the strike count rather than counting it as a failure
|
|
528
|
+
// — `cfProcess` is still healthy, the *registration* is the
|
|
529
|
+
// soft state that needs repair.
|
|
530
|
+
if (resp.status === 404 && reRegister) {
|
|
531
|
+
reRegisterCycles += 1;
|
|
532
|
+
const ok = await reRegister();
|
|
533
|
+
if (ok) {
|
|
534
|
+
consecutiveFailures = 0;
|
|
535
|
+
// First few cycles are normal (transient backend hiccup).
|
|
536
|
+
// After the budget, the route or registration model is
|
|
537
|
+
// fundamentally broken — keep the tunnel up but say so.
|
|
538
|
+
if (reRegisterCycles >= REREGISTER_CYCLE_BUDGET && !reRegisterDiagnosticPrinted) {
|
|
539
|
+
reRegisterDiagnosticPrinted = true;
|
|
540
|
+
console.error(`Notice: heartbeat path /api/v1/connect/heartbeat is repeatedly 404'ing despite successful re-registration ` +
|
|
541
|
+
`(${reRegisterCycles} cycles). Backend appears to drop the active connection between heartbeats. ` +
|
|
542
|
+
`Tunnel + cloudflared are still healthy; subsequent simulations may still hit TunnelInactive on dispatch. ` +
|
|
543
|
+
`Investigate the backend's /connect route or restart the API service.`);
|
|
544
|
+
}
|
|
545
|
+
else if (!json) {
|
|
546
|
+
console.error("Tunnel registration restored.");
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// Fall through to the strike-count path if re-register fails.
|
|
551
|
+
}
|
|
478
552
|
// If 401 and we can refresh, try once
|
|
479
553
|
if (resp.status === 401 && doRefresh) {
|
|
480
554
|
try {
|
|
@@ -645,13 +719,15 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
645
719
|
}
|
|
646
720
|
await deregisterTunnel(apiUrl, currentToken, json, true);
|
|
647
721
|
cfProcess.kill();
|
|
722
|
+
clearLock();
|
|
648
723
|
process.exit(3);
|
|
649
724
|
};
|
|
650
725
|
heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
|
|
651
726
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
652
727
|
cfProcess.kill();
|
|
728
|
+
clearLock();
|
|
653
729
|
process.exit(1);
|
|
654
|
-
}, onAuthPermanent, json);
|
|
730
|
+
}, onAuthPermanent, json, () => registerTunnel(apiUrl, currentToken, tunnelUrl, port));
|
|
655
731
|
proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
|
|
656
732
|
const shutdown = async () => {
|
|
657
733
|
if (shuttingDown)
|
|
@@ -663,6 +739,7 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
663
739
|
proactiveRefresh?.stop();
|
|
664
740
|
cfProcess.kill();
|
|
665
741
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
742
|
+
clearLock();
|
|
666
743
|
process.exit(0);
|
|
667
744
|
};
|
|
668
745
|
process.on("SIGINT", shutdown);
|
|
@@ -675,7 +752,265 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
675
752
|
heartbeat?.stop();
|
|
676
753
|
proactiveRefresh?.stop();
|
|
677
754
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
755
|
+
clearLock();
|
|
678
756
|
process.exit(0);
|
|
679
757
|
}
|
|
680
758
|
});
|
|
759
|
+
// Detached mode: when this process was spawned by `connect --detach`, the
|
|
760
|
+
// parent needs the registration confirmation we already emitted as a
|
|
761
|
+
// {status:"connected", ...} line on stdout. Write the lock file now so
|
|
762
|
+
// `ish connect status` and `ish disconnect` can find this PID.
|
|
763
|
+
if (process.env.ISH_CONNECT_DETACHED === "1") {
|
|
764
|
+
writeLock({
|
|
765
|
+
pid: process.pid,
|
|
766
|
+
tunnel_url: tunnelUrl,
|
|
767
|
+
local_port: port,
|
|
768
|
+
registered_at: new Date().toISOString(),
|
|
769
|
+
api_url: apiUrl,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function writeLock(lock) {
|
|
774
|
+
mkdirSync(ishDir(), { recursive: true });
|
|
775
|
+
writeFileSync(connectLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
|
776
|
+
}
|
|
777
|
+
function readLock() {
|
|
778
|
+
const path = connectLockPath();
|
|
779
|
+
if (!existsSync(path))
|
|
780
|
+
return null;
|
|
781
|
+
try {
|
|
782
|
+
const raw = readFileSync(path, "utf-8");
|
|
783
|
+
const parsed = JSON.parse(raw);
|
|
784
|
+
if (typeof parsed.pid !== "number")
|
|
785
|
+
return null;
|
|
786
|
+
return parsed;
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function clearLock() {
|
|
793
|
+
const path = connectLockPath();
|
|
794
|
+
if (existsSync(path)) {
|
|
795
|
+
try {
|
|
796
|
+
unlinkSync(path);
|
|
797
|
+
}
|
|
798
|
+
catch { /* best-effort */ }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function pidIsAlive(pid) {
|
|
802
|
+
try {
|
|
803
|
+
process.kill(pid, 0);
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// Detached connect — spawn a child running the same `ish connect <port>`
|
|
812
|
+
// in JSON mode, wait for its first `{status:"connected", ...}` line on
|
|
813
|
+
// stdout, then exit the parent. The child writes the lock file on its
|
|
814
|
+
// own once detached (see ISH_CONNECT_DETACHED branch in runTunnel).
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
export async function runDetached(port, apiUrlArg, tokenArg, tokenFileArg) {
|
|
817
|
+
const existing = readLock();
|
|
818
|
+
if (existing && pidIsAlive(existing.pid)) {
|
|
819
|
+
throw new Error(`An active tunnel is already running (pid ${existing.pid}, ${existing.tunnel_url}). Run \`ish disconnect\` first.`);
|
|
820
|
+
}
|
|
821
|
+
if (existing) {
|
|
822
|
+
// Stale lock — process is gone. Clear before continuing.
|
|
823
|
+
clearLock();
|
|
824
|
+
}
|
|
825
|
+
// Re-invoke the same CLI binary with `--json connect <port>` minus
|
|
826
|
+
// `--detach`, in a detached child process so it survives this parent.
|
|
827
|
+
const exe = process.execPath;
|
|
828
|
+
const script = process.argv[1];
|
|
829
|
+
const childArgs = [];
|
|
830
|
+
// Preserve global flags the user passed at the program root (e.g.
|
|
831
|
+
// --api-url, --token-file). We rebuild minimally instead of slicing
|
|
832
|
+
// process.argv to avoid carrying --detach forward.
|
|
833
|
+
if (apiUrlArg)
|
|
834
|
+
childArgs.push("--api-url", apiUrlArg);
|
|
835
|
+
if (tokenArg)
|
|
836
|
+
childArgs.push("--token", tokenArg);
|
|
837
|
+
if (tokenFileArg)
|
|
838
|
+
childArgs.push("--token-file", tokenFileArg);
|
|
839
|
+
childArgs.push("--json", "connect", String(port));
|
|
840
|
+
const child = spawn(exe, [script, ...childArgs], {
|
|
841
|
+
detached: true,
|
|
842
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
843
|
+
env: { ...process.env, ISH_CONNECT_DETACHED: "1" },
|
|
844
|
+
});
|
|
845
|
+
// Buffer the child's stdout until we see the connected JSON line.
|
|
846
|
+
// M6 / Pattern G: hold for `registered: true` specifically — a
|
|
847
|
+
// `registered: false` connected line means the cloudflared tunnel is
|
|
848
|
+
// up but the backend never accepted the registration POST. Returning
|
|
849
|
+
// before registration confirms used to leave the parent's reported
|
|
850
|
+
// `tunnel_url` orphaned (subsequent API calls would 404 against it),
|
|
851
|
+
// and the child's heartbeat would burn through the 3-strike countdown
|
|
852
|
+
// and exit before the agent even tried to use the URL. The child's
|
|
853
|
+
// `registerTunnel` already retries with backoff, so by the time it
|
|
854
|
+
// emits `registered: false` the situation is permanent on its end —
|
|
855
|
+
// surface it as a startup error rather than a half-success.
|
|
856
|
+
const connected = await new Promise((resolve, reject) => {
|
|
857
|
+
let buffered = "";
|
|
858
|
+
const onData = (chunk) => {
|
|
859
|
+
buffered += chunk.toString("utf-8");
|
|
860
|
+
let nl;
|
|
861
|
+
while ((nl = buffered.indexOf("\n")) !== -1) {
|
|
862
|
+
const line = buffered.slice(0, nl).trim();
|
|
863
|
+
buffered = buffered.slice(nl + 1);
|
|
864
|
+
if (!line)
|
|
865
|
+
continue;
|
|
866
|
+
try {
|
|
867
|
+
const parsed = JSON.parse(line);
|
|
868
|
+
if (parsed.status === "connected" && typeof parsed.tunnel_url === "string") {
|
|
869
|
+
if (!parsed.registered) {
|
|
870
|
+
child.stdout?.off("data", onData);
|
|
871
|
+
child.off("exit", onExit);
|
|
872
|
+
reject(new Error(`Tunnel started (${parsed.tunnel_url}) but backend registration failed. ` +
|
|
873
|
+
"Check API connectivity and re-run; the orphaned cloudflared process has been terminated."));
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
child.stdout?.off("data", onData);
|
|
877
|
+
child.off("exit", onExit);
|
|
878
|
+
resolve({
|
|
879
|
+
tunnel_url: parsed.tunnel_url,
|
|
880
|
+
registered: Boolean(parsed.registered),
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
// Non-JSON line — ignore (older builds may emit progress).
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
const onExit = (code) => {
|
|
891
|
+
reject(new Error(`connect child exited before first heartbeat (code ${code ?? "?"}).`));
|
|
892
|
+
};
|
|
893
|
+
child.stdout?.on("data", onData);
|
|
894
|
+
child.on("exit", onExit);
|
|
895
|
+
// Surface child stderr so a connection failure isn't silent.
|
|
896
|
+
child.stderr?.on("data", (chunk) => {
|
|
897
|
+
process.stderr.write(chunk);
|
|
898
|
+
});
|
|
899
|
+
// Hard ceiling — cloudflared startup timeout is 30s; give the
|
|
900
|
+
// backend register call another 15s on top.
|
|
901
|
+
setTimeout(() => {
|
|
902
|
+
child.stdout?.off("data", onData);
|
|
903
|
+
child.off("exit", onExit);
|
|
904
|
+
reject(new Error("Timed out waiting for tunnel to come up."));
|
|
905
|
+
}, CLOUDFLARED_STARTUP_TIMEOUT + 15_000);
|
|
906
|
+
}).catch((err) => {
|
|
907
|
+
try {
|
|
908
|
+
child.kill();
|
|
909
|
+
}
|
|
910
|
+
catch { /* best-effort */ }
|
|
911
|
+
throw err;
|
|
912
|
+
});
|
|
913
|
+
// Detach the child from this parent so it survives. We deliberately
|
|
914
|
+
// don't unref stdio — the child's own SIGTERM handler does graceful
|
|
915
|
+
// shutdown via deregister.
|
|
916
|
+
child.unref();
|
|
917
|
+
console.log(JSON.stringify({
|
|
918
|
+
pid: child.pid,
|
|
919
|
+
tunnel_url: connected.tunnel_url,
|
|
920
|
+
registered: connected.registered,
|
|
921
|
+
}));
|
|
922
|
+
}
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
// Status — read-only check of the active tunnel.
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
export function connectStatus(json) {
|
|
927
|
+
const lock = readLock();
|
|
928
|
+
if (!lock || !pidIsAlive(lock.pid)) {
|
|
929
|
+
if (lock && !pidIsAlive(lock.pid))
|
|
930
|
+
clearLock();
|
|
931
|
+
if (json) {
|
|
932
|
+
console.log(JSON.stringify({ active: false }));
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
console.log("No active tunnel.");
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (json) {
|
|
940
|
+
console.log(JSON.stringify({
|
|
941
|
+
active: true,
|
|
942
|
+
pid: lock.pid,
|
|
943
|
+
tunnel_url: lock.tunnel_url,
|
|
944
|
+
local_port: lock.local_port,
|
|
945
|
+
registered_at: lock.registered_at,
|
|
946
|
+
api_url: lock.api_url,
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
console.log(`Active tunnel: ${lock.tunnel_url} → http://localhost:${lock.local_port}`);
|
|
951
|
+
console.log(` pid: ${lock.pid}`);
|
|
952
|
+
console.log(` registered_at: ${lock.registered_at}`);
|
|
953
|
+
console.log(` api: ${lock.api_url}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
// Disconnect — SIGTERM the tracked PID; the child's own handler does the
|
|
958
|
+
// graceful deregister + cloudflared cleanup. We poll for exit, then clear
|
|
959
|
+
// the lock if the child didn't already.
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
export async function disconnect(json) {
|
|
962
|
+
const lock = readLock();
|
|
963
|
+
if (!lock) {
|
|
964
|
+
if (json) {
|
|
965
|
+
console.log(JSON.stringify({ status: "no_active_tunnel" }));
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
console.log("No active tunnel.");
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (!pidIsAlive(lock.pid)) {
|
|
973
|
+
clearLock();
|
|
974
|
+
if (json) {
|
|
975
|
+
console.log(JSON.stringify({ status: "stale_lock_cleared", pid: lock.pid }));
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
console.log(`Stale lock cleared (pid ${lock.pid} was not running).`);
|
|
979
|
+
}
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
process.kill(lock.pid, "SIGTERM");
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
throw new Error(`Failed to signal pid ${lock.pid}: ${err instanceof Error ? err.message : err}`);
|
|
987
|
+
}
|
|
988
|
+
// Poll for graceful exit. The child's own shutdown handler does the
|
|
989
|
+
// backend deregister; once it exits, the lock file is the next thing
|
|
990
|
+
// we clear.
|
|
991
|
+
const deadline = Date.now() + 5_000;
|
|
992
|
+
while (Date.now() < deadline) {
|
|
993
|
+
if (!pidIsAlive(lock.pid))
|
|
994
|
+
break;
|
|
995
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
996
|
+
}
|
|
997
|
+
if (pidIsAlive(lock.pid)) {
|
|
998
|
+
// Hard fallback: if SIGTERM didn't take, escalate to SIGKILL so the
|
|
999
|
+
// user isn't left with an orphan tunnel.
|
|
1000
|
+
try {
|
|
1001
|
+
process.kill(lock.pid, "SIGKILL");
|
|
1002
|
+
}
|
|
1003
|
+
catch { /* best-effort */ }
|
|
1004
|
+
}
|
|
1005
|
+
clearLock();
|
|
1006
|
+
if (json) {
|
|
1007
|
+
console.log(JSON.stringify({
|
|
1008
|
+
status: "disconnected",
|
|
1009
|
+
pid: lock.pid,
|
|
1010
|
+
tunnel_url: lock.tunnel_url,
|
|
1011
|
+
}));
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
console.log(`Disconnected (pid ${lock.pid}).`);
|
|
1015
|
+
}
|
|
681
1016
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { program, Option } from "commander";
|
|
3
|
-
import { runTunnel } from "./connect.js";
|
|
4
|
-
import { login,
|
|
3
|
+
import { runTunnel, runDetached, connectStatus, disconnect } from "./connect.js";
|
|
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";
|
|
@@ -11,8 +11,10 @@ import { registerProfileCommands } from "./commands/profile.js";
|
|
|
11
11
|
import { registerSourceCommands } from "./commands/source.js";
|
|
12
12
|
import { registerConfigCommands } from "./commands/config.js";
|
|
13
13
|
import { registerAskCommands } from "./commands/ask.js";
|
|
14
|
+
import { registerChatCommand } from "./commands/chat.js";
|
|
14
15
|
import { registerDocsCommands } from "./commands/docs.js";
|
|
15
16
|
import { registerInitCommands } from "./commands/init.js";
|
|
17
|
+
import { registerSecretCommands } from "./commands/secret.js";
|
|
16
18
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
17
19
|
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
18
20
|
import { resolveApiUrl, resolveToken } from "./lib/auth.js";
|
|
@@ -80,14 +82,14 @@ program
|
|
|
80
82
|
// --- Inline commands (from upstream) ---
|
|
81
83
|
program
|
|
82
84
|
.command("login")
|
|
83
|
-
.description("Authenticate with
|
|
85
|
+
.description("Authenticate with ish via your browser")
|
|
84
86
|
.action(async (_opts, cmd) => {
|
|
85
87
|
await runInline(cmd, async (globals) => {
|
|
86
|
-
const
|
|
87
|
-
const tokens = await login(appUrl);
|
|
88
|
+
const tokens = await login();
|
|
88
89
|
const config = loadConfig();
|
|
89
90
|
config.access_token = tokens.accessToken;
|
|
90
91
|
config.refresh_token = tokens.refreshToken;
|
|
92
|
+
config.oauth_client_id = tokens.clientId;
|
|
91
93
|
saveConfig(config);
|
|
92
94
|
output({ message: "Login successful" }, globals.json);
|
|
93
95
|
});
|
|
@@ -217,24 +219,58 @@ program
|
|
|
217
219
|
console.error(`\nHint: ${payload.hint}`);
|
|
218
220
|
});
|
|
219
221
|
});
|
|
220
|
-
program
|
|
222
|
+
const connectCmd = program
|
|
221
223
|
.command("connect")
|
|
222
224
|
.description("Expose your localhost to Ish via a Cloudflare tunnel")
|
|
223
|
-
.argument("
|
|
224
|
-
.
|
|
225
|
-
.
|
|
225
|
+
.argument("[port]", "Local port to connect (e.g. 3000)")
|
|
226
|
+
.option("--detach", "Fork after first heartbeat success; print {pid, tunnel_url, registered} JSON and exit")
|
|
227
|
+
.addHelpText("after", `
|
|
228
|
+
Subcommands:
|
|
229
|
+
ish connect status Read-only check of the active detached tunnel
|
|
230
|
+
ish disconnect Graceful shutdown of the active detached tunnel
|
|
231
|
+
|
|
232
|
+
Note: --json emits structured one-line JSON for connected/disconnected events.
|
|
233
|
+
--fields and --quiet have limited effect; use --json for machine-readable output.
|
|
234
|
+
|
|
235
|
+
Detach mode tracks the active tunnel in ~/.ish/connect.lock so \`ish connect
|
|
236
|
+
status\` and \`ish disconnect\` can find it. Foreground (non-detached)
|
|
237
|
+
connects do not write a lock — Ctrl+C is the shutdown verb in that mode.`)
|
|
238
|
+
.action(async (port, opts, cmd) => {
|
|
226
239
|
await runInline(cmd, async (globals) => {
|
|
240
|
+
if (!port) {
|
|
241
|
+
throw new Error("Pass a port: `ish connect <port>` (e.g. `ish connect 3000`).");
|
|
242
|
+
}
|
|
227
243
|
const portNum = parseInt(port, 10);
|
|
228
244
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
229
245
|
throw new Error(`Invalid port: ${port}`);
|
|
230
246
|
}
|
|
231
247
|
const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
|
|
248
|
+
if (opts.detach) {
|
|
249
|
+
await runDetached(portNum, apiUrl, globals.token, globals.tokenFile);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
232
252
|
await runTunnel(portNum, globals.token, apiUrl, globals.tokenFile, {
|
|
233
253
|
json: globals.json,
|
|
234
254
|
quiet: globals.quiet,
|
|
235
255
|
});
|
|
236
256
|
});
|
|
237
257
|
});
|
|
258
|
+
connectCmd
|
|
259
|
+
.command("status")
|
|
260
|
+
.description("Show the active detached tunnel (or {active:false} when none)")
|
|
261
|
+
.action(async (_opts, cmd) => {
|
|
262
|
+
await runInline(cmd, async (globals) => {
|
|
263
|
+
connectStatus(globals.json);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
program
|
|
267
|
+
.command("disconnect")
|
|
268
|
+
.description("Stop the active detached tunnel (graceful SIGTERM + backend deregister)")
|
|
269
|
+
.action(async (_opts, cmd) => {
|
|
270
|
+
await runInline(cmd, async (globals) => {
|
|
271
|
+
await disconnect(globals.json);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
238
274
|
// --- Modular command groups ---
|
|
239
275
|
registerWorkspaceCommands(program);
|
|
240
276
|
registerStudyCommands(program);
|
|
@@ -243,8 +279,10 @@ registerProfileCommands(program);
|
|
|
243
279
|
registerSourceCommands(program);
|
|
244
280
|
registerConfigCommands(program);
|
|
245
281
|
registerAskCommands(program);
|
|
282
|
+
registerChatCommand(program);
|
|
246
283
|
registerDocsCommands(program);
|
|
247
284
|
registerInitCommands(program);
|
|
285
|
+
registerSecretCommands(program);
|
|
248
286
|
program
|
|
249
287
|
.command("upgrade")
|
|
250
288
|
.description("Update ish to the latest version")
|
|
@@ -254,4 +292,24 @@ program
|
|
|
254
292
|
await upgrade(version, options.release);
|
|
255
293
|
});
|
|
256
294
|
injectGlobalWorkspaceOption(program);
|
|
295
|
+
// L3 / Pattern J: surface --get and --fields on every verb's --help.
|
|
296
|
+
// They're declared at the program root, so the per-verb help would
|
|
297
|
+
// otherwise omit them — agents reach for `jq` instead of discovering the
|
|
298
|
+
// in-CLI projection flags. This walks the command tree once at startup
|
|
299
|
+
// and adds a one-line "Tips:" footer to every leaf verb that doesn't
|
|
300
|
+
// already mention `--get`.
|
|
301
|
+
function injectAgentTipsFooter(cmd) {
|
|
302
|
+
for (const sub of cmd.commands) {
|
|
303
|
+
injectAgentTipsFooter(sub);
|
|
304
|
+
// Skip groups (commands that themselves carry subcommands) — their own
|
|
305
|
+
// verbs render the footer; the group help would duplicate it.
|
|
306
|
+
if (sub.commands.length > 0)
|
|
307
|
+
continue;
|
|
308
|
+
const existing = sub.helpInformation();
|
|
309
|
+
if (existing.includes("--get <path>") || existing.includes("--get <field>"))
|
|
310
|
+
continue;
|
|
311
|
+
sub.addHelpText("after", `\nTips:\n Use \`--get <path>\` to capture a single value (e.g. \`--get id\`),\n \`--fields a,b,c\` to project the JSON output to listed fields.\n See \`ish docs get-page reference/json-mode\` for the full output rules.`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
injectAgentTipsFooter(program);
|
|
257
315
|
program.parse();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort alias-cache hydration on alias-miss (Pattern F).
|
|
3
|
+
*
|
|
4
|
+
* The CLI persists aliases to ``~/.ish/aliases.json``, so the cache survives
|
|
5
|
+
* across processes. Where it bites: the file is missing/empty (fresh install,
|
|
6
|
+
* `rm ~/.ish/aliases.json`, agent running in a sandbox with a fresh
|
|
7
|
+
* `ISH_HOME`) and the agent has an alias from a prior process or the docs.
|
|
8
|
+
*
|
|
9
|
+
* ``resolveIdAsync(input, client, hints?)`` mirrors the sync ``resolveId``
|
|
10
|
+
* contract but, on alias-miss, attempts a single ``GET /list`` to repopulate
|
|
11
|
+
* the cache before retrying. The hydrate is BEST-EFFORT: a failing list
|
|
12
|
+
* call is swallowed and the canonical "Unknown alias" error fires with the
|
|
13
|
+
* actionable suggestion in the message.
|
|
14
|
+
*
|
|
15
|
+
* For prefixes whose list endpoint requires a parent ID (study/iteration/
|
|
16
|
+
* ask/etc), the caller passes ``hints`` carrying the parent (workspaceId,
|
|
17
|
+
* studyId). Without a parent we skip the hydrate — global N+1 fan-out is
|
|
18
|
+
* too expensive for a papercut.
|
|
19
|
+
*/
|
|
20
|
+
import { ApiClient } from "./api-client.js";
|
|
21
|
+
export interface HydrateHints {
|
|
22
|
+
workspaceId?: string;
|
|
23
|
+
studyId?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Best-effort hydrate of the alias cache for ``alias``'s entity type. Returns
|
|
27
|
+
* silently on success or any failure — the caller checks the cache after
|
|
28
|
+
* this returns.
|
|
29
|
+
*/
|
|
30
|
+
export declare function hydrateForAlias(client: ApiClient, alias: string, hints?: HydrateHints): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Async sibling of ``resolveId``: same UUID-or-alias contract, but on
|
|
33
|
+
* alias-miss attempts a best-effort hydrate via the matching list endpoint
|
|
34
|
+
* before retrying. Falls back to the canonical "Unknown alias" error
|
|
35
|
+
* (with named list-command suggestion) when the alias is still missing
|
|
36
|
+
* after the hydrate.
|
|
37
|
+
*
|
|
38
|
+
* Use this at command-handler entry points where the agent supplies an
|
|
39
|
+
* alias and the same call carries enough context (workspace / study) to
|
|
40
|
+
* scope the hydrate cheaply.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveIdAsync(input: string, client: ApiClient, hints?: HydrateHints): Promise<string>;
|