foldrun 0.4.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/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 } 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) {
@@ -689,18 +697,16 @@ function bundleDirs(workspace, kind) {
689
697
 
690
698
  // ---------------------------------------------------------------- run
691
699
 
692
- /**
693
- * Credentials come from ANTHROPIC_API_KEY or, more often on a laptop, from an
694
- * existing Claude Code login. Requiring the key outright would lock out anyone
695
- * already authenticated — a bad first run for the most likely user.
696
- */
700
+ // An API key, and only that. A claude.ai login on this machine is not a
701
+ // credential foldrun may run on: Anthropic does not allow products built on
702
+ // its Agent SDK to use claude.ai subscriptions, so the CLI neither looks
703
+ // for one nor suggests it. An agent that names its own `provider:` needs
704
+ // no Anthropic key at all — that is checked where the agent is read.
697
705
  function assertCredentials() {
698
706
  if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return;
699
- const home = process.env.HOME ?? "";
700
- if (home && fs.existsSync(path.join(home, ".claude"))) return; // Claude Code login
701
707
  throw new Error(
702
- "no credentials — set ANTHROPIC_API_KEY, or log in with Claude Code.\n" +
703
- " `foldrun check` works without either.",
708
+ "no credentials — set ANTHROPIC_API_KEY (an API key from console.anthropic.com),\n" +
709
+ " or give the agent its own `provider:`. `foldrun check` works without either.",
704
710
  );
705
711
  }
706
712
 
@@ -713,6 +719,11 @@ async function runTarget(target, flags) {
713
719
  const name = target.replace(/^flow:/, "");
714
720
  const asFlow = target.startsWith("flow:") || !listAgents(T, P).some((a) => a.name === name);
715
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 } : {};
716
727
  let run;
717
728
  if (asFlow) {
718
729
  const flow = loadFlow(T, P, name);
@@ -722,12 +733,12 @@ async function runTarget(target, flags) {
722
733
  i === 0 ? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${flags.task}\n</run_task>` } : s,
723
734
  )
724
735
  : flow.steps;
725
- run = startFlowRun(T, P, steps, flow.name, flow.model);
736
+ run = startFlowRun(T, P, steps, flow.name, flow.model, [], null, null, opts);
726
737
  } else {
727
- 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);
728
739
  }
729
740
 
730
- 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`);
731
742
  const seen = new Map();
732
743
  for (;;) {
733
744
  const current = readRun(T, P, run.id);
@@ -898,7 +909,7 @@ async function deployOverHttp(url, workspace, files, flags) {
898
909
  try {
899
910
  res = await fetch(endpoint, {
900
911
  method: "POST",
901
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
912
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": USER_AGENT },
902
913
  body: JSON.stringify({
903
914
  files,
904
915
  commit: flags.commit ?? null,
@@ -910,7 +921,7 @@ async function deployOverHttp(url, workspace, files, flags) {
910
921
  throw new Error(`could not reach ${url} — ${err instanceof Error ? err.message : String(err)}`);
911
922
  }
912
923
 
913
- const body = await res.json().catch(() => ({}));
924
+ const body = /** @type {any} */ (await res.json().catch(() => ({})));
914
925
  if (res.status === 401) throw new Error(`${body.error ?? "unauthorized"} — run \`foldrun login\` again, or check FOLDRUN_TOKEN`);
915
926
  // 422 is a refusal the caller has to read, not a transport failure: the
916
927
  // issues are in the body and reported like any other refused deploy.
@@ -943,6 +954,7 @@ async function deploy(source, flags) {
943
954
  // which is what a laptop or a CI job does. Without one, or with --local,
944
955
  // it is written straight into the installation on this machine.
945
956
  const url = flags.local === true ? undefined : remoteUrl(flags);
957
+ /** @type {any} */
946
958
  const plan = url
947
959
  ? await deployOverHttp(url, workspace, files, flags)
948
960
  : flags["dry-run"]
@@ -1066,28 +1078,223 @@ function promptVisible(question) {
1066
1078
  // should not shadow the credentials file with nothing.
1067
1079
  const env = (name) => process.env[name] || undefined;
1068
1080
 
1069
- const remoteUrl = (flags) => flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? undefined;
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
+ */
1092
+ function chosenProfile(flags) {
1093
+ if (typeof flags.profile === "string") {
1094
+ const p = profileByName(flags.profile);
1095
+ if (!p) {
1096
+ const names = listProfiles().map((x) => x.name);
1097
+ throw new Error(`no profile called "${flags.profile}"${names.length ? ` — try ${names.join(", ")}` : " — sign in with `foldrun login`"}`);
1098
+ }
1099
+ return p;
1100
+ }
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";
1147
+ }
1148
+
1149
+ const remoteUrl = (flags) =>
1150
+ (typeof flags.profile === "string" ? chosenProfile(flags)?.url : undefined) ??
1151
+ flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? undefined;
1070
1152
 
1071
1153
  const NOT_SIGNED_IN = "not signed in — run `foldrun login`, or set FOLDRUN_TOKEN / pass --token";
1072
1154
 
1073
1155
  function tokenFor(url, flags) {
1074
- const token = flags.token ?? env("FOLDRUN_TOKEN") ?? credentialFor(url)?.token;
1156
+ // --token and the environment win, always: a CI job with a key in the
1157
+ // environment must never read a file, whatever is stored in it.
1158
+ const token = flags.token ?? env("FOLDRUN_TOKEN") ?? chosenProfile(flags)?.token;
1075
1159
  if (!token) throw new Error(NOT_SIGNED_IN);
1160
+ announce(url, flags);
1076
1161
  return token;
1077
1162
  }
1078
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
+ */
1079
1275
  async function remoteCall(url, flags, apiPath, init = {}) {
1080
1276
  const token = tokenFor(url, flags);
1081
- const res = await fetch(new URL(apiPath, url), {
1082
- ...init,
1083
- headers: {
1084
- authorization: `Bearer ${token}`,
1085
- "content-type": "application/json",
1086
- ...(init.headers ?? {}),
1087
- },
1088
- });
1089
- const body = await res.json().catch(() => ({}));
1090
- 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
+ }
1091
1298
  return body;
1092
1299
  }
1093
1300
 
@@ -1110,49 +1317,125 @@ function finishLine(run) {
1110
1317
  return ok ? 0 : run.status === "awaiting-approval" ? 2 : 1;
1111
1318
  }
1112
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
+
1113
1412
  /**
1114
1413
  * Follow a run on a platform: the same server-sent events the dashboard's
1115
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.
1116
1421
  */
1117
1422
  async function followRemote(url, flags, ws, runId, seen = new Map()) {
1118
- const token = tokenFor(url, flags);
1119
- const res = await fetch(new URL(`/api/workspaces/${ws}/runs/${runId}/stream`, url), {
1120
- headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
1121
- });
1122
- if (!res.ok || !res.body) throw new Error(`stream → HTTP ${res.status}`);
1123
- const reader = res.body.getReader();
1124
- const decoder = new TextDecoder();
1125
- let buffer = "";
1126
- let last = null;
1127
- for (;;) {
1128
- const { value, done } = await reader.read();
1129
- if (done) break;
1130
- buffer += decoder.decode(value, { stream: true });
1131
- let cut;
1132
- while ((cut = buffer.indexOf("\n\n")) !== -1) {
1133
- const frame = buffer.slice(0, cut);
1134
- buffer = buffer.slice(cut + 2);
1135
- const event = frame.match(/^event: (.*)$/m)?.[1];
1136
- const data = frame.match(/^data: (.*)$/m)?.[1];
1137
- if (!event || !data) continue;
1138
- if (event === "run") {
1139
- try {
1140
- last = JSON.parse(data);
1141
- printNew(last, seen);
1142
- } catch {
1143
- // a partial frame; the next one supersedes it
1144
- }
1145
- } else if (event === "done") {
1146
- try {
1147
- await reader.cancel();
1148
- } catch {
1149
- // the server ended it first
1150
- }
1151
- 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
+ );
1152
1435
  }
1436
+ console.error(` ${c.dim(`… ${err.message}; reconnecting (${attempt}/3)`)}`);
1153
1437
  }
1154
1438
  }
1155
- return last;
1156
1439
  }
1157
1440
 
1158
1441
  /**
@@ -1219,6 +1502,32 @@ async function secretsCmd(positional, flags) {
1219
1502
  return 0;
1220
1503
  }
1221
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
+
1222
1531
  if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
1223
1532
 
1224
1533
  if (verb === "set") {
@@ -1369,8 +1678,7 @@ async function remoteLogs(url, positional, flags) {
1369
1678
  console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)} ${c.dim(run.status)}\n`);
1370
1679
  const seen = new Map();
1371
1680
  printNew(run, seen);
1372
- const live = run.status === "queued" || run.status === "running" || run.status === "awaiting-approval";
1373
- 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;
1374
1682
  return finishLine(run);
1375
1683
  }
1376
1684
 
@@ -1391,18 +1699,59 @@ async function invoke(target, flags) {
1391
1699
  const ws = flags.to;
1392
1700
  if (!ws) throw new Error("which workspace is it in? pass --to <workspace>");
1393
1701
 
1394
- 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
+ };
1395
1727
  // --from N starts at step N of the flow as its file numbers them; the
1396
1728
  // earlier steps are recorded as skipped. Mutually exclusive with --task
1397
1729
  // server-side (the task goes to step 1, which --from skips).
1398
1730
  const from = flags.from !== undefined ? Number(flags.from) : undefined;
1399
- const body = await remoteCall(url, flags, `/api/workspaces/${ws}/flows/${target}/run${wait}`, {
1400
- method: "POST",
1401
- body: JSON.stringify({
1402
- task: typeof flags.task === "string" ? flags.task : "",
1403
- ...(from !== undefined ? { from } : {}),
1404
- }),
1405
- });
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
+ }
1406
1755
 
1407
1756
  if (flags.watch === true && body.runId) {
1408
1757
  // Queued, then followed: the trace lands here line by line, the way the
@@ -1416,14 +1765,16 @@ async function invoke(target, flags) {
1416
1765
  return finishLine(run);
1417
1766
  }
1418
1767
  if (!flags.wait) {
1419
- 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`);
1420
1769
  return 0;
1421
1770
  }
1422
1771
  const run = body.run ?? body;
1423
- const ok = (run.status ?? body.status) === "completed";
1772
+ const status = run.status ?? body.status ?? "finished";
1773
+ const ok = status === "completed";
1424
1774
  if (body.result) console.log(`\n${body.result}\n`);
1425
- console.log(` ${ok ? c.green("✓") : c.red("✗")} ${run.status ?? body.status ?? "finished"}${body.costUsd != null ? ` · $${Number(body.costUsd).toFixed(4)}` : ""}\n`);
1426
- 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;
1427
1778
  }
1428
1779
 
1429
1780
  // ---------------------------------------------------------------- connect
@@ -1452,7 +1803,7 @@ const CONNECT_PORT = 8642;
1452
1803
  */
1453
1804
  async function connect(positional, flags) {
1454
1805
  const name = positional[0];
1455
- 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]");
1456
1807
  if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
1457
1808
  const { OAUTH_PRESETS } = await core();
1458
1809
 
@@ -1463,10 +1814,6 @@ async function connect(positional, flags) {
1463
1814
  }
1464
1815
  const authorizeUrl = flags["authorize-url"] ?? preset?.authorize_url;
1465
1816
  const tokenUrl = flags["token-url"] ?? preset?.token_url;
1466
- if (!authorizeUrl || !tokenUrl) throw new Error("--provider, or both --authorize-url and --token-url");
1467
- if (!/^https:\/\//.test(tokenUrl) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(tokenUrl)) {
1468
- throw new Error("token URL must be https — a refresh token over http is a leaked one");
1469
- }
1470
1817
 
1471
1818
  // Where the result goes: the platform when one is named or signed in,
1472
1819
  // this machine's vault otherwise. Decided before the browser opens, so a
@@ -1475,6 +1822,49 @@ async function connect(positional, flags) {
1475
1822
  const token = url ? tokenFor(url, flags) : null;
1476
1823
  const workspaceName = flags.to ?? (flags.workspace ? path.basename(path.resolve(flags.workspace)) : undefined);
1477
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
+
1478
1868
  const clientId = flags["client-id"] ?? env("OAUTH_CLIENT_ID") ?? (await promptVisible(" client_id: "));
1479
1869
  const clientSecret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(" client_secret: "));
1480
1870
  if (!clientId || !clientSecret) throw new Error("client_id and client_secret are required");
@@ -1519,7 +1909,7 @@ async function connect(positional, flags) {
1519
1909
  server.close();
1520
1910
  resolve(u.searchParams.get("code"));
1521
1911
  });
1522
- server.on("error", (e) => reject(e.code === "EADDRINUSE"
1912
+ server.on("error", (/** @type {any} */ e) => reject(e.code === "EADDRINUSE"
1523
1913
  ? new Error(`port ${port} is in use — pass --port <n> and register http://localhost:<n>/callback on the app`)
1524
1914
  : e));
1525
1915
  server.listen(port, "127.0.0.1", async () => {
@@ -1537,14 +1927,15 @@ async function connect(positional, flags) {
1537
1927
  headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1538
1928
  body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret }).toString(),
1539
1929
  });
1540
- const payload = await exchange.json().catch(() => ({}));
1930
+ const payload = /** @type {any} */ (await exchange.json().catch(() => ({})));
1541
1931
  if (!exchange.ok || (!payload.refresh_token && !payload.access_token)) {
1542
1932
  throw new Error(`token exchange failed (${exchange.status}): ${payload.error_description ?? payload.error ?? "no token in the reply"}`);
1543
1933
  }
1544
1934
 
1545
1935
  // Store: the refresh recipe when there is one, the bare token otherwise.
1936
+ /** @type {any} */
1546
1937
  const body = payload.refresh_token
1547
- ? { 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 } : {}) }
1548
1939
  : { name, value: payload.access_token, workspace: workspaceName };
1549
1940
  if (url) {
1550
1941
  // The same PUT `secrets set` makes. A store that fails here has spent the
@@ -1607,15 +1998,17 @@ async function login(flags) {
1607
1998
  if (typeof flags.token === "string") {
1608
1999
  // Verify before storing: a wrong key stored is a wrong key on every
1609
2000
  // later command, each failing one step further from the cause.
1610
- const me = await remoteCall(url, { token: flags.token }, "/api/me");
1611
- saveCredential(url, { token: flags.token, email: me.actor.email ?? null, account: me.account, role: me.role });
1612
- 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})`)}\n`);
2001
+ const me = await remoteCall(url, { token: flags.token, quiet: true }, "/api/me");
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 });
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})`)}`);
2004
+ console.log(` ${c.dim(`stored as the account "${name}" — \`foldrun accounts\` lists them, \`foldrun use ${name}\` switches`)}\n`);
1613
2005
  return 0;
1614
2006
  }
1615
2007
 
2008
+ /** @type {any} */
1616
2009
  let start;
1617
2010
  try {
1618
- const res = await fetch(new URL("/api/cli/login", url), {
2011
+ const res = await remoteFetch(url, "/api/cli/login", {
1619
2012
  method: "POST",
1620
2013
  headers: { "content-type": "application/json" },
1621
2014
  body: JSON.stringify({ hostname: os.hostname() }),
@@ -1635,9 +2028,12 @@ async function login(flags) {
1635
2028
  const deadline = Date.parse(start.expiresAt) || Date.now() + 10 * 60 * 1000;
1636
2029
  while (Date.now() < deadline) {
1637
2030
  await sleep(interval);
2031
+ /** @type {any} */
1638
2032
  let poll;
1639
2033
  try {
1640
- 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}`);
1641
2037
  poll = await res.json().catch(() => ({}));
1642
2038
  } catch {
1643
2039
  continue; // a blip; the next poll asks again
@@ -1649,9 +2045,9 @@ async function login(flags) {
1649
2045
  // `minted`: this key exists because of this login, so logout may end
1650
2046
  // it. A key stored with --token was made elsewhere and may be in use
1651
2047
  // elsewhere; logout only forgets it.
1652
- saveCredential(url, { token: poll.token, email: poll.email, account: poll.account, role: poll.role, minted: true });
2048
+ const name = saveCredential(url, { token: poll.token, email: poll.email, account: poll.account, role: poll.role, minted: true }, { name: typeof flags.profile === "string" ? flags.profile : undefined });
1653
2049
  console.log(`\n ${c.green("✓")} signed in to ${c.bold(url)} as ${poll.email} ${c.dim(`(${poll.account}, ${poll.role})`)}\n`);
1654
- console.log(` ${c.dim("Stored in ~/.foldrun/credentials.json. `foldrun whoami` shows it; `foldrun logout` removes it.")}\n`);
2050
+ console.log(` ${c.dim(`Stored as the account "${name}" in ~/.foldrun/credentials.json. \`foldrun accounts\` lists every account signed in here; \`foldrun use ${name}\` switches.`)}\n`);
1655
2051
  return 0;
1656
2052
  }
1657
2053
  }
@@ -1666,31 +2062,32 @@ async function login(flags) {
1666
2062
  * be in use elsewhere.
1667
2063
  */
1668
2064
  async function logout(flags) {
1669
- const url = remoteUrl(flags);
1670
- if (!url) {
1671
- console.log(`\n ${c.dim("not signed in anywhere")}\n`);
1672
- return 0;
1673
- }
1674
- const entry = credentialFor(url);
2065
+ // One account, not one machine: --profile names which, else the one this
2066
+ // shell is acting as. Signing out of a customer must not sign you out of
2067
+ // the other three.
2068
+ const entry = chosenProfile(flags);
1675
2069
  if (!entry) {
1676
- console.log(`\n ${c.dim(`not signed in to ${url}`)}\n`);
2070
+ console.log(`\n ${c.dim("not signed in anywhere")}\n`);
1677
2071
  return 0;
1678
2072
  }
2073
+ const url = entry.url;
1679
2074
  let revoked = false;
1680
2075
  if (entry.minted) {
1681
2076
  try {
1682
- const me = await remoteCall(url, { token: entry.token }, "/api/me");
2077
+ const me = await remoteCall(url, { token: entry.token, quiet: true }, "/api/me");
1683
2078
  if (me.actor?.kind === "key" && me.actor.id) {
1684
- 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 }) });
1685
2080
  revoked = true;
1686
2081
  }
1687
2082
  } catch {
1688
2083
  // Not allowed, or unreachable. The local copy still goes.
1689
2084
  }
1690
2085
  }
1691
- removeCredential(url);
2086
+ removeCredential(entry.name ?? url);
1692
2087
  const note = revoked ? "" : entry.minted ? " (the key is forgotten here; revoke it on Settings → API keys to be sure)" : " (the key is forgotten here, not revoked — it was not made by `foldrun login`)";
1693
- console.log(`\n ${c.green("✓")} signed out of ${c.bold(url)}${c.dim(note)}\n`);
2088
+ console.log(`\n ${c.green("✓")} signed out of ${c.bold(entry.name ?? url)} ${c.dim(`(${entry.account} · ${url})`)}${c.dim(note)}`);
2089
+ const left = listProfiles();
2090
+ console.log(left.length ? ` ${c.dim(`still signed in as: ${left.map((p) => p.name).join(", ")}`)}\n` : "");
1694
2091
  return 0;
1695
2092
  }
1696
2093
 
@@ -1705,11 +2102,110 @@ async function whoami(flags) {
1705
2102
  console.log(` account ${me.account}${me.owner ? c.dim(` (owner ${me.owner})`) : ""}`);
1706
2103
  console.log(` role ${me.role}`);
1707
2104
  console.log(` workspaces ${me.workspaces === null ? "all" : me.workspaces.join(", ") || "none"}`);
1708
- const source = flags.token ? "--token" : env("FOLDRUN_TOKEN") ? "FOLDRUN_TOKEN" : "~/.foldrun/credentials.json";
1709
- console.log(` ${c.dim(`credential from ${source}`)}\n`);
2105
+ const profile = flags.token || env("FOLDRUN_TOKEN") ? null : chosenProfile(flags);
2106
+ const source = flags.token ? "--token" : env("FOLDRUN_TOKEN") ? "FOLDRUN_TOKEN" : `~/.foldrun/credentials.json as "${profile?.name ?? "?"}"`;
2107
+ console.log(` ${c.dim(`credential from ${source}`)}`);
2108
+ const others = listProfiles().filter((p) => p.name !== profile?.name);
2109
+ console.log(others.length ? ` ${c.dim(`also signed in as: ${others.map((p) => p.name).join(", ")} — \`foldrun accounts\``)}\n` : "\n");
1710
2110
  return 0;
1711
2111
  }
1712
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
+
1713
2209
  /**
1714
2210
  * `foldrun keys ls|create|revoke` — the account's API keys, from the terminal.
1715
2211
  *
@@ -1762,6 +2258,115 @@ async function keysCmd(positional, flags) {
1762
2258
  throw new Error(`keys: unknown verb "${verb}" — ls, create, revoke`);
1763
2259
  }
1764
2260
 
2261
+ /**
2262
+ * `foldrun source` — the files themselves, on the platform, one at a time.
2263
+ *
2264
+ * source ls [dir] the workspace's tree (or one folder of it)
2265
+ * source cat <path> one file, to stdout
2266
+ * source put <path> [--file f] write a file: from --file, else from stdin
2267
+ * (--message "why" goes on the revision)
2268
+ * source mv <from> <to>
2269
+ * source rm <path>
2270
+ *
2271
+ * The same door the dashboard's editor uses, so every write is a revision
2272
+ * with who and why, and the workspace's history shows it. For a whole
2273
+ * tree, `foldrun deploy`; for what agents PRODUCE (storage/), the
2274
+ * dashboard's Storage page — source is what you wrote, storage is what
2275
+ * they wrote.
2276
+ */
2277
+ async function sourceCmd(positional, flags) {
2278
+ const [verb, a, b] = positional;
2279
+ const url = remoteUrl(flags);
2280
+ if (!url) throw new Error("source reads a workspace on a platform — pass --url, or `foldrun login` first");
2281
+ const ws = flags.to;
2282
+ if (!ws) throw new Error("which workspace? pass --to <workspace>");
2283
+ const base = `/api/workspaces/${encodeURIComponent(ws)}/source`;
2284
+
2285
+ if (verb === "ls" || verb === "list" || verb === undefined) {
2286
+ const { files } = await remoteCall(url, flags, base);
2287
+ const prefix = a ? a.replace(/\/+$/, "") + "/" : "";
2288
+ const shown = files.filter((f) => !prefix || f.startsWith(prefix));
2289
+ if (!shown.length) {
2290
+ console.log(`\n ${c.dim(prefix ? `nothing under ${prefix}` : "an empty workspace")}\n`);
2291
+ return 0;
2292
+ }
2293
+ console.log("");
2294
+ for (const f of shown) console.log(` ${f}`);
2295
+ console.log(`\n ${c.dim(`${shown.length} file${shown.length === 1 ? "" : "s"} · ${ws} · ${url}`)}\n`);
2296
+ return 0;
2297
+ }
2298
+ if (verb === "cat" || verb === "read" || verb === "get") {
2299
+ if (!a) throw new Error("which file? foldrun source cat flows/daily.md --to <workspace>");
2300
+ const { content } = await remoteCall(url, flags, `${base}?path=${encodeURIComponent(a)}`);
2301
+ process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
2302
+ return 0;
2303
+ }
2304
+ if (verb === "put" || verb === "write") {
2305
+ if (!a) throw new Error("which file? foldrun source put flows/daily.md --file ./daily.md --to <workspace>");
2306
+ const content = typeof flags.file === "string" ? fs.readFileSync(flags.file, "utf8") : fs.readFileSync(0, "utf8");
2307
+ if (!content.trim()) throw new Error("refusing to write an empty file — `foldrun source rm` is the deliberate way to remove one");
2308
+ const body = { path: a, content, ...(typeof flags.message === "string" ? { message: flags.message } : {}) };
2309
+ await remoteCall(url, flags, base, { method: "PUT", body: JSON.stringify(body) });
2310
+ console.log(`\n ${c.green("✓")} ${ws}/${a} ${c.dim(`${content.length} chars · revision recorded${flags.message ? ` · "${flags.message}"` : ""}`)}\n`);
2311
+ return 0;
2312
+ }
2313
+ if (verb === "mv" || verb === "move") {
2314
+ if (!a || !b) throw new Error("foldrun source mv <from> <to> --to <workspace>");
2315
+ await remoteCall(url, flags, base, { method: "PATCH", body: JSON.stringify({ from: a, to: b }) });
2316
+ console.log(`\n ${c.green("✓")} ${a} → ${b}\n`);
2317
+ return 0;
2318
+ }
2319
+ if (verb === "rm" || verb === "delete") {
2320
+ if (!a) throw new Error("foldrun source rm <path> --to <workspace>");
2321
+ await remoteCall(url, flags, base, { method: "DELETE", body: JSON.stringify({ path: a }) });
2322
+ console.log(`\n ${c.green("✓")} removed ${a}\n`);
2323
+ return 0;
2324
+ }
2325
+ throw new Error(`source: unknown verb "${verb}" — ls, cat, put, mv, rm`);
2326
+ }
2327
+
2328
+ /**
2329
+ * `foldrun accounts` — every account this machine is signed in to, and
2330
+ * which one a bare command talks as. `foldrun use <name>` switches.
2331
+ *
2332
+ * The list is the point: someone looking after four customers on one
2333
+ * platform could see only the last one they signed in as, and had to paste
2334
+ * --token to reach the others.
2335
+ */
2336
+ function accountsCmd(positional) {
2337
+ const profiles = listProfiles();
2338
+ if (!profiles.length) {
2339
+ console.log(`\n ${c.dim("not signed in anywhere — `foldrun login`")}\n`);
2340
+ return 0;
2341
+ }
2342
+ const verb = positional[0];
2343
+ if (verb && verb !== "ls" && verb !== "list") throw new Error(`accounts: unknown verb "${verb}" — it takes none; \`foldrun use <name>\` switches`);
2344
+ const width = Math.max(...profiles.map((p) => p.name.length));
2345
+ console.log("");
2346
+ for (const p of profiles) {
2347
+ const mark = p.current ? c.green("●") : " ";
2348
+ const who = p.email ?? c.dim("api key");
2349
+ console.log(` ${mark} ${c.bold(p.name.padEnd(width))} ${p.account} ${c.dim(`${p.role ?? "?"} · ${who} · ${p.url}`)}`);
2350
+ }
2351
+ console.log(`\n ${c.dim("● is the one a bare command talks as. `foldrun use <name>` switches; --profile <name> is one command.")}\n`);
2352
+ return 0;
2353
+ }
2354
+
2355
+ function useCmd(positional) {
2356
+ const name = positional[0];
2357
+ if (!name) {
2358
+ const names = listProfiles().map((p) => p.name);
2359
+ throw new Error(`which account? ${names.length ? names.join(", ") : "none stored — `foldrun login` first"}`);
2360
+ }
2361
+ const p = useProfile(name);
2362
+ if (!p) {
2363
+ const names = listProfiles().map((x) => x.name);
2364
+ throw new Error(`no account called "${name}"${names.length ? ` — try ${names.join(", ")}` : ""}`);
2365
+ }
2366
+ console.log(`\n ${c.green("✓")} now acting as ${c.bold(p.name)} ${c.dim(`(${p.account}, ${p.role} · ${p.url})`)}\n`);
2367
+ return 0;
2368
+ }
2369
+
1765
2370
  export async function run(command, positional, flags, workspace) {
1766
2371
  switch (command) {
1767
2372
  case "login":
@@ -1770,8 +2375,16 @@ export async function run(command, positional, flags, workspace) {
1770
2375
  return logout(flags);
1771
2376
  case "whoami":
1772
2377
  return whoami(flags);
2378
+ case "doctor":
2379
+ return doctor(flags);
1773
2380
  case "keys":
1774
2381
  return keysCmd(positional, flags);
2382
+ case "accounts":
2383
+ case "profiles":
2384
+ return accountsCmd(positional);
2385
+ case "use":
2386
+ case "switch":
2387
+ return useCmd(positional);
1775
2388
  case "init":
1776
2389
  return init(workspace, flags.from);
1777
2390
  case "check":
@@ -1794,6 +2407,8 @@ export async function run(command, positional, flags, workspace) {
1794
2407
  return logsCmd(positional, flags);
1795
2408
  case "invoke":
1796
2409
  return invoke(positional[0], flags);
2410
+ case "source":
2411
+ return sourceCmd(positional, flags);
1797
2412
  case "open":
1798
2413
  return openCmd(positional, flags);
1799
2414
  default: