@ishlabs/cli 0.23.1 → 0.24.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/dist/commands/ask.js +4 -4
- package/dist/commands/iteration.js +25 -3
- package/dist/commands/study-share.d.ts +18 -0
- package/dist/commands/study-share.js +117 -0
- package/dist/commands/study.js +54 -7
- package/dist/commands/workspace.js +4 -1
- package/dist/connect.d.ts +4 -2
- package/dist/connect.js +151 -11
- package/dist/index.js +63 -6
- package/dist/lib/ask-questions.d.ts +15 -5
- package/dist/lib/ask-questions.js +34 -11
- package/dist/lib/auth.d.ts +1 -0
- package/dist/lib/auth.js +7 -1
- package/dist/lib/command-helpers.js +33 -5
- package/dist/lib/docs.js +140 -8
- package/dist/lib/output.js +8 -1
- package/dist/lib/reverse-proxy.d.ts +19 -0
- package/dist/lib/reverse-proxy.js +87 -0
- package/dist/lib/reverse-proxy.test.d.ts +10 -0
- package/dist/lib/reverse-proxy.test.js +149 -0
- package/dist/lib/segmentation.d.ts +31 -0
- package/dist/lib/segmentation.js +105 -0
- package/dist/lib/skill-content.js +76 -4
- package/dist/lib/types.d.ts +2 -0
- package/package.json +3 -1
package/dist/connect.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
import { spawn, execSync } from "node:child_process";
|
|
5
5
|
import { existsSync, mkdirSync, chmodSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import net from "node:net";
|
|
7
8
|
import { loadConfig, saveConfig } from "./config.js";
|
|
8
9
|
import { refreshTokens, isTokenExpired, decodeJwtExp, AuthRefreshPermanentError } from "./auth.js";
|
|
9
10
|
import { binDir, cloudflaredBin, simulationsDir, connectLockPath, ishDir } from "./lib/paths.js";
|
|
10
11
|
import { c } from "./lib/colors.js";
|
|
12
|
+
import { startReverseProxy } from "./lib/reverse-proxy.js";
|
|
11
13
|
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
12
14
|
const HEARTBEAT_INTERVAL = 10_000;
|
|
13
15
|
const MAX_HEARTBEAT_FAILURES = 3;
|
|
@@ -641,8 +643,63 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, onAuthPerm
|
|
|
641
643
|
}, delay);
|
|
642
644
|
return { stop: () => clearTimeout(timer) };
|
|
643
645
|
}
|
|
646
|
+
// 500ms TCP probe. We label the primary service "frontend" by convention
|
|
647
|
+
// (most devs run a UI on the primary port and APIs behind --proxy /api=…)
|
|
648
|
+
// but it's purely cosmetic — the proxy itself routes by path, not label.
|
|
649
|
+
async function probeService(port, timeoutMs = 500) {
|
|
650
|
+
return new Promise((resolve) => {
|
|
651
|
+
const socket = new net.Socket();
|
|
652
|
+
let settled = false;
|
|
653
|
+
const finish = (ok) => {
|
|
654
|
+
if (settled)
|
|
655
|
+
return;
|
|
656
|
+
settled = true;
|
|
657
|
+
socket.destroy();
|
|
658
|
+
resolve(ok);
|
|
659
|
+
};
|
|
660
|
+
socket.setTimeout(timeoutMs);
|
|
661
|
+
socket.once("connect", () => finish(true));
|
|
662
|
+
socket.once("timeout", () => finish(false));
|
|
663
|
+
socket.once("error", () => finish(false));
|
|
664
|
+
socket.connect(port, "127.0.0.1");
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
async function probeServices(primaryPort, routes) {
|
|
668
|
+
const targets = [
|
|
669
|
+
{ label: "frontend", port: primaryPort, reachable: false },
|
|
670
|
+
];
|
|
671
|
+
for (const route of routes) {
|
|
672
|
+
// Pull the port back out of `http://host:port` for the probe; if the user
|
|
673
|
+
// pointed a route at a non-localhost target we skip the probe (we can't
|
|
674
|
+
// assume what's reachable from here) — they'll find out on first request.
|
|
675
|
+
try {
|
|
676
|
+
const url = new URL(route.target);
|
|
677
|
+
const port = parseInt(url.port, 10);
|
|
678
|
+
if (!isNaN(port)) {
|
|
679
|
+
targets.push({ label: route.prefix, port, reachable: false });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
// Malformed target shouldn't happen — collectProxy validates upstream.
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
await Promise.all(targets.map(async (t) => {
|
|
687
|
+
t.reachable = await probeService(t.port);
|
|
688
|
+
}));
|
|
689
|
+
return targets;
|
|
690
|
+
}
|
|
691
|
+
function printProbeResults(probes) {
|
|
692
|
+
for (const p of probes) {
|
|
693
|
+
if (p.reachable) {
|
|
694
|
+
console.error(`${c.green}✓${c.reset} ${p.label} ready on :${p.port}`);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
console.error(`${c.dim}·${c.reset} ${p.label} not responding on :${p.port} (will retry on first request)`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
644
701
|
// --- Main ---
|
|
645
|
-
export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputOpts = {}) {
|
|
702
|
+
export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputOpts = {}, routes = []) {
|
|
646
703
|
const json = outputOpts.json ?? false;
|
|
647
704
|
const quiet = outputOpts.quiet ?? false;
|
|
648
705
|
const apiUrl = resolveApiUrl(apiUrlArg);
|
|
@@ -665,28 +722,68 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
665
722
|
}
|
|
666
723
|
: null;
|
|
667
724
|
const cloudflaredPath = await resolveCloudflaredBin();
|
|
725
|
+
// Multi-service mode: spin up a local reverse proxy on an OS-assigned port
|
|
726
|
+
// and tunnel THAT port. Bare `ish connect <port>` (no --proxy) keeps the
|
|
727
|
+
// legacy single-port behaviour — no proxy, no health checks.
|
|
728
|
+
let reverseProxy = null;
|
|
729
|
+
let tunneledPort = port;
|
|
730
|
+
if (routes.length > 0) {
|
|
731
|
+
if (!json && !quiet) {
|
|
732
|
+
const probes = await probeServices(port, routes);
|
|
733
|
+
printProbeResults(probes);
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
reverseProxy = await startReverseProxy({ primaryPort: port, routes });
|
|
737
|
+
tunneledPort = reverseProxy.port;
|
|
738
|
+
}
|
|
739
|
+
catch (e) {
|
|
740
|
+
throw new Error(`Failed to start reverse proxy: ${e instanceof Error ? e.message : e}`);
|
|
741
|
+
}
|
|
742
|
+
if (!json && !quiet) {
|
|
743
|
+
const summary = routes.map((r) => `${r.prefix} → ${r.target}`).join(", ");
|
|
744
|
+
console.error(`Reverse proxy ready on :${tunneledPort} (default → :${port}, ${summary})`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
668
747
|
let cfResult;
|
|
669
748
|
try {
|
|
670
|
-
cfResult = await startCloudflared(
|
|
749
|
+
cfResult = await startCloudflared(tunneledPort, cloudflaredPath, json);
|
|
671
750
|
}
|
|
672
751
|
catch (e) {
|
|
752
|
+
if (reverseProxy)
|
|
753
|
+
await reverseProxy.close().catch(() => { });
|
|
673
754
|
throw new Error(`Failed to start cloudflared: ${e instanceof Error ? e.message : e}`);
|
|
674
755
|
}
|
|
675
756
|
const { process: cfProcess, tunnelUrl } = cfResult;
|
|
676
|
-
const registered = await registerTunnel(apiUrl, currentToken, tunnelUrl,
|
|
757
|
+
const registered = await registerTunnel(apiUrl, currentToken, tunnelUrl, tunneledPort);
|
|
677
758
|
// Announce the tunnel URL — this is the load-bearing output of `ish connect`.
|
|
678
759
|
if (json) {
|
|
679
|
-
|
|
760
|
+
const connectedPayload = {
|
|
680
761
|
status: "connected",
|
|
681
762
|
tunnel_url: tunnelUrl,
|
|
763
|
+
// `local_port` keeps reporting the user-supplied primary port so
|
|
764
|
+
// single-port consumers don't see the proxy's ephemeral port slip in.
|
|
682
765
|
local_port: port,
|
|
683
766
|
registered,
|
|
684
|
-
}
|
|
767
|
+
};
|
|
768
|
+
if (routes.length > 0) {
|
|
769
|
+
connectedPayload.proxy_port = tunneledPort;
|
|
770
|
+
connectedPayload.routes = routes;
|
|
771
|
+
}
|
|
772
|
+
console.log(JSON.stringify(connectedPayload));
|
|
685
773
|
}
|
|
686
774
|
else {
|
|
687
775
|
if (!quiet)
|
|
688
776
|
printBanner();
|
|
689
777
|
console.log(`Tunnel URL: ${tunnelUrl} → http://localhost:${port}\n`);
|
|
778
|
+
if (routes.length > 0) {
|
|
779
|
+
console.log("Routes:");
|
|
780
|
+
console.log(` / → localhost:${port}`);
|
|
781
|
+
for (const r of routes) {
|
|
782
|
+
const padded = r.prefix.padEnd(10, " ");
|
|
783
|
+
console.log(` ${padded} → ${r.target.replace(/^http:\/\//, "")}`);
|
|
784
|
+
}
|
|
785
|
+
console.log("");
|
|
786
|
+
}
|
|
690
787
|
}
|
|
691
788
|
let shuttingDown = false;
|
|
692
789
|
// Holders for forward references — `onAuthPermanent` needs to stop these
|
|
@@ -720,16 +817,20 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
720
817
|
console.error(`Cause: ${cause.message}`);
|
|
721
818
|
}
|
|
722
819
|
await deregisterTunnel(apiUrl, currentToken, json, true);
|
|
820
|
+
if (reverseProxy)
|
|
821
|
+
await reverseProxy.close().catch(() => { });
|
|
723
822
|
cfProcess.kill();
|
|
724
823
|
clearLock();
|
|
725
824
|
process.exit(3);
|
|
726
825
|
};
|
|
727
826
|
heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
|
|
728
827
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
828
|
+
if (reverseProxy)
|
|
829
|
+
await reverseProxy.close().catch(() => { });
|
|
729
830
|
cfProcess.kill();
|
|
730
831
|
clearLock();
|
|
731
832
|
process.exit(1);
|
|
732
|
-
}, onAuthPermanent, json, () => registerTunnel(apiUrl, currentToken, tunnelUrl,
|
|
833
|
+
}, onAuthPermanent, json, () => registerTunnel(apiUrl, currentToken, tunnelUrl, tunneledPort));
|
|
733
834
|
proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
|
|
734
835
|
const shutdown = async () => {
|
|
735
836
|
if (shuttingDown)
|
|
@@ -739,6 +840,10 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
739
840
|
console.error("\nShutting down...");
|
|
740
841
|
heartbeat?.stop();
|
|
741
842
|
proactiveRefresh?.stop();
|
|
843
|
+
// Close the reverse proxy BEFORE killing cloudflared: stop accepting new
|
|
844
|
+
// inbound requests at the local hop, then tear down the tunnel.
|
|
845
|
+
if (reverseProxy)
|
|
846
|
+
await reverseProxy.close().catch(() => { });
|
|
742
847
|
cfProcess.kill();
|
|
743
848
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
744
849
|
clearLock();
|
|
@@ -753,6 +858,8 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
753
858
|
if (!shuttingDown) {
|
|
754
859
|
heartbeat?.stop();
|
|
755
860
|
proactiveRefresh?.stop();
|
|
861
|
+
if (reverseProxy)
|
|
862
|
+
await reverseProxy.close().catch(() => { });
|
|
756
863
|
await deregisterTunnel(apiUrl, currentToken, json);
|
|
757
864
|
clearLock();
|
|
758
865
|
process.exit(0);
|
|
@@ -766,9 +873,13 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
|
|
|
766
873
|
writeLock({
|
|
767
874
|
pid: process.pid,
|
|
768
875
|
tunnel_url: tunnelUrl,
|
|
876
|
+
// `local_port` is what the user typed (the primary). When multi-service
|
|
877
|
+
// is active, `proxy_port` records the OS-assigned port we actually
|
|
878
|
+
// tunneled, alongside the route table.
|
|
769
879
|
local_port: port,
|
|
770
880
|
registered_at: new Date().toISOString(),
|
|
771
881
|
api_url: apiUrl,
|
|
882
|
+
...(routes.length > 0 ? { proxy_port: tunneledPort, routes } : {}),
|
|
772
883
|
});
|
|
773
884
|
}
|
|
774
885
|
}
|
|
@@ -785,6 +896,8 @@ function readLock() {
|
|
|
785
896
|
const parsed = JSON.parse(raw);
|
|
786
897
|
if (typeof parsed.pid !== "number")
|
|
787
898
|
return null;
|
|
899
|
+
if (!Array.isArray(parsed.routes))
|
|
900
|
+
parsed.routes = [];
|
|
788
901
|
return parsed;
|
|
789
902
|
}
|
|
790
903
|
catch {
|
|
@@ -815,7 +928,7 @@ function pidIsAlive(pid) {
|
|
|
815
928
|
// stdout, then exit the parent. The child writes the lock file on its
|
|
816
929
|
// own once detached (see ISH_CONNECT_DETACHED branch in runTunnel).
|
|
817
930
|
// ---------------------------------------------------------------------------
|
|
818
|
-
export async function runDetached(port, apiUrlArg, tokenArg, tokenFileArg) {
|
|
931
|
+
export async function runDetached(port, apiUrlArg, tokenArg, tokenFileArg, routes = []) {
|
|
819
932
|
const existing = readLock();
|
|
820
933
|
if (existing && pidIsAlive(existing.pid)) {
|
|
821
934
|
throw new Error(`An active tunnel is already running (pid ${existing.pid}, ${existing.tunnel_url}). Run \`ish disconnect\` first.`);
|
|
@@ -839,6 +952,14 @@ export async function runDetached(port, apiUrlArg, tokenArg, tokenFileArg) {
|
|
|
839
952
|
if (tokenFileArg)
|
|
840
953
|
childArgs.push("--token-file", tokenFileArg);
|
|
841
954
|
childArgs.push("--json", "connect", String(port));
|
|
955
|
+
// Re-serialise routes into `--proxy /prefix=host:port` form for the child.
|
|
956
|
+
// Targets coming through here are always `http://host:port` (collectProxy
|
|
957
|
+
// shape-validates and parseProxyValues builds the URL), so stripping the
|
|
958
|
+
// scheme back off round-trips cleanly.
|
|
959
|
+
for (const route of routes) {
|
|
960
|
+
const stripped = route.target.replace(/^https?:\/\//, "");
|
|
961
|
+
childArgs.push("--proxy", `${route.prefix}=${stripped}`);
|
|
962
|
+
}
|
|
842
963
|
const child = spawn(exe, [script, ...childArgs], {
|
|
843
964
|
detached: true,
|
|
844
965
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -916,11 +1037,14 @@ export async function runDetached(port, apiUrlArg, tokenArg, tokenFileArg) {
|
|
|
916
1037
|
// don't unref stdio — the child's own SIGTERM handler does graceful
|
|
917
1038
|
// shutdown via deregister.
|
|
918
1039
|
child.unref();
|
|
919
|
-
|
|
1040
|
+
const detachOutput = {
|
|
920
1041
|
pid: child.pid,
|
|
921
1042
|
tunnel_url: connected.tunnel_url,
|
|
922
1043
|
registered: connected.registered,
|
|
923
|
-
}
|
|
1044
|
+
};
|
|
1045
|
+
if (routes.length > 0)
|
|
1046
|
+
detachOutput.routes = routes;
|
|
1047
|
+
console.log(JSON.stringify(detachOutput));
|
|
924
1048
|
}
|
|
925
1049
|
// ---------------------------------------------------------------------------
|
|
926
1050
|
// Status — read-only check of the active tunnel.
|
|
@@ -938,21 +1062,37 @@ export function connectStatus(json) {
|
|
|
938
1062
|
}
|
|
939
1063
|
return;
|
|
940
1064
|
}
|
|
1065
|
+
const lockRoutes = lock.routes ?? [];
|
|
941
1066
|
if (json) {
|
|
942
|
-
|
|
1067
|
+
const payload = {
|
|
943
1068
|
active: true,
|
|
944
1069
|
pid: lock.pid,
|
|
945
1070
|
tunnel_url: lock.tunnel_url,
|
|
946
1071
|
local_port: lock.local_port,
|
|
947
1072
|
registered_at: lock.registered_at,
|
|
948
1073
|
api_url: lock.api_url,
|
|
949
|
-
}
|
|
1074
|
+
};
|
|
1075
|
+
if (lockRoutes.length > 0) {
|
|
1076
|
+
payload.proxy_port = lock.proxy_port;
|
|
1077
|
+
payload.routes = lockRoutes;
|
|
1078
|
+
}
|
|
1079
|
+
console.log(JSON.stringify(payload));
|
|
950
1080
|
}
|
|
951
1081
|
else {
|
|
952
1082
|
console.log(`Active tunnel: ${lock.tunnel_url} → http://localhost:${lock.local_port}`);
|
|
953
1083
|
console.log(` pid: ${lock.pid}`);
|
|
954
1084
|
console.log(` registered_at: ${lock.registered_at}`);
|
|
955
1085
|
console.log(` api: ${lock.api_url}`);
|
|
1086
|
+
if (lockRoutes.length > 0) {
|
|
1087
|
+
if (lock.proxy_port)
|
|
1088
|
+
console.log(` proxy_port: ${lock.proxy_port}`);
|
|
1089
|
+
console.log("Routes:");
|
|
1090
|
+
console.log(` / → localhost:${lock.local_port}`);
|
|
1091
|
+
for (const r of lockRoutes) {
|
|
1092
|
+
const padded = r.prefix.padEnd(10, " ");
|
|
1093
|
+
console.log(` ${padded} → ${r.target.replace(/^http:\/\//, "")}`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
956
1096
|
}
|
|
957
1097
|
}
|
|
958
1098
|
// ---------------------------------------------------------------------------
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { program, Option } from "commander";
|
|
2
|
+
import { program, Option, InvalidArgumentError } from "commander";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as os from "node:os";
|
|
@@ -21,7 +21,7 @@ import { registerMcpCommands } from "./commands/mcp.js";
|
|
|
21
21
|
import { registerSecretCommands } from "./commands/secret.js";
|
|
22
22
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
23
23
|
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
24
|
-
import { resolveApiUrl, resolveToken } from "./lib/auth.js";
|
|
24
|
+
import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
|
|
25
25
|
import { ApiClient } from "./lib/api-client.js";
|
|
26
26
|
import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
|
|
27
27
|
import { output } from "./lib/output.js";
|
|
@@ -155,7 +155,18 @@ Examples:
|
|
|
155
155
|
if (!opts.force) {
|
|
156
156
|
const existing = loadConfig();
|
|
157
157
|
const existingToken = existing.access_token;
|
|
158
|
-
|
|
158
|
+
// Local expiry alone isn't enough to claim "Already logged in": a token
|
|
159
|
+
// can be unexpired yet rejected by the API — a revoked session, a rotated
|
|
160
|
+
// signing key, or the classic footgun of a token minted against the dev
|
|
161
|
+
// Supabase project while we're calling the prod api. Confirm the server
|
|
162
|
+
// actually accepts it before short-circuiting; otherwise fall through to
|
|
163
|
+
// the browser flow and re-auth. verifyToken() returns true on a network
|
|
164
|
+
// blip (best-effort probe), so an offline user with an otherwise-good
|
|
165
|
+
// token still short-circuits rather than being forced online.
|
|
166
|
+
const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
|
|
167
|
+
if (existingToken &&
|
|
168
|
+
!isTokenExpired(existingToken) &&
|
|
169
|
+
(await verifyToken(existingToken, apiUrl))) {
|
|
159
170
|
const claims = decodeJwtClaims(existingToken);
|
|
160
171
|
const email = typeof claims?.email === "string" ? claims.email : "(no email in token)";
|
|
161
172
|
const exp = typeof claims?.exp === "number" ? claims.exp : 0;
|
|
@@ -388,11 +399,45 @@ program
|
|
|
388
399
|
}
|
|
389
400
|
});
|
|
390
401
|
});
|
|
402
|
+
// Validator for --proxy values. Accepts /prefix=port or /prefix=host:port; we
|
|
403
|
+
// keep the host charset narrow (alphanum, dot, dash) so a malformed value
|
|
404
|
+
// fails fast with a clear message instead of hitting cloudflared.
|
|
405
|
+
const PROXY_VALUE_PATTERN = /^\/[a-zA-Z0-9/_-]+=([a-zA-Z0-9.-]+:)?[0-9]+$/;
|
|
406
|
+
function collectProxy(value, previous) {
|
|
407
|
+
if (!PROXY_VALUE_PATTERN.test(value)) {
|
|
408
|
+
// InvalidArgumentError routes through Commander's error-handling path,
|
|
409
|
+
// which our exitOverride converts to the standard JSON envelope. A
|
|
410
|
+
// plain Error here would crash with a raw stack trace.
|
|
411
|
+
throw new InvalidArgumentError(`--proxy expected /prefix=port, got "${value}"`);
|
|
412
|
+
}
|
|
413
|
+
return [...previous, value];
|
|
414
|
+
}
|
|
415
|
+
// Convert raw --proxy values (already shape-validated by collectProxy) into
|
|
416
|
+
// `{prefix, target}` records the connect flow can pass straight to
|
|
417
|
+
// startReverseProxy. Rejects duplicate prefixes — the route table would
|
|
418
|
+
// otherwise have a silent winner depending on declaration order.
|
|
419
|
+
function parseProxyValues(values) {
|
|
420
|
+
const routes = [];
|
|
421
|
+
const seen = new Set();
|
|
422
|
+
for (const raw of values) {
|
|
423
|
+
const eq = raw.indexOf("=");
|
|
424
|
+
const prefix = raw.slice(0, eq);
|
|
425
|
+
const rhs = raw.slice(eq + 1);
|
|
426
|
+
if (seen.has(prefix)) {
|
|
427
|
+
throw new Error(`--proxy prefix "${prefix}" specified more than once`);
|
|
428
|
+
}
|
|
429
|
+
seen.add(prefix);
|
|
430
|
+
const target = rhs.includes(":") ? `http://${rhs}` : `http://127.0.0.1:${rhs}`;
|
|
431
|
+
routes.push({ prefix, target });
|
|
432
|
+
}
|
|
433
|
+
return routes;
|
|
434
|
+
}
|
|
391
435
|
const connectCmd = program
|
|
392
436
|
.command("connect")
|
|
393
437
|
.description("Expose your localhost to Ish via a Cloudflare tunnel")
|
|
394
438
|
.argument("[port]", "Local port to connect (e.g. 3000)")
|
|
395
439
|
.option("--detach", "Fork after first heartbeat success; print {pid, tunnel_url, registered} JSON and exit")
|
|
440
|
+
.option("--proxy <route>", "<prefix>=<port>, repeatable (e.g. --proxy /api=8000). Routes incoming requests under <prefix> to localhost:<port>; preserves the full request path.", collectProxy, [])
|
|
396
441
|
.addHelpText("after", `
|
|
397
442
|
Subcommands:
|
|
398
443
|
ish connect status Read-only check of the active detached tunnel
|
|
@@ -403,7 +448,18 @@ Note: --json emits structured one-line JSON for connected/disconnected events.
|
|
|
403
448
|
|
|
404
449
|
Detach mode tracks the active tunnel in ~/.ish/connect.lock so \`ish connect
|
|
405
450
|
status\` and \`ish disconnect\` can find it. Foreground (non-detached)
|
|
406
|
-
connects do not write a lock — Ctrl+C is the shutdown verb in that mode
|
|
451
|
+
connects do not write a lock — Ctrl+C is the shutdown verb in that mode.
|
|
452
|
+
|
|
453
|
+
Multi-service tunneling: pass --proxy /<prefix>=<port> (repeatable) to fan
|
|
454
|
+
one tunnel across several local services. The CLI runs a tiny local reverse
|
|
455
|
+
proxy on an OS-assigned port, tunnels that port, and routes requests by
|
|
456
|
+
longest-prefix match (default → <port>). One origin in the cloud browser →
|
|
457
|
+
no CORS, no cookie crossover.
|
|
458
|
+
|
|
459
|
+
Examples:
|
|
460
|
+
$ ish connect 3000 # frontend only
|
|
461
|
+
$ ish connect 3000 --proxy /api=8000 # frontend + backend
|
|
462
|
+
$ ish connect 3000 --proxy /api=8000 --proxy /ws=9000`)
|
|
407
463
|
.action(async (port, opts, cmd) => {
|
|
408
464
|
await runInline(cmd, async (globals) => {
|
|
409
465
|
if (!port) {
|
|
@@ -413,15 +469,16 @@ connects do not write a lock — Ctrl+C is the shutdown verb in that mode.`)
|
|
|
413
469
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
414
470
|
throw new Error(`Invalid port: ${port}`);
|
|
415
471
|
}
|
|
472
|
+
const routes = parseProxyValues(opts.proxy ?? []);
|
|
416
473
|
const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
|
|
417
474
|
if (opts.detach) {
|
|
418
|
-
await runDetached(portNum, apiUrl, globals.token, globals.tokenFile);
|
|
475
|
+
await runDetached(portNum, apiUrl, globals.token, globals.tokenFile, routes);
|
|
419
476
|
return;
|
|
420
477
|
}
|
|
421
478
|
await runTunnel(portNum, globals.token, apiUrl, globals.tokenFile, {
|
|
422
479
|
json: globals.json,
|
|
423
480
|
quiet: globals.quiet,
|
|
424
|
-
});
|
|
481
|
+
}, routes);
|
|
425
482
|
});
|
|
426
483
|
});
|
|
427
484
|
connectCmd
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loader/validator for
|
|
2
|
+
* Loader/validator for the questionnaire input (`ish study create/update
|
|
3
|
+
* --questionnaire`, `ish ask … --questions`).
|
|
3
4
|
*
|
|
4
|
-
* Accepts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Accepts THREE input forms so agents never have to drop a temp file just to
|
|
6
|
+
* use a rich question type — mirroring how `--assignments` takes inline JSON
|
|
7
|
+
* and `--assignments-file` takes a path:
|
|
8
|
+
* - **inline JSON array**: `'[{"question":"…","type":"slider","min":0,"max":10}]'`
|
|
9
|
+
* (value, after trimming, starts with `[`)
|
|
10
|
+
* - **@file**: `@/path/to/questions.json`
|
|
11
|
+
* - **bare file path**: `./questions.json`
|
|
12
|
+
*
|
|
13
|
+
* In every form the payload is a JSON array of InterviewQuestion entries.
|
|
14
|
+
* Validation is loose — requires `question: string` on every entry and folds
|
|
15
|
+
* the `type` enum to its canonical hyphenated form; the rest passes through, so
|
|
16
|
+
* the backend stays the source of truth for the full schema.
|
|
7
17
|
*/
|
|
8
18
|
import type { InterviewQuestion } from "./types.js";
|
|
9
|
-
export declare function loadQuestionsManifest(
|
|
19
|
+
export declare function loadQuestionsManifest(input: string): InterviewQuestion[];
|
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loader/validator for
|
|
2
|
+
* Loader/validator for the questionnaire input (`ish study create/update
|
|
3
|
+
* --questionnaire`, `ish ask … --questions`).
|
|
3
4
|
*
|
|
4
|
-
* Accepts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Accepts THREE input forms so agents never have to drop a temp file just to
|
|
6
|
+
* use a rich question type — mirroring how `--assignments` takes inline JSON
|
|
7
|
+
* and `--assignments-file` takes a path:
|
|
8
|
+
* - **inline JSON array**: `'[{"question":"…","type":"slider","min":0,"max":10}]'`
|
|
9
|
+
* (value, after trimming, starts with `[`)
|
|
10
|
+
* - **@file**: `@/path/to/questions.json`
|
|
11
|
+
* - **bare file path**: `./questions.json`
|
|
12
|
+
*
|
|
13
|
+
* In every form the payload is a JSON array of InterviewQuestion entries.
|
|
14
|
+
* Validation is loose — requires `question: string` on every entry and folds
|
|
15
|
+
* the `type` enum to its canonical hyphenated form; the rest passes through, so
|
|
16
|
+
* the backend stays the source of truth for the full schema.
|
|
7
17
|
*/
|
|
8
18
|
import { readFileSync } from "node:fs";
|
|
9
19
|
import { resolve as resolvePath } from "node:path";
|
|
10
20
|
import { normalizeEnumValue, QUESTION_TYPES } from "./enums.js";
|
|
11
|
-
export function loadQuestionsManifest(
|
|
21
|
+
export function loadQuestionsManifest(input) {
|
|
22
|
+
const trimmed = input.trim();
|
|
23
|
+
// Inline JSON vs file path (bare or `@`-prefixed). Inline JSON starts with
|
|
24
|
+
// `[` (the expected array) or `{` (a bare object — caught below with a clear
|
|
25
|
+
// "must be an array" message rather than a confusing "cannot read file");
|
|
26
|
+
// a real path never starts with either, so the leading char disambiguates.
|
|
27
|
+
const isInline = trimmed.startsWith("[") || trimmed.startsWith("{");
|
|
28
|
+
const filePath = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed;
|
|
29
|
+
const source = isInline ? "inline questionnaire JSON" : `questions file: ${filePath}`;
|
|
12
30
|
let raw;
|
|
13
|
-
|
|
14
|
-
raw =
|
|
31
|
+
if (isInline) {
|
|
32
|
+
raw = trimmed;
|
|
15
33
|
}
|
|
16
|
-
|
|
17
|
-
|
|
34
|
+
else {
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(resolvePath(filePath), "utf-8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Cannot read questions file: ${filePath}`);
|
|
40
|
+
}
|
|
18
41
|
}
|
|
19
42
|
let parsed;
|
|
20
43
|
try {
|
|
21
44
|
parsed = JSON.parse(raw);
|
|
22
45
|
}
|
|
23
46
|
catch {
|
|
24
|
-
throw new Error(`Invalid JSON in
|
|
47
|
+
throw new Error(`Invalid JSON in ${source}.`);
|
|
25
48
|
}
|
|
26
49
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
27
|
-
throw new Error(`
|
|
50
|
+
throw new Error(`Questionnaire must be a non-empty JSON array (${source}).`);
|
|
28
51
|
}
|
|
29
52
|
for (let i = 0; i < parsed.length; i++) {
|
|
30
53
|
const q = parsed[i];
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -5,4 +5,5 @@
|
|
|
5
5
|
export declare const DEFAULT_API_URL = "https://api.ishlabs.io";
|
|
6
6
|
export declare const API_BASE = "/api/v1";
|
|
7
7
|
export declare function resolveApiUrl(apiUrlArg?: string, dev?: boolean): string;
|
|
8
|
+
export declare function verifyToken(token: string, apiUrl: string): Promise<boolean>;
|
|
8
9
|
export declare function resolveToken(tokenArg: string | undefined, apiUrl: string, tokenFileArg?: string): Promise<string>;
|
package/dist/lib/auth.js
CHANGED
|
@@ -15,7 +15,13 @@ export function resolveApiUrl(apiUrlArg, dev) {
|
|
|
15
15
|
return apiUrlArg;
|
|
16
16
|
return process.env.ISH_API_URL ?? DEFAULT_API_URL;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// Exported so the `ish login` idempotency guard can confirm the API actually
|
|
19
|
+
// accepts a saved token before claiming "Already logged in" — a locally
|
|
20
|
+
// unexpired JWT can still be rejected server-side (revoked session, rotated
|
|
21
|
+
// signing key, or a token minted against the dev Supabase project while we call
|
|
22
|
+
// the prod api). Both callers treat a network blip as "assume valid" (returns
|
|
23
|
+
// true below) so an offline user is never forced through a needless re-login.
|
|
24
|
+
export async function verifyToken(token, apiUrl) {
|
|
19
25
|
try {
|
|
20
26
|
const resp = await fetch(`${apiUrl}${API_BASE}/connect/active`, {
|
|
21
27
|
headers: withBaggage({ Authorization: `Bearer ${token}` }),
|
|
@@ -657,14 +657,38 @@ function noActiveContextError(message, errorCode, suggestions) {
|
|
|
657
657
|
err.suggestions = suggestions;
|
|
658
658
|
return err;
|
|
659
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Reject an explicitly-supplied-but-empty context flag (`--study ""`,
|
|
662
|
+
* `--workspace " "`). Distinct from the flag being *omitted*
|
|
663
|
+
* (`explicit === undefined`), which legitimately falls through to env →
|
|
664
|
+
* active-config. An empty/whitespace value is a usage error: it would
|
|
665
|
+
* otherwise be falsy and silently resolve to the active context, attaching
|
|
666
|
+
* work to the wrong entity. Maps to exit 2 via the ValidationError name.
|
|
667
|
+
*/
|
|
668
|
+
function rejectEmptyContextFlag(explicit, flag, noun) {
|
|
669
|
+
if (!explicit.trim()) {
|
|
670
|
+
const err = new Error(`${flag} was given an empty value. Pass a ${noun} id/alias, or omit it to use the active ${noun}.`);
|
|
671
|
+
err.name = "ValidationError";
|
|
672
|
+
throw err;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
660
675
|
export function resolveWorkspace(explicit) {
|
|
661
|
-
if (explicit)
|
|
676
|
+
if (explicit !== undefined) {
|
|
677
|
+
rejectEmptyContextFlag(explicit, "--workspace", "workspace");
|
|
662
678
|
return resolveId(explicit);
|
|
679
|
+
}
|
|
663
680
|
// Fall back to the program-root --workspace cached by applyGlobals — covers
|
|
664
681
|
// `ish --workspace W study list` where the subcommand action doesn't see the
|
|
665
|
-
// flag in its local opts.
|
|
666
|
-
|
|
682
|
+
// flag in its local opts. The GLOBAL --workspace shadows the subcommand's
|
|
683
|
+
// local one, so an empty `--workspace ""` arrives HERE (as `_activeWorkspace
|
|
684
|
+
// === ""`), never via `explicit`. computeGlobals preserves the empty-vs-
|
|
685
|
+
// omitted distinction (`""` when passed empty, `undefined` when omitted), so
|
|
686
|
+
// apply the same empty-value guard as `explicit` above — otherwise
|
|
687
|
+
// `--workspace ""` silently falls through to env/saved config (exit 0).
|
|
688
|
+
if (_activeWorkspace !== undefined) {
|
|
689
|
+
rejectEmptyContextFlag(_activeWorkspace, "--workspace", "workspace");
|
|
667
690
|
return resolveId(_activeWorkspace);
|
|
691
|
+
}
|
|
668
692
|
const env = process.env.ISH_WORKSPACE;
|
|
669
693
|
if (env)
|
|
670
694
|
return resolveId(env);
|
|
@@ -677,8 +701,10 @@ export function resolveWorkspace(explicit) {
|
|
|
677
701
|
]);
|
|
678
702
|
}
|
|
679
703
|
export function resolveStudy(explicit) {
|
|
680
|
-
if (explicit)
|
|
704
|
+
if (explicit !== undefined) {
|
|
705
|
+
rejectEmptyContextFlag(explicit, "--study", "study");
|
|
681
706
|
return resolveId(explicit);
|
|
707
|
+
}
|
|
682
708
|
const env = process.env.ISH_STUDY;
|
|
683
709
|
if (env)
|
|
684
710
|
return resolveId(env);
|
|
@@ -691,8 +717,10 @@ export function resolveStudy(explicit) {
|
|
|
691
717
|
]);
|
|
692
718
|
}
|
|
693
719
|
export function resolveAsk(explicit) {
|
|
694
|
-
if (explicit)
|
|
720
|
+
if (explicit !== undefined) {
|
|
721
|
+
rejectEmptyContextFlag(explicit, "--ask", "ask");
|
|
695
722
|
return resolveId(explicit);
|
|
723
|
+
}
|
|
696
724
|
const env = process.env.ISH_ASK;
|
|
697
725
|
if (env)
|
|
698
726
|
return resolveId(env);
|