foldrun 0.5.0 → 0.6.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 CHANGED
@@ -107,6 +107,7 @@ declared the same way.
107
107
  | `foldrun open [page]` | the dashboard for this workspace, in the browser |
108
108
  | `foldrun login` | sign this machine in from the browser — no key to copy |
109
109
  | `foldrun whoami` | who you are on the platform: account, role, workspaces |
110
+ | `foldrun doctor` | check the path to the platform: node, CLI, account, DNS, a timed `/api/healthz` — exit 1 if any line fails |
110
111
  | `foldrun keys ls` | the account's API keys — also `create <label>`, `revoke <id>` |
111
112
 
112
113
  `foldrun --help` lists every flag.
package/bin/foldrun.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  // foldrun open [page] the dashboard for this workspace
20
20
  // foldrun login sign this machine in from the browser
21
21
  // foldrun whoami who the platform thinks this terminal is
22
+ // foldrun doctor what is between this terminal and the platform, checked
22
23
  // foldrun accounts every account signed in here; `use <name>` switches
23
24
  // foldrun keys <verb> ls / create / revoke — the account's API keys
24
25
  //
@@ -47,7 +48,7 @@ const HELP = `foldrun — agents are just folders
47
48
  foldrun eval [name] run one eval, or all of them
48
49
  foldrun probe <model> live check: can this model hold a tool loop here?
49
50
  foldrun logs [run-id] recent runs, or one run's full event trail
50
- foldrun secrets set NAME store a secret (prompted, never echoed) — also ls, rm
51
+ foldrun secrets set NAME store a secret (prompted, never echoed) — also ls, rm, status
51
52
  foldrun connect NAME OAuth sign-in from the terminal, stored as an auto-refreshing secret
52
53
  foldrun deploy [dir] push a workspace into an installation
53
54
  foldrun invoke <flow> start a flow on a running platform (--to <workspace>)
@@ -58,6 +59,7 @@ Signing in
58
59
  foldrun login sign this machine in from the browser (--token <key> to skip it)
59
60
  foldrun logout forget this machine's key, and revoke it where allowed
60
61
  foldrun whoami who you are on the platform: account, role, workspaces
62
+ foldrun doctor check the path to the platform: node, CLI, account, DNS, a timed /api/healthz
61
63
  foldrun accounts every account signed in on this machine, and which one is active
62
64
  foldrun use <name> act as one of them from here on
63
65
  foldrun keys ls the account's API keys — also create <label>, revoke <id>
@@ -67,6 +69,7 @@ Options
67
69
  --workspace <dir> the workspace folder (default: .)
68
70
  --from <template> start from a shipped template, e.g. templates/hello
69
71
  --task "<text>" the instruction for a manual run
72
+ --test run, invoke: a test run — nothing outward, state/ untouched, receipts on the run page
70
73
  --follow logs: keep tailing a live run (with --url: on the platform)
71
74
  --value "<text>" secrets set: skip the prompt (careful with shell history)
72
75
  --file <path> source put: the local file to send (default: stdin)
@@ -91,6 +94,7 @@ Platform options (deploy, invoke, secrets, logs, keys)
91
94
  --data <dir> the installation's data directory
92
95
  --url <url> a running platform (or FOLDRUN_URL, or where you last signed in)
93
96
  --token <key> API key for --url (or FOLDRUN_TOKEN, or the one from foldrun login)
97
+ FOLDRUN_TIMEOUT=<s> seconds one request to the platform may take (default 30)
94
98
  --profile <name> act as one stored account for this command (see foldrun accounts)
95
99
  --local deploy: into the installation on this machine, even when signed in
96
100
  --commit <sha> deploy: record which commit this is
@@ -111,7 +115,7 @@ if (!command || command === "--help" || command === "-h") {
111
115
  // `--value` as the account's argument and stored an empty secret; `--force
112
116
  // ./dir` swallowed the directory. A flag followed by another flag is also
113
117
  // boolean, so an unlisted switch at least does not eat its neighbour.
114
- const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local"]);
118
+ const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test"]);
115
119
  const flags = {};
116
120
  const positional = [];
117
121
  for (let i = 0; i < rest.length; i++) {
@@ -162,12 +166,14 @@ if (isDeploy) {
162
166
  process.env.FOLDRUN_DATA ??= installationRoot ?? path.join(workspace, ".foldrun");
163
167
  }
164
168
 
165
- const { run } = await import(path.join(HERE, "../src/commands.mjs"));
169
+ const { run, explain } = await import(path.join(HERE, "../src/commands.mjs"));
166
170
 
167
171
  try {
168
172
  const code = await run(command, positional, flags, workspace);
169
173
  process.exit(code ?? 0);
170
174
  } catch (err) {
171
- console.error(`\n ${err instanceof Error ? err.message : String(err)}\n`);
175
+ // The whole chain, not the top: "fetch failed" is the top, and the
176
+ // ECONNREFUSED underneath it is the part that says what to do.
177
+ console.error(`\n ${explain(err)}\n`);
172
178
  process.exit(1);
173
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agents are just folders. Write them, check them, run them — on your machine.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "dependencies": {
14
- "@foldrun/core": "^0.3.0"
14
+ "@foldrun/core": "^0.4.0"
15
15
  },
16
16
  "files": [
17
17
  "bin",
@@ -20,8 +20,9 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
23
- "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-secrets.test.ts tests/cli-check-library.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts",
24
- "release": "node scripts/release.mjs"
23
+ "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-secrets.test.ts tests/cli-check-library.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts",
24
+ "release": "node scripts/release.mjs",
25
+ "typecheck": "tsc -p tsconfig.json"
25
26
  },
26
27
  "repository": {
27
28
  "type": "git",
@@ -40,5 +41,10 @@
40
41
  ],
41
42
  "publishConfig": {
42
43
  "access": "public"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.20.2",
47
+ "gray-matter": "^4.0.3",
48
+ "typescript": "^5.9.3"
43
49
  }
44
50
  }
package/src/commands.mjs CHANGED
@@ -1,19 +1,25 @@
1
1
  // CLI commands. Kept separate from bin/ so the environment is set before the
2
2
  // core is imported — single-workspace mode is read at module load.
3
3
 
4
+ import dns from "node:dns";
4
5
  import fs from "node:fs";
6
+ import net from "node:net";
5
7
  import os from "node:os";
6
8
  import path from "node:path";
7
9
  import { fileURLToPath } from "node:url";
8
- import { credentialFor, defaultPlatform, saveCredential, removeCredential, readCredentials, normaliseUrl, profileByName, currentProfile, useProfile, listProfiles } from "./credentials.mjs";
10
+ import { defaultPlatform, saveCredential, removeCredential, readCredentials, normaliseUrl, profileByName, currentProfile, useProfile, listProfiles } from "./credentials.mjs";
9
11
 
12
+ // NO_COLOR (no-color.org) turns the escapes off — for a log file, a CI
13
+ // job, or a test that wants to match what a person reads. The tests had
14
+ // been setting it all along, to no effect.
15
+ const paint = (code) => (s) => (process.env.NO_COLOR ? String(s) : `\x1b[${code}m${s}\x1b[0m`);
10
16
  const c = {
11
- dim: (s) => `\x1b[2m${s}\x1b[0m`,
12
- bold: (s) => `\x1b[1m${s}\x1b[0m`,
13
- green: (s) => `\x1b[32m${s}\x1b[0m`,
14
- red: (s) => `\x1b[31m${s}\x1b[0m`,
15
- yellow: (s) => `\x1b[33m${s}\x1b[0m`,
16
- amber: (s) => `\x1b[33m${s}\x1b[0m`,
17
+ dim: paint(2),
18
+ bold: paint(1),
19
+ green: paint(32),
20
+ red: paint(31),
21
+ yellow: paint(33),
22
+ amber: paint(33),
17
23
  };
18
24
 
19
25
  // The published runtime, not a relative reach into a sibling directory. It was
@@ -323,12 +329,14 @@ async function extract(workspace, flags) {
323
329
  // Prove it before deleting anything. parseToolDef is what the runtime
324
330
  // uses, so "it loads" here means it loads there.
325
331
  const check = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(manifest);
332
+ if (!check) throw new Error(`${name}: the manifest has no frontmatter — nothing to extract from`);
326
333
  const data = Object.fromEntries(
327
334
  check[1]
328
335
  .split("\n")
329
- .map((l) => /^([a-z_-]+):\s*(.*)$/.exec(l))
330
- .filter(Boolean)
331
- .map((m) => [m[1], m[2]]),
336
+ .flatMap((l) => {
337
+ const m = /^([a-z_-]+):\s*(.*)$/.exec(l);
338
+ return m ? [[m[1], m[2]]] : [];
339
+ }),
332
340
  );
333
341
  const def = parseToolDef(data, name, manifest.slice(check[0].length));
334
342
  const ok =
@@ -369,7 +377,7 @@ async function platformLibrary(flags) {
369
377
  if (!url) return { url: undefined, tools: new Set(), skills: new Set(), warning: null };
370
378
  let token;
371
379
  try {
372
- token = tokenFor(url, flags);
380
+ token = tokenFor(url, { ...flags, quiet: true });
373
381
  } catch {
374
382
  return { url, tools: new Set(), skills: new Set(), warning: `${url} is the platform but this machine is not signed in — library tools there cannot be seen` };
375
383
  }
@@ -379,7 +387,7 @@ async function platformLibrary(flags) {
379
387
  signal: AbortSignal.timeout(8_000),
380
388
  });
381
389
  if (!res.ok) throw new Error(`/api/library/${kind} → HTTP ${res.status}`);
382
- const body = await res.json();
390
+ const body = /** @type {any} */ (await res.json());
383
391
  return new Set((body.entries ?? []).map((e) => e.name).filter(Boolean));
384
392
  };
385
393
  try {
@@ -556,7 +564,7 @@ async function check(workspace, flags = {}) {
556
564
  note("error", `flows/${f.file}`, `[[${s.subflow ? "flow:" : ""}${target}]] does not exist`, s.line);
557
565
  }
558
566
  }
559
- for (const w of lintFlow(f, { agents: [...agentNames] })) note("warn", `flows/${f.file}`, w.message, w.line);
567
+ for (const w of lintFlow(f, { agents: [...agentNames] })) note(w.level ?? "warn", `flows/${f.file}`, w.message, w.line);
560
568
  }
561
569
 
562
570
  for (const e of evals) {
@@ -711,6 +719,11 @@ async function runTarget(target, flags) {
711
719
  const name = target.replace(/^flow:/, "");
712
720
  const asFlow = target.startsWith("flow:") || !listAgents(T, P).some((a) => a.name === name);
713
721
 
722
+ // --test: a test run. Locally there is no egress proxy, so what holds is
723
+ // the runner's own half of it — send-capable secrets are withheld from
724
+ // scripts, FOLDRUN_TEST_MODE=1 is set, and state/ and storage/ writes
725
+ // are moved under runs/<id>/test-writes/ after each step.
726
+ const opts = flags.test === true ? { test: true } : {};
714
727
  let run;
715
728
  if (asFlow) {
716
729
  const flow = loadFlow(T, P, name);
@@ -720,12 +733,12 @@ async function runTarget(target, flags) {
720
733
  i === 0 ? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${flags.task}\n</run_task>` } : s,
721
734
  )
722
735
  : flow.steps;
723
- run = startFlowRun(T, P, steps, flow.name, flow.model);
736
+ run = startFlowRun(T, P, steps, flow.name, flow.model, [], null, null, opts);
724
737
  } else {
725
- run = startFlowRun(T, P, [{ agent: name, instruction: flags.task ?? "", group: 1, optional: false }], `cli:${name}`);
738
+ run = startFlowRun(T, P, [{ agent: name, instruction: flags.task ?? "", group: 1, optional: false }], `cli:${name}`, null, [], null, null, opts);
726
739
  }
727
740
 
728
- console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)}\n`);
741
+ console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)}${run.test ? ` ${c.amber("TEST")}` : ""}\n`);
729
742
  const seen = new Map();
730
743
  for (;;) {
731
744
  const current = readRun(T, P, run.id);
@@ -896,7 +909,7 @@ async function deployOverHttp(url, workspace, files, flags) {
896
909
  try {
897
910
  res = await fetch(endpoint, {
898
911
  method: "POST",
899
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
912
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": USER_AGENT },
900
913
  body: JSON.stringify({
901
914
  files,
902
915
  commit: flags.commit ?? null,
@@ -908,7 +921,7 @@ async function deployOverHttp(url, workspace, files, flags) {
908
921
  throw new Error(`could not reach ${url} — ${err instanceof Error ? err.message : String(err)}`);
909
922
  }
910
923
 
911
- const body = await res.json().catch(() => ({}));
924
+ const body = /** @type {any} */ (await res.json().catch(() => ({})));
912
925
  if (res.status === 401) throw new Error(`${body.error ?? "unauthorized"} — run \`foldrun login\` again, or check FOLDRUN_TOKEN`);
913
926
  // 422 is a refusal the caller has to read, not a transport failure: the
914
927
  // issues are in the body and reported like any other refused deploy.
@@ -941,6 +954,7 @@ async function deploy(source, flags) {
941
954
  // which is what a laptop or a CI job does. Without one, or with --local,
942
955
  // it is written straight into the installation on this machine.
943
956
  const url = flags.local === true ? undefined : remoteUrl(flags);
957
+ /** @type {any} */
944
958
  const plan = url
945
959
  ? await deployOverHttp(url, workspace, files, flags)
946
960
  : flags["dry-run"]
@@ -1064,8 +1078,17 @@ function promptVisible(question) {
1064
1078
  // should not shadow the credentials file with nothing.
1065
1079
  const env = (name) => process.env[name] || undefined;
1066
1080
 
1067
- /** The profile a command acts as: --profile by name, else the one whose URL
1068
- * was asked for, else the current one. */
1081
+ /**
1082
+ * The profile a command acts as. In order: --profile by name; else, when a
1083
+ * URL is named (--url, FOLDRUN_URL), the current profile if it is on that
1084
+ * URL, else the one profile there, else a refusal that lists them; else the
1085
+ * current profile.
1086
+ *
1087
+ * It was "the newest login on that URL", which meant `foldrun whoami --url
1088
+ * https://dev.foldrun.io` acted as a different account from the bare
1089
+ * `foldrun whoami` on a machine with two customers on one platform — the
1090
+ * same command, two accounts, and nothing on screen to say so.
1091
+ */
1069
1092
  function chosenProfile(flags) {
1070
1093
  if (typeof flags.profile === "string") {
1071
1094
  const p = profileByName(flags.profile);
@@ -1075,9 +1098,52 @@ function chosenProfile(flags) {
1075
1098
  }
1076
1099
  return p;
1077
1100
  }
1078
- const url = flags.url ?? env("FOLDRUN_URL");
1079
- if (url) return credentialFor(url);
1080
- return currentProfile();
1101
+ const current = currentProfile();
1102
+ const url = typeof flags.url === "string" ? flags.url : env("FOLDRUN_URL");
1103
+ if (!url) return current;
1104
+ let key;
1105
+ try {
1106
+ key = normaliseUrl(url);
1107
+ } catch {
1108
+ return null;
1109
+ }
1110
+ if (current && normaliseUrl(current.url) === key) return current;
1111
+ const here = listProfiles().filter((p) => normaliseUrl(p.url) === key);
1112
+ if (here.length <= 1) return here[0] ?? null;
1113
+ throw new Error(
1114
+ `${here.length} accounts are signed in to ${key} (${here.map((p) => p.name).join(", ")}) and none of them is the current one — say which: --profile <name> for this command, or \`foldrun use <name>\``,
1115
+ );
1116
+ }
1117
+
1118
+ /**
1119
+ * Which account this command is acting as, said once, dim, on stderr —
1120
+ * stderr so `foldrun source cat … > file` stays a file. The same shape as
1121
+ * a row of `foldrun accounts`, so the two read as one thing.
1122
+ */
1123
+ let announced = false;
1124
+ function announce(url, flags) {
1125
+ if (announced || flags.quiet === true) return;
1126
+ announced = true;
1127
+ const line = actingAs(url, flags);
1128
+ if (line) console.error(` ${c.dim(line)}`);
1129
+ }
1130
+
1131
+ /** "acting as <name> <account> · <role> · <email> · <url>", or the --token / FOLDRUN_TOKEN form. */
1132
+ function actingAs(url, flags) {
1133
+ if (typeof flags.token === "string") return `acting with --token · ${url}`;
1134
+ if (env("FOLDRUN_TOKEN")) return `acting with FOLDRUN_TOKEN · ${url}`;
1135
+ const p = chosenProfile(flags);
1136
+ return p ? `acting as ${p.name} ${p.account} · ${p.role ?? "?"} · ${p.email ?? "api key"} · ${p.url}` : null;
1137
+ }
1138
+
1139
+ /**
1140
+ * What to add to a 401/403: which credential was refused. Without it a
1141
+ * `logs --url` that fails on a machine with three accounts says
1142
+ * "forbidden" and leaves the reader to guess which key was sent.
1143
+ */
1144
+ function refusedHint(url, flags) {
1145
+ const who = actingAs(url, flags);
1146
+ return who ? ` — ${who}; \`foldrun accounts\` lists the others, --profile <name> picks one` : " — `foldrun login` signs this machine in";
1081
1147
  }
1082
1148
 
1083
1149
  const remoteUrl = (flags) =>
@@ -1089,23 +1155,146 @@ const NOT_SIGNED_IN = "not signed in — run `foldrun login`, or set FOLDRUN_TOK
1089
1155
  function tokenFor(url, flags) {
1090
1156
  // --token and the environment win, always: a CI job with a key in the
1091
1157
  // environment must never read a file, whatever is stored in it.
1092
- const token = flags.token ?? env("FOLDRUN_TOKEN") ?? chosenProfile(flags)?.token ?? credentialFor(url)?.token;
1158
+ const token = flags.token ?? env("FOLDRUN_TOKEN") ?? chosenProfile(flags)?.token;
1093
1159
  if (!token) throw new Error(NOT_SIGNED_IN);
1160
+ announce(url, flags);
1094
1161
  return token;
1095
1162
  }
1096
1163
 
1164
+ // ---------------------------------------------------------------- the HTTP client
1165
+
1166
+ /** This CLI's version, so a platform's logs can tell which CLI called. */
1167
+ const CLI_VERSION = (() => {
1168
+ try {
1169
+ return String(JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0");
1170
+ } catch {
1171
+ return "0";
1172
+ }
1173
+ })();
1174
+ const USER_AGENT = `foldrun-cli/${CLI_VERSION}`;
1175
+
1176
+ /** How long one request may take, in seconds: FOLDRUN_TIMEOUT, else 30. */
1177
+ function timeoutSeconds() {
1178
+ const n = Number(env("FOLDRUN_TIMEOUT"));
1179
+ return Number.isFinite(n) && n > 0 ? n : 30;
1180
+ }
1181
+
1182
+ /**
1183
+ * Every message on the way down, joined. "fetch failed" on its own says
1184
+ * nothing; the ECONNREFUSED / ENOTFOUND / connect timeout that explains it
1185
+ * sits in err.cause, where undici puts it and where nothing printed from —
1186
+ * so `foldrun whoami` against a stopped box said "fetch failed" and no more.
1187
+ * @param {unknown} err
1188
+ */
1189
+ export function explain(err) {
1190
+ const parts = [];
1191
+ const seen = new Set();
1192
+ for (let e = /** @type {any} */ (err); e != null && !seen.has(e); e = e.cause) {
1193
+ seen.add(e);
1194
+ // undici tries every address and hands back an AggregateError with an
1195
+ // empty message; the first member is the one worth reading.
1196
+ if (e instanceof AggregateError && !e.message && e.errors?.length) {
1197
+ parts.push(explain(e.errors[0]));
1198
+ continue;
1199
+ }
1200
+ const msg = e instanceof Error ? e.message : String(e);
1201
+ const code = typeof e.code === "string" && !msg.includes(e.code) ? ` (${e.code})` : "";
1202
+ parts.push(`${msg}${code}`);
1203
+ }
1204
+ return parts.join(": ");
1205
+ }
1206
+
1207
+ /** A failed call: the status and whatever the platform said, kept on the error. */
1208
+ export class HttpError extends Error {
1209
+ /** @param {string} message @param {number} status @param {any} body */
1210
+ constructor(message, status, body) {
1211
+ super(message);
1212
+ this.status = status;
1213
+ this.body = body;
1214
+ }
1215
+ }
1216
+
1217
+ const RETRY_STATUS = new Set([429, 502, 503, 504]);
1218
+
1219
+ /** Retry-After as milliseconds — seconds or an HTTP date — else one second, never past the timeout. */
1220
+ function retryAfterMs(res, seconds) {
1221
+ const h = res.headers.get("retry-after");
1222
+ let ms = 1000;
1223
+ if (h && /^\d+$/.test(h.trim())) ms = Number(h) * 1000;
1224
+ else if (h && !Number.isNaN(Date.parse(h))) ms = Date.parse(h) - Date.now();
1225
+ return Math.min(Math.max(ms, 0), seconds * 1000);
1226
+ }
1227
+
1228
+ /**
1229
+ * One request to a platform: the auth header, a User-Agent, and a clock.
1230
+ *
1231
+ * Without the clock a box that accepts the TCP connection and never answers
1232
+ * held the terminal for as long as the kernel allowed. A GET that meets a
1233
+ * 429/502/503/504 is asked once more, after Retry-After when the server
1234
+ * names one — a rolling deploy or a rate limit deserves the nudge. Nothing
1235
+ * else is retried: a second POST could be a second run.
1236
+ * @param {string} url @param {string} apiPath @param {RequestInit} [init]
1237
+ * @param {{ token?: string }} [opts]
1238
+ */
1239
+ async function remoteFetch(url, apiPath, init = {}, { token } = {}) {
1240
+ const target = new URL(apiPath, url);
1241
+ const method = (init.method ?? "GET").toUpperCase();
1242
+ const headers = {
1243
+ "user-agent": USER_AGENT,
1244
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
1245
+ ...(init.headers ?? {}),
1246
+ };
1247
+ const seconds = timeoutSeconds();
1248
+ const attempt = async () => {
1249
+ try {
1250
+ return await fetch(target, { ...init, headers, signal: AbortSignal.timeout(seconds * 1000) });
1251
+ } catch (err) {
1252
+ const name = /** @type {any} */ (err)?.name;
1253
+ if (name === "TimeoutError" || name === "AbortError") {
1254
+ throw new Error(`${target.host} did not answer ${method} ${target.pathname} within ${seconds}s — FOLDRUN_TIMEOUT=<seconds> allows longer`);
1255
+ }
1256
+ throw new Error(`could not reach ${target.origin}`, { cause: err });
1257
+ }
1258
+ };
1259
+ let res = await attempt();
1260
+ if (method === "GET" && RETRY_STATUS.has(res.status)) {
1261
+ await sleep(retryAfterMs(res, seconds));
1262
+ res = await attempt();
1263
+ }
1264
+ return res;
1265
+ }
1266
+
1267
+ /**
1268
+ * A call to the platform's API, as JSON. A body that is not JSON is not the
1269
+ * platform talking — a Cloudflare challenge, a proxy's error page, a tunnel
1270
+ * that is down — and used to be read as {} and reported as a bare "HTTP
1271
+ * 403". Now the status, the content-type and the first line say so.
1272
+ * @param {string} url @param {Record<string, any>} flags @param {string} apiPath @param {RequestInit} [init]
1273
+ * @returns {Promise<any>}
1274
+ */
1097
1275
  async function remoteCall(url, flags, apiPath, init = {}) {
1098
1276
  const token = tokenFor(url, flags);
1099
- const res = await fetch(new URL(apiPath, url), {
1100
- ...init,
1101
- headers: {
1102
- authorization: `Bearer ${token}`,
1103
- "content-type": "application/json",
1104
- ...(init.headers ?? {}),
1105
- },
1106
- });
1107
- const body = await res.json().catch(() => ({}));
1108
- if (!res.ok) throw new Error(body.error ?? `${apiPath} → HTTP ${res.status}`);
1277
+ const res = await remoteFetch(url, apiPath, { ...init, headers: { "content-type": "application/json", ...(init.headers ?? {}) } }, { token });
1278
+ const text = await res.text();
1279
+ let body;
1280
+ try {
1281
+ body = text.trim() ? JSON.parse(text) : {};
1282
+ } catch {
1283
+ if (res.ok) return {};
1284
+ const type = (res.headers.get("content-type") ?? "unknown type").split(";")[0].trim();
1285
+ const title = text.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim();
1286
+ const line = title || text.split("\n").map((l) => l.trim()).find(Boolean) || "";
1287
+ throw new HttpError(`${apiPath} → HTTP ${res.status} (${type})${line ? `: ${line.slice(0, 160)}` : ""} — not the platform's answer; is ${new URL(url).host} the right address, and is it up?`, res.status, {});
1288
+ }
1289
+ if (!res.ok) {
1290
+ // The body is the useful half of a failure and throwing it away is how a
1291
+ // failed RUN comes to look like a broken PLATFORM. `?wait=true` answers a
1292
+ // run that failed with 500 and a full record of why — status, steps, the
1293
+ // agent's last words — and a caller that prints only "HTTP 500" sends the
1294
+ // reader hunting for an outage that never happened.
1295
+ const hint = res.status === 401 || res.status === 403 ? refusedHint(url, flags) : "";
1296
+ throw new HttpError(`${body.error ?? `${apiPath} → HTTP ${res.status}`}${hint}`, res.status, body);
1297
+ }
1109
1298
  return body;
1110
1299
  }
1111
1300
 
@@ -1128,49 +1317,125 @@ function finishLine(run) {
1128
1317
  return ok ? 0 : run.status === "awaiting-approval" ? 2 : 1;
1129
1318
  }
1130
1319
 
1320
+ /** A run that has not finished: the stream is worth following. */
1321
+ const isLive = (run) => run.status === "queued" || run.status === "running" || run.status === "awaiting-approval";
1322
+
1323
+ /** How long a run stream may stay silent before it is reconnected: four request timeouts, 120 s by default. */
1324
+ const streamIdleSeconds = () => timeoutSeconds() * 4;
1325
+
1326
+ /** The stream said nothing for this long. Not a failure yet; a reason to reconnect. */
1327
+ class StreamIdle extends Error {
1328
+ /** @param {number} seconds */
1329
+ constructor(seconds) {
1330
+ super(`the run stream went quiet for ${seconds}s`);
1331
+ this.seconds = seconds;
1332
+ }
1333
+ }
1334
+
1335
+ /**
1336
+ * One connection to a run's stream, printed as frames land. Resolves with
1337
+ * the finished run on `done`, with the last run seen if the server ends it
1338
+ * first, and throws StreamIdle when nothing arrives for streamIdleSeconds.
1339
+ */
1340
+ async function streamOnce(url, flags, ws, runId, seen) {
1341
+ const token = tokenFor(url, flags);
1342
+ const idle = streamIdleSeconds();
1343
+ const target = new URL(`/api/workspaces/${ws}/runs/${runId}/stream`, url);
1344
+ const ctrl = new AbortController();
1345
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
1346
+ let timer;
1347
+ const arm = () => {
1348
+ clearTimeout(timer);
1349
+ timer = setTimeout(() => ctrl.abort(new StreamIdle(idle)), idle * 1000);
1350
+ };
1351
+ arm();
1352
+ try {
1353
+ let res;
1354
+ try {
1355
+ res = await fetch(target, {
1356
+ headers: { authorization: `Bearer ${token}`, accept: "text/event-stream", "user-agent": USER_AGENT },
1357
+ signal: ctrl.signal,
1358
+ });
1359
+ } catch (err) {
1360
+ if (ctrl.signal.aborted) throw ctrl.signal.reason;
1361
+ throw new Error(`could not reach ${target.origin}`, { cause: err });
1362
+ }
1363
+ if (!res.ok || !res.body) {
1364
+ const hint = res.status === 401 || res.status === 403 ? refusedHint(url, flags) : "";
1365
+ throw new HttpError(`stream → HTTP ${res.status}${hint}`, res.status, {});
1366
+ }
1367
+ const reader = res.body.getReader();
1368
+ const decoder = new TextDecoder();
1369
+ let buffer = "";
1370
+ let last = null;
1371
+ for (;;) {
1372
+ let chunk;
1373
+ try {
1374
+ chunk = await reader.read();
1375
+ } catch (err) {
1376
+ if (ctrl.signal.aborted) throw ctrl.signal.reason;
1377
+ throw err;
1378
+ }
1379
+ if (chunk.done) break;
1380
+ arm();
1381
+ buffer += decoder.decode(chunk.value, { stream: true });
1382
+ let cut;
1383
+ while ((cut = buffer.indexOf("\n\n")) !== -1) {
1384
+ const frame = buffer.slice(0, cut);
1385
+ buffer = buffer.slice(cut + 2);
1386
+ const event = frame.match(/^event: (.*)$/m)?.[1];
1387
+ const data = frame.match(/^data: (.*)$/m)?.[1];
1388
+ if (!event || !data) continue;
1389
+ if (event === "run") {
1390
+ try {
1391
+ last = JSON.parse(data);
1392
+ printNew(last, seen);
1393
+ } catch {
1394
+ // a partial frame; the next one supersedes it
1395
+ }
1396
+ } else if (event === "done") {
1397
+ try {
1398
+ await reader.cancel();
1399
+ } catch {
1400
+ // the server ended it first
1401
+ }
1402
+ return last;
1403
+ }
1404
+ }
1405
+ }
1406
+ return last;
1407
+ } finally {
1408
+ clearTimeout(timer);
1409
+ }
1410
+ }
1411
+
1131
1412
  /**
1132
1413
  * Follow a run on a platform: the same server-sent events the dashboard's
1133
1414
  * run page reads, printed as they land. Resolves with the finished run.
1415
+ *
1416
+ * A stream that goes quiet is reconnected, up to three times, as long as
1417
+ * the run is still live — every `run` frame carries the whole run, so a
1418
+ * fresh connection IS the resume; `seen` keeps the lines already printed
1419
+ * from printing twice. It used to block `invoke --watch` and `logs
1420
+ * --follow` for as long as a stalled tunnel cared to keep the socket.
1134
1421
  */
1135
1422
  async function followRemote(url, flags, ws, runId, seen = new Map()) {
1136
- const token = tokenFor(url, flags);
1137
- const res = await fetch(new URL(`/api/workspaces/${ws}/runs/${runId}/stream`, url), {
1138
- headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
1139
- });
1140
- if (!res.ok || !res.body) throw new Error(`stream → HTTP ${res.status}`);
1141
- const reader = res.body.getReader();
1142
- const decoder = new TextDecoder();
1143
- let buffer = "";
1144
- let last = null;
1145
- for (;;) {
1146
- const { value, done } = await reader.read();
1147
- if (done) break;
1148
- buffer += decoder.decode(value, { stream: true });
1149
- let cut;
1150
- while ((cut = buffer.indexOf("\n\n")) !== -1) {
1151
- const frame = buffer.slice(0, cut);
1152
- buffer = buffer.slice(cut + 2);
1153
- const event = frame.match(/^event: (.*)$/m)?.[1];
1154
- const data = frame.match(/^data: (.*)$/m)?.[1];
1155
- if (!event || !data) continue;
1156
- if (event === "run") {
1157
- try {
1158
- last = JSON.parse(data);
1159
- printNew(last, seen);
1160
- } catch {
1161
- // a partial frame; the next one supersedes it
1162
- }
1163
- } else if (event === "done") {
1164
- try {
1165
- await reader.cancel();
1166
- } catch {
1167
- // the server ended it first
1168
- }
1169
- return last;
1423
+ for (let attempt = 1; ; attempt++) {
1424
+ try {
1425
+ return await streamOnce(url, flags, ws, runId, seen);
1426
+ } catch (err) {
1427
+ if (!(err instanceof StreamIdle)) throw err;
1428
+ const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
1429
+ printNew(run, seen);
1430
+ if (!isLive(run)) return run;
1431
+ if (attempt >= 3) {
1432
+ throw new Error(
1433
+ `${err.message} three times while ${runId} is still ${run.status} — \`foldrun logs ${runId} --to ${ws} --follow\` picks it up again, or ${url}/dashboard/${ws}/runs?run=${runId}`,
1434
+ );
1170
1435
  }
1436
+ console.error(` ${c.dim(`… ${err.message}; reconnecting (${attempt}/3)`)}`);
1171
1437
  }
1172
1438
  }
1173
- return last;
1174
1439
  }
1175
1440
 
1176
1441
  /**
@@ -1237,6 +1502,32 @@ async function secretsCmd(positional, flags) {
1237
1502
  return 0;
1238
1503
  }
1239
1504
 
1505
+ if (verb === "status") {
1506
+ // OAuth grants only: which still refresh, which are failing or about to
1507
+ // expire, and whether a no-prompt reconnect exists for each.
1508
+ if (!url) throw new Error("secrets status reads the platform's connection health — sign in first (foldrun login)");
1509
+ const { connections } = await remoteCall(url, flags, "/api/oauth/connections");
1510
+ if (!connections.length) {
1511
+ console.log(`\n ${c.dim("no OAuth connections")}\n`);
1512
+ return 0;
1513
+ }
1514
+ console.log();
1515
+ for (const s of connections) {
1516
+ const where = s.workspace ? `workspace ${s.workspace}` : "account";
1517
+ const state =
1518
+ s.status === "ok" ? c.green(`ok${s.daysLeft !== null ? ` · ${s.daysLeft} days left` : ""}`)
1519
+ : s.status === "expiring" ? c.amber(`expires in ${s.daysLeft} days`)
1520
+ : s.status === "no-client" ? c.dim("ok · no saved client (reconnect will ask for it once)")
1521
+ : c.red(`${s.status}${s.lastError ? ` — ${s.lastError}` : ""}`);
1522
+ console.log(` ${c.bold(s.name)} ${c.dim(where)} ${state}`);
1523
+ if (s.status !== "ok" && s.status !== "no-client") {
1524
+ console.log(` ${c.dim(`reconnect: foldrun connect ${s.name}${s.workspace ? ` --to ${s.workspace}` : ""} or ${s.reconnectUrl}`)}`);
1525
+ }
1526
+ }
1527
+ console.log();
1528
+ return 0;
1529
+ }
1530
+
1240
1531
  if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
1241
1532
 
1242
1533
  if (verb === "set") {
@@ -1387,8 +1678,7 @@ async function remoteLogs(url, positional, flags) {
1387
1678
  console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)} ${c.dim(run.status)}\n`);
1388
1679
  const seen = new Map();
1389
1680
  printNew(run, seen);
1390
- const live = run.status === "queued" || run.status === "running" || run.status === "awaiting-approval";
1391
- if (flags.follow === true && live) run = (await followRemote(url, flags, ws, runId, seen)) ?? run;
1681
+ if (flags.follow === true && isLive(run)) run = (await followRemote(url, flags, ws, runId, seen)) ?? run;
1392
1682
  return finishLine(run);
1393
1683
  }
1394
1684
 
@@ -1409,18 +1699,59 @@ async function invoke(target, flags) {
1409
1699
  const ws = flags.to;
1410
1700
  if (!ws) throw new Error("which workspace is it in? pass --to <workspace>");
1411
1701
 
1412
- const wait = flags.wait === true ? "?wait=true" : "";
1702
+ // --wait asks in short pieces, not one long request: whatever sits in
1703
+ // front of the platform cuts a request that stays silent too long
1704
+ // (Cloudflare at 100 s, an ALB at 60 s), and a flow can run for many
1705
+ // minutes. The server answers 202 { timedOut: true } when a piece runs out
1706
+ // and the same question is asked again of the run itself. The 524 that
1707
+ // used to land here after 100 s while the run carried on was the trigger.
1708
+ const WAIT_PIECE_S = 25;
1709
+ const wait = flags.wait === true ? `?wait=true&timeout=${WAIT_PIECE_S}` : "";
1710
+ // A waited run that FAILS is answered with 500 and a full record (see
1711
+ // server/wait.ts). That is not an error in the call, it is the answer to
1712
+ // it, so it is reported as a failed run rather than thrown as a transport
1713
+ // fault — the difference between "your flow failed" and "the platform is
1714
+ // down", which is the first thing anyone reading this needs to know.
1715
+ const reportFailedRun = (body, ws) => {
1716
+ if (body?.result) console.log(`\n${body.result}\n`);
1717
+ for (const st of body?.steps ?? []) {
1718
+ const mark = st.status === "completed" ? c.green("✓") : st.status === "skipped" ? c.dim("–") : c.red("✗");
1719
+ console.log(` ${mark} ${c.dim(st.agent ?? "?")} ${st.status}${st.skipReason ? c.dim(` (${st.skipReason})`) : ""}`);
1720
+ }
1721
+ console.log(
1722
+ `\n ${c.red("✗")} ${body?.status ?? "failed"}${body?.costUsd != null ? ` · $${Number(body.costUsd).toFixed(4)}` : ""}` +
1723
+ `${body?.runId ? c.dim(` — foldrun logs ${body.runId} --to ${ws}`) : ""}\n`,
1724
+ );
1725
+ return 1;
1726
+ };
1413
1727
  // --from N starts at step N of the flow as its file numbers them; the
1414
1728
  // earlier steps are recorded as skipped. Mutually exclusive with --task
1415
1729
  // server-side (the task goes to step 1, which --from skips).
1416
1730
  const from = flags.from !== undefined ? Number(flags.from) : undefined;
1417
- const body = await remoteCall(url, flags, `/api/workspaces/${ws}/flows/${target}/run${wait}`, {
1418
- method: "POST",
1419
- body: JSON.stringify({
1420
- task: typeof flags.task === "string" ? flags.task : "",
1421
- ...(from !== undefined ? { from } : {}),
1422
- }),
1423
- });
1731
+ let body;
1732
+ try {
1733
+ body = await remoteCall(url, flags, `/api/workspaces/${ws}/flows/${target}/run${wait}`, {
1734
+ method: "POST",
1735
+ body: JSON.stringify({
1736
+ task: typeof flags.task === "string" ? flags.task : "",
1737
+ ...(from !== undefined ? { from } : {}),
1738
+ // --test: the platform marks the run a test run — sends refused or
1739
+ // sunk at the proxy, send-capable secrets withheld, state/ kept.
1740
+ ...(flags.test === true ? { test: true } : {}),
1741
+ }),
1742
+ });
1743
+ } catch (err) {
1744
+ if (flags.wait === true && err?.body?.runId && err.body.status) return reportFailedRun(err.body, ws);
1745
+ throw err;
1746
+ }
1747
+ while (flags.wait === true && body?.timedOut === true && body.runId) {
1748
+ try {
1749
+ body = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${body.runId}${wait}`);
1750
+ } catch (err) {
1751
+ if (err?.body?.runId && err.body.status) return reportFailedRun(err.body, ws);
1752
+ throw err;
1753
+ }
1754
+ }
1424
1755
 
1425
1756
  if (flags.watch === true && body.runId) {
1426
1757
  // Queued, then followed: the trace lands here line by line, the way the
@@ -1434,14 +1765,16 @@ async function invoke(target, flags) {
1434
1765
  return finishLine(run);
1435
1766
  }
1436
1767
  if (!flags.wait) {
1437
- console.log(`\n ${c.green("✓")} queued ${c.bold(body.runId)} — ${c.dim(`foldrun logs ${body.runId} --to ${ws} --follow, or ${url}/dashboard/${ws}/runs?run=${body.runId}`)}\n`);
1768
+ console.log(`\n ${c.green("✓")} queued ${c.bold(body.runId)}${body.test ? ` ${c.amber("TEST")}` : ""} — ${c.dim(`foldrun logs ${body.runId} --to ${ws} --follow, or ${url}/dashboard/${ws}/runs?run=${body.runId}`)}\n`);
1438
1769
  return 0;
1439
1770
  }
1440
1771
  const run = body.run ?? body;
1441
- const ok = (run.status ?? body.status) === "completed";
1772
+ const status = run.status ?? body.status ?? "finished";
1773
+ const ok = status === "completed";
1442
1774
  if (body.result) console.log(`\n${body.result}\n`);
1443
- console.log(` ${ok ? c.green("✓") : c.red("✗")} ${run.status ?? body.status ?? "finished"}${body.costUsd != null ? ` · $${Number(body.costUsd).toFixed(4)}` : ""}\n`);
1444
- return ok ? 0 : 1;
1775
+ const mark = ok ? c.green("✓") : status === "awaiting-approval" ? c.amber("⏸") : c.red("✗");
1776
+ console.log(` ${mark} ${status}${body.costUsd != null ? ` · $${Number(body.costUsd).toFixed(4)}` : ""}${status === "awaiting-approval" ? c.dim(` — ${url}/dashboard/${ws}/runs?run=${body.runId}`) : ""}\n`);
1777
+ return ok ? 0 : status === "awaiting-approval" ? 2 : 1;
1445
1778
  }
1446
1779
 
1447
1780
  // ---------------------------------------------------------------- connect
@@ -1470,7 +1803,7 @@ const CONNECT_PORT = 8642;
1470
1803
  */
1471
1804
  async function connect(positional, flags) {
1472
1805
  const name = positional[0];
1473
- if (!name) throw new Error("usage: foldrun connect NAME --provider <google|github|microsoft|linkedin> [--workspace <name>]");
1806
+ if (!name) throw new Error("usage: foldrun connect NAME [--provider <google|github|microsoft|linkedin>] [--workspace <name>] [--new-client]");
1474
1807
  if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
1475
1808
  const { OAUTH_PRESETS } = await core();
1476
1809
 
@@ -1481,10 +1814,6 @@ async function connect(positional, flags) {
1481
1814
  }
1482
1815
  const authorizeUrl = flags["authorize-url"] ?? preset?.authorize_url;
1483
1816
  const tokenUrl = flags["token-url"] ?? preset?.token_url;
1484
- if (!authorizeUrl || !tokenUrl) throw new Error("--provider, or both --authorize-url and --token-url");
1485
- if (!/^https:\/\//.test(tokenUrl) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(tokenUrl)) {
1486
- throw new Error("token URL must be https — a refresh token over http is a leaked one");
1487
- }
1488
1817
 
1489
1818
  // Where the result goes: the platform when one is named or signed in,
1490
1819
  // this machine's vault otherwise. Decided before the browser opens, so a
@@ -1493,6 +1822,49 @@ async function connect(positional, flags) {
1493
1822
  const token = url ? tokenFor(url, flags) : null;
1494
1823
  const workspaceName = flags.to ?? (flags.workspace ? path.basename(path.resolve(flags.workspace)) : undefined);
1495
1824
 
1825
+ // Reconnecting: the platform already holds the client this secret (or this
1826
+ // provider) was connected with, so run its consent flow — no client id, no
1827
+ // secret, nothing typed. The redirect is the platform's own callback, which
1828
+ // rewrites the same secret at the same scope; we wait for it to land.
1829
+ if (url && !flags["client-id"] && !env("OAUTH_CLIENT_ID") && flags["new-client"] !== true) {
1830
+ let started = null;
1831
+ try {
1832
+ started = await remoteCall(url, flags, "/api/oauth/reconnect", {
1833
+ method: "POST",
1834
+ body: JSON.stringify({ secret: name, workspace: workspaceName, provider: providerName }),
1835
+ });
1836
+ } catch (err) {
1837
+ if (err.status !== 404) throw err;
1838
+ }
1839
+ if (started?.url) {
1840
+ const before = (await remoteCall(url, flags, "/api/oauth/connections")).connections
1841
+ .find((s) => s.name === name && (s.workspace ?? undefined) === workspaceName)?.connectedAt ?? null;
1842
+ console.log(`\n Reusing the saved ${c.bold(started.client)} client — nothing to type.`);
1843
+ console.log(` ${c.dim(`consent returns to ${started.redirectUri} (it must be registered on the app)`)}`);
1844
+ const opened = flags["no-browser"] === true ? false : await openInBrowser(started.url);
1845
+ console.log(opened
1846
+ ? ` Opened your browser. Approve as the account that owns the ${providerName ?? started.client} resource.\n`
1847
+ : ` Open this address in your browser:\n\n ${started.url}\n`);
1848
+ console.log(` ${c.dim("Waiting for the platform to store it…")}`);
1849
+ const deadline = Date.now() + 10 * 60 * 1000;
1850
+ while (Date.now() < deadline) {
1851
+ await new Promise((r) => setTimeout(r, 3000));
1852
+ const row = (await remoteCall(url, flags, "/api/oauth/connections")).connections
1853
+ .find((s) => s.name === name && (s.workspace ?? undefined) === workspaceName);
1854
+ if (row?.connectedAt && row.connectedAt !== before) {
1855
+ console.log(`\n ${c.green("✓")} ${name} reconnected on ${url}${workspaceName ? ` · ${workspaceName}` : " · account"}\n`);
1856
+ return 0;
1857
+ }
1858
+ }
1859
+ throw new Error("no consent arrived within 10 minutes — run it again, or pass --new-client to enter a client id and secret");
1860
+ }
1861
+ }
1862
+
1863
+ if (!authorizeUrl || !tokenUrl) throw new Error("--provider, or both --authorize-url and --token-url");
1864
+ if (!/^https:\/\//.test(tokenUrl) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(tokenUrl)) {
1865
+ throw new Error("token URL must be https — a refresh token over http is a leaked one");
1866
+ }
1867
+
1496
1868
  const clientId = flags["client-id"] ?? env("OAUTH_CLIENT_ID") ?? (await promptVisible(" client_id: "));
1497
1869
  const clientSecret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(" client_secret: "));
1498
1870
  if (!clientId || !clientSecret) throw new Error("client_id and client_secret are required");
@@ -1537,7 +1909,7 @@ async function connect(positional, flags) {
1537
1909
  server.close();
1538
1910
  resolve(u.searchParams.get("code"));
1539
1911
  });
1540
- server.on("error", (e) => reject(e.code === "EADDRINUSE"
1912
+ server.on("error", (/** @type {any} */ e) => reject(e.code === "EADDRINUSE"
1541
1913
  ? new Error(`port ${port} is in use — pass --port <n> and register http://localhost:<n>/callback on the app`)
1542
1914
  : e));
1543
1915
  server.listen(port, "127.0.0.1", async () => {
@@ -1555,14 +1927,15 @@ async function connect(positional, flags) {
1555
1927
  headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1556
1928
  body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret }).toString(),
1557
1929
  });
1558
- const payload = await exchange.json().catch(() => ({}));
1930
+ const payload = /** @type {any} */ (await exchange.json().catch(() => ({})));
1559
1931
  if (!exchange.ok || (!payload.refresh_token && !payload.access_token)) {
1560
1932
  throw new Error(`token exchange failed (${exchange.status}): ${payload.error_description ?? payload.error ?? "no token in the reply"}`);
1561
1933
  }
1562
1934
 
1563
1935
  // Store: the refresh recipe when there is one, the bare token otherwise.
1936
+ /** @type {any} */
1564
1937
  const body = payload.refresh_token
1565
- ? { name, oauth2: { token_url: tokenUrl, client_id: clientId, client_secret: clientSecret, refresh_token: payload.refresh_token }, workspace: workspaceName }
1938
+ ? { name, oauth2: { token_url: tokenUrl, client_id: clientId, client_secret: clientSecret, refresh_token: payload.refresh_token }, workspace: workspaceName, scopes, ...(payload.refresh_token_expires_in ? { refresh_token_expires_in: payload.refresh_token_expires_in } : {}) }
1566
1939
  : { name, value: payload.access_token, workspace: workspaceName };
1567
1940
  if (url) {
1568
1941
  // The same PUT `secrets set` makes. A store that fails here has spent the
@@ -1625,16 +1998,17 @@ async function login(flags) {
1625
1998
  if (typeof flags.token === "string") {
1626
1999
  // Verify before storing: a wrong key stored is a wrong key on every
1627
2000
  // later command, each failing one step further from the cause.
1628
- const me = await remoteCall(url, { token: flags.token }, "/api/me");
2001
+ const me = await remoteCall(url, { token: flags.token, quiet: true }, "/api/me");
1629
2002
  const name = saveCredential(url, { token: flags.token, email: me.actor.email ?? null, account: me.account, role: me.role }, { name: typeof flags.profile === "string" ? flags.profile : undefined });
1630
2003
  console.log(`\n ${c.green("✓")} signed in to ${c.bold(url)} as ${me.actor.email ?? me.actor.label ?? "an API key"} ${c.dim(`(${me.account}, ${me.role})`)}`);
1631
2004
  console.log(` ${c.dim(`stored as the account "${name}" — \`foldrun accounts\` lists them, \`foldrun use ${name}\` switches`)}\n`);
1632
2005
  return 0;
1633
2006
  }
1634
2007
 
2008
+ /** @type {any} */
1635
2009
  let start;
1636
2010
  try {
1637
- const res = await fetch(new URL("/api/cli/login", url), {
2011
+ const res = await remoteFetch(url, "/api/cli/login", {
1638
2012
  method: "POST",
1639
2013
  headers: { "content-type": "application/json" },
1640
2014
  body: JSON.stringify({ hostname: os.hostname() }),
@@ -1654,9 +2028,12 @@ async function login(flags) {
1654
2028
  const deadline = Date.parse(start.expiresAt) || Date.now() + 10 * 60 * 1000;
1655
2029
  while (Date.now() < deadline) {
1656
2030
  await sleep(interval);
2031
+ /** @type {any} */
1657
2032
  let poll;
1658
2033
  try {
1659
- const res = await fetch(new URL(`/api/cli/login/${start.id}`, url));
2034
+ // Through the shared client: a poll that hangs used to hang the
2035
+ // sign-in; now it is cut at FOLDRUN_TIMEOUT and asked again.
2036
+ const res = await remoteFetch(url, `/api/cli/login/${start.id}`);
1660
2037
  poll = await res.json().catch(() => ({}));
1661
2038
  } catch {
1662
2039
  continue; // a blip; the next poll asks again
@@ -1697,9 +2074,9 @@ async function logout(flags) {
1697
2074
  let revoked = false;
1698
2075
  if (entry.minted) {
1699
2076
  try {
1700
- const me = await remoteCall(url, { token: entry.token }, "/api/me");
2077
+ const me = await remoteCall(url, { token: entry.token, quiet: true }, "/api/me");
1701
2078
  if (me.actor?.kind === "key" && me.actor.id) {
1702
- await remoteCall(url, { token: entry.token }, "/api/keys", { method: "DELETE", body: JSON.stringify({ id: me.actor.id }) });
2079
+ await remoteCall(url, { token: entry.token, quiet: true }, "/api/keys", { method: "DELETE", body: JSON.stringify({ id: me.actor.id }) });
1703
2080
  revoked = true;
1704
2081
  }
1705
2082
  } catch {
@@ -1733,6 +2110,102 @@ async function whoami(flags) {
1733
2110
  return 0;
1734
2111
  }
1735
2112
 
2113
+ /**
2114
+ * `foldrun doctor [--url]` — one line per thing that can be wrong between
2115
+ * this terminal and a platform, ✓ or ✗, in the order a person would check
2116
+ * them by hand: node, this CLI, its runtime, the environment, the account
2117
+ * a command would act as, the name, and a timed request through the same
2118
+ * client every other command uses. Exit 1 if any line is ✗.
2119
+ *
2120
+ * It exists because "fetch failed" was the whole diagnosis, and the fix
2121
+ * was in a different place each time: a tunnel, a stale profile, a proxy
2122
+ * variable, a box that was down.
2123
+ */
2124
+ async function doctor(flags) {
2125
+ const results = [];
2126
+ const line = (ok, label, detail) => {
2127
+ results.push(ok);
2128
+ console.log(` ${ok ? c.green("✓") : c.red("✗")} ${label.padEnd(9)} ${detail}`);
2129
+ };
2130
+ console.log();
2131
+
2132
+ const major = Number(process.versions.node.split(".")[0]);
2133
+ line(major >= 22, "node", `${process.version}${major >= 22 ? "" : " — foldrun needs 22 or newer"} ${c.dim(process.execPath)}`);
2134
+ line(true, "cli", `foldrun ${CLI_VERSION} ${c.dim(fileURLToPath(new URL("..", import.meta.url)))}`);
2135
+ try {
2136
+ const root = coreRoot();
2137
+ const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
2138
+ line(true, "core", `@foldrun/core ${version} ${c.dim(root)}`);
2139
+ } catch (err) {
2140
+ line(false, "core", `@foldrun/core cannot be resolved — ${explain(err)}`);
2141
+ }
2142
+ // Set or unset, never the value: two of these are a key and a proxy URL
2143
+ // that may carry one.
2144
+ line(true, "env", ["FOLDRUN_URL", "FOLDRUN_TOKEN", "FOLDRUN_TIMEOUT", "HTTPS_PROXY"].map((v) => (env(v) ? `${v} set` : c.dim(`${v} unset`))).join(" · "));
2145
+
2146
+ let url;
2147
+ let token;
2148
+ try {
2149
+ url = remoteUrl(flags);
2150
+ if (typeof flags.token === "string" || env("FOLDRUN_TOKEN")) {
2151
+ token = flags.token ?? env("FOLDRUN_TOKEN");
2152
+ line(true, "account", `${typeof flags.token === "string" ? "--token" : "FOLDRUN_TOKEN"} ${token.slice(0, 8)}… ${c.dim(url ?? "no platform")}`);
2153
+ } else {
2154
+ const p = chosenProfile(flags);
2155
+ if (p) {
2156
+ token = p.token;
2157
+ line(true, "account", `${p.name} ${p.account} · ${p.role ?? "?"} · ${p.email ?? "api key"} · key ${String(p.token).slice(0, 8)}… ${c.dim(p.url)}`);
2158
+ } else {
2159
+ line(false, "account", url ? `no account stored for ${url} — \`foldrun login --url ${url}\`, or --token` : "not signed in — `foldrun login`, or --url with --token");
2160
+ }
2161
+ }
2162
+ } catch (err) {
2163
+ line(false, "account", explain(err));
2164
+ }
2165
+
2166
+ if (!url) {
2167
+ line(false, "dns", "no platform — pass --url, or sign in");
2168
+ line(false, "healthz", "no platform — pass --url, or sign in");
2169
+ } else {
2170
+ const host = new URL(url).hostname;
2171
+ if (net.isIP(host)) line(true, "dns", `${host} is an address, nothing to resolve`);
2172
+ else {
2173
+ const [a, aaaa] = await Promise.allSettled([dns.promises.resolve4(host), dns.promises.resolve6(host)]);
2174
+ const list = (r) => (r.status === "fulfilled" ? r.value.join(", ") : "none");
2175
+ if (a.status === "fulfilled" || aaaa.status === "fulfilled") line(true, "dns", `${host} → A ${list(a)} · AAAA ${list(aaaa)}`);
2176
+ else {
2177
+ // Not in DNS; /etc/hosts still counts, and is where a dev box lives.
2178
+ try {
2179
+ const found = await dns.promises.lookup(host, { all: true });
2180
+ line(true, "dns", `${host} → ${found.map((f) => f.address).join(", ")} ${c.dim("(hosts file, not DNS)")}`);
2181
+ } catch (err) {
2182
+ line(false, "dns", `${host} — ${explain(a.status === "rejected" ? a.reason : err)}`);
2183
+ }
2184
+ }
2185
+ }
2186
+ const t0 = performance.now();
2187
+ const took = () => `${Math.round(performance.now() - t0)} ms`;
2188
+ try {
2189
+ const res = await remoteFetch(url, "/api/healthz", {}, { token });
2190
+ const text = await res.text();
2191
+ let body = {};
2192
+ try {
2193
+ body = JSON.parse(text);
2194
+ } catch {
2195
+ // not JSON; the first line is shown below
2196
+ }
2197
+ const fields = ["version", "role"].filter((k) => body[k] != null).map((k) => `${k} ${body[k]}`).join(" · ");
2198
+ const firstLine = text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
2199
+ line(res.ok, "healthz", `GET /api/healthz ${res.status} in ${took()}${fields ? ` · ${fields}` : ""}${res.ok ? "" : ` — ${(res.headers.get("content-type") ?? "?").split(";")[0]}: ${firstLine.slice(0, 120)}`}`);
2200
+ } catch (err) {
2201
+ line(false, "healthz", `${explain(err)} ${c.dim(`(${took()})`)}`);
2202
+ }
2203
+ }
2204
+
2205
+ console.log();
2206
+ return results.every(Boolean) ? 0 : 1;
2207
+ }
2208
+
1736
2209
  /**
1737
2210
  * `foldrun keys ls|create|revoke` — the account's API keys, from the terminal.
1738
2211
  *
@@ -1902,6 +2375,8 @@ export async function run(command, positional, flags, workspace) {
1902
2375
  return logout(flags);
1903
2376
  case "whoami":
1904
2377
  return whoami(flags);
2378
+ case "doctor":
2379
+ return doctor(flags);
1905
2380
  case "keys":
1906
2381
  return keysCmd(positional, flags);
1907
2382
  case "accounts":
@@ -99,6 +99,9 @@ function write(current, profiles) {
99
99
  * Remember a signed-in account. A second login to the same URL for the same
100
100
  * ACCOUNT replaces that profile rather than making a near-duplicate; a
101
101
  * different account on the same URL is a new one.
102
+ * @param {string} url
103
+ * @param {Record<string, any>} entry
104
+ * @param {{ makeDefault?: boolean, name?: string }} [opts]
102
105
  */
103
106
  export function saveCredential(url, entry, { makeDefault = true, name } = {}) {
104
107
  const key = normaliseUrl(url);