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/README.md +1 -0
- package/bin/foldrun.mjs +18 -4
- package/package.json +10 -4
- package/src/commands.mjs +731 -116
- package/src/credentials.mjs +137 -30
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) {
|
|
@@ -689,18 +697,16 @@ function bundleDirs(workspace, kind) {
|
|
|
689
697
|
|
|
690
698
|
// ---------------------------------------------------------------- run
|
|
691
699
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
body
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
|
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
|
-
|
|
1426
|
-
|
|
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})`)}
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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(
|
|
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)}
|
|
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
|
|
1709
|
-
|
|
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:
|