@ishlabs/cli 0.23.1 → 0.24.1

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/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(port, cloudflaredPath, json);
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, port);
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
- console.log(JSON.stringify({
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, port));
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
- console.log(JSON.stringify({
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
- console.log(JSON.stringify({
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
- if (existingToken && !isTokenExpired(existingToken)) {
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 `--questions <file.json>`.
2
+ * Loader/validator for the questionnaire input (`ish study create/update
3
+ * --questionnaire`, `ish ask … --questions`).
3
4
  *
4
- * Accepts a JSON array of InterviewQuestion entries. Mirrors the loose validation
5
- * used by `ish study create --questions`: requires `question: string` on every entry,
6
- * passes the rest through. The backend is the source of truth for the full schema.
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(filePath: string): InterviewQuestion[];
19
+ export declare function loadQuestionsManifest(input: string): InterviewQuestion[];
@@ -1,30 +1,53 @@
1
1
  /**
2
- * Loader/validator for `--questions <file.json>`.
2
+ * Loader/validator for the questionnaire input (`ish study create/update
3
+ * --questionnaire`, `ish ask … --questions`).
3
4
  *
4
- * Accepts a JSON array of InterviewQuestion entries. Mirrors the loose validation
5
- * used by `ish study create --questions`: requires `question: string` on every entry,
6
- * passes the rest through. The backend is the source of truth for the full schema.
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(filePath) {
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
- try {
14
- raw = readFileSync(resolvePath(filePath), "utf-8");
31
+ if (isInline) {
32
+ raw = trimmed;
15
33
  }
16
- catch {
17
- throw new Error(`Cannot read questions file: ${filePath}`);
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 questions file: ${filePath}`);
47
+ throw new Error(`Invalid JSON in ${source}.`);
25
48
  }
26
49
  if (!Array.isArray(parsed) || parsed.length === 0) {
27
- throw new Error(`Questions file must be a non-empty JSON array: ${filePath}`);
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];
@@ -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
- async function verifyToken(token, apiUrl) {
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}` }),
@@ -2,17 +2,23 @@
2
2
  * Credit cost estimators — mirror the backend's billing formulas so the CLI
3
3
  * can surface a pre-dispatch estimate without a network round-trip.
4
4
  *
5
- * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
- * and `app/billing/service.py`. If the backend formula changes, update
7
- * this file in the same commit, otherwise the CLI will mislead agents.
5
+ * Source of truth: `ish-backend/app/billing/rates.py` (`MODALITY_RATES`,
6
+ * `compute_step_cost`, `compute_chat_cost`, `ASK_PER_RESPONSE_CREDITS`,
7
+ * `PAIR_CHAT_SIDE_MULTIPLIER`). These numbers MUST match that module when a
8
+ * multiplier changes there, update this file in the same commit, otherwise the
9
+ * CLI will mislead agents. The backend prices through one `price_run` dispatcher
10
+ * shared by `POST /billing/estimate` and the real preflight reservation, so the
11
+ * preview here is calibrated to the actual charge as long as the rates agree.
8
12
  *
9
- * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
- * per principal (per participant for media/interactive, per conversation for
11
- * chat, ×2 for participant-pair). Asks bill flat 1 credit per successful
12
- * participant response. These are intentionally per-run estimates; long-term
13
- * we'll fetch `GET /billing/rates` and parameterise modalitiessee
14
- * `reference/credits` docs page.
13
+ * Each modality has its own per-step rate (interactive costs the most —
14
+ * screenshot + vision per step; text-only the least). The per-principal cost is
15
+ * `max(1, round(steps * per_step_credits))`, per participant for step-based
16
+ * modalities and per conversation (×2 in pair mode) for chat. Asks bill a flat
17
+ * credit per successful participant response (an upper bound refusals/errors
18
+ * don't bill). Clients that need authoritative live rates can fetch them from
19
+ * `GET /billing/rates`; this offline mirror is for the pre-dispatch preview.
15
20
  */
21
+ import type { Modality } from "./modality.js";
16
22
  export interface CreditEstimate {
17
23
  /** Upper bound (no early termination). Never claims exactness. */
18
24
  upper_bound: number;
@@ -23,16 +29,24 @@ export interface CreditEstimate {
23
29
  /** Always "credits" today; reserved for future-proofing (e.g. millicredits). */
24
30
  unit: "credits";
25
31
  }
26
- /** Mirror of `app/media/billing.py::media_credit_cost`. */
27
- export declare function mediaCreditCost(steps: number): number;
28
- /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
29
- export declare function chatCreditCost(turns: number): number;
30
32
  /**
31
- * Media/interactive run: 1 credit-cost-per-participant × participant count. Modality
32
- * doesn't currently affect the rate (interactive == text == video at the
33
- * billing layer) — kept as a parameter for forward compatibility.
33
+ * Per-principal step-based cost for one participant running `steps` interactions
34
+ * on a study of `modality`. Mirror of `app/billing/rates.py::compute_step_cost`.
35
+ */
36
+ export declare function stepCreditCost(modality: Modality, steps: number): number;
37
+ /**
38
+ * Per-conversation chat cost. Mirror of `app/billing/rates.py::compute_chat_cost`
39
+ * — `max(1, round(turns * chat_rate))`, doubled in pair mode (both sides bill
40
+ * per turn).
41
+ */
42
+ export declare function chatCreditCost(turns: number, isPair?: boolean): number;
43
+ /**
44
+ * Step-based run (interactive / text / image / video / audio / document):
45
+ * per-participant cost × participant count. The per-step rate is modality-
46
+ * specific (see `PER_STEP_CREDITS`).
34
47
  */
35
48
  export declare function estimateMediaRun(args: {
49
+ modality: Modality;
36
50
  participantCount: number;
37
51
  maxInteractions: number;
38
52
  }): CreditEstimate;