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 +1 -0
- package/bin/foldrun.mjs +10 -4
- package/package.json +10 -4
- package/src/commands.mjs +571 -96
- package/src/credentials.mjs +3 -0
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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: (
|
|
12
|
-
bold: (
|
|
13
|
-
green: (
|
|
14
|
-
red: (
|
|
15
|
-
yellow: (
|
|
16
|
-
amber: (
|
|
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
|
-
.
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
/**
|
|
1068
|
-
*
|
|
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
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
|
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
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
body
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
|
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
|
-
|
|
1444
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
package/src/credentials.mjs
CHANGED
|
@@ -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);
|