foldrun 0.4.0 → 0.5.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/bin/foldrun.mjs +8 -0
- package/package.json +2 -2
- package/src/commands.mjs +167 -27
- package/src/credentials.mjs +134 -30
package/bin/foldrun.mjs
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
// foldrun secrets <verb> set / ls / rm — the vault, from the terminal
|
|
16
16
|
// foldrun deploy [dir] push a workspace into an installation
|
|
17
17
|
// foldrun invoke <flow> start a flow on a running platform
|
|
18
|
+
// foldrun source <verb> ls / cat / put / mv / rm one workspace file on a platform
|
|
18
19
|
// foldrun open [page] the dashboard for this workspace
|
|
19
20
|
// foldrun login sign this machine in from the browser
|
|
20
21
|
// foldrun whoami who the platform thinks this terminal is
|
|
22
|
+
// foldrun accounts every account signed in here; `use <name>` switches
|
|
21
23
|
// foldrun keys <verb> ls / create / revoke — the account's API keys
|
|
22
24
|
//
|
|
23
25
|
// `check` is the one to run in CI: it catches the mistakes that otherwise only
|
|
@@ -49,12 +51,15 @@ const HELP = `foldrun — agents are just folders
|
|
|
49
51
|
foldrun connect NAME OAuth sign-in from the terminal, stored as an auto-refreshing secret
|
|
50
52
|
foldrun deploy [dir] push a workspace into an installation
|
|
51
53
|
foldrun invoke <flow> start a flow on a running platform (--to <workspace>)
|
|
54
|
+
foldrun source <verb> the files on a platform, one at a time: ls, cat <path>, put <path>, mv, rm (--to <workspace>)
|
|
52
55
|
foldrun open [page] the dashboard for this workspace, in the browser
|
|
53
56
|
|
|
54
57
|
Signing in
|
|
55
58
|
foldrun login sign this machine in from the browser (--token <key> to skip it)
|
|
56
59
|
foldrun logout forget this machine's key, and revoke it where allowed
|
|
57
60
|
foldrun whoami who you are on the platform: account, role, workspaces
|
|
61
|
+
foldrun accounts every account signed in on this machine, and which one is active
|
|
62
|
+
foldrun use <name> act as one of them from here on
|
|
58
63
|
foldrun keys ls the account's API keys — also create <label>, revoke <id>
|
|
59
64
|
foldrun --help
|
|
60
65
|
|
|
@@ -64,6 +69,8 @@ Options
|
|
|
64
69
|
--task "<text>" the instruction for a manual run
|
|
65
70
|
--follow logs: keep tailing a live run (with --url: on the platform)
|
|
66
71
|
--value "<text>" secrets set: skip the prompt (careful with shell history)
|
|
72
|
+
--file <path> source put: the local file to send (default: stdin)
|
|
73
|
+
--message "<why>" source put: recorded on the file's revision
|
|
67
74
|
--account secrets: account scope instead of the workspace's
|
|
68
75
|
--wait invoke: hold on and print the result
|
|
69
76
|
--watch invoke: follow the run's trace here as it happens
|
|
@@ -84,6 +91,7 @@ Platform options (deploy, invoke, secrets, logs, keys)
|
|
|
84
91
|
--data <dir> the installation's data directory
|
|
85
92
|
--url <url> a running platform (or FOLDRUN_URL, or where you last signed in)
|
|
86
93
|
--token <key> API key for --url (or FOLDRUN_TOKEN, or the one from foldrun login)
|
|
94
|
+
--profile <name> act as one stored account for this command (see foldrun accounts)
|
|
87
95
|
--local deploy: into the installation on this machine, even when signed in
|
|
88
96
|
--commit <sha> deploy: record which commit this is
|
|
89
97
|
--dry-run deploy: check and report, change nothing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "foldrun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -20,7 +20,7 @@
|
|
|
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",
|
|
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
24
|
"release": "node scripts/release.mjs"
|
|
25
25
|
},
|
|
26
26
|
"repository": {
|
package/src/commands.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { credentialFor, defaultPlatform, saveCredential, removeCredential, readCredentials, normaliseUrl } from "./credentials.mjs";
|
|
8
|
+
import { credentialFor, defaultPlatform, saveCredential, removeCredential, readCredentials, normaliseUrl, profileByName, currentProfile, useProfile, listProfiles } from "./credentials.mjs";
|
|
9
9
|
|
|
10
10
|
const c = {
|
|
11
11
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -689,18 +689,16 @@ function bundleDirs(workspace, kind) {
|
|
|
689
689
|
|
|
690
690
|
// ---------------------------------------------------------------- run
|
|
691
691
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
692
|
+
// An API key, and only that. A claude.ai login on this machine is not a
|
|
693
|
+
// credential foldrun may run on: Anthropic does not allow products built on
|
|
694
|
+
// its Agent SDK to use claude.ai subscriptions, so the CLI neither looks
|
|
695
|
+
// for one nor suggests it. An agent that names its own `provider:` needs
|
|
696
|
+
// no Anthropic key at all — that is checked where the agent is read.
|
|
697
697
|
function assertCredentials() {
|
|
698
698
|
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
699
|
throw new Error(
|
|
702
|
-
"no credentials — set ANTHROPIC_API_KEY
|
|
703
|
-
" `foldrun check` works without either.",
|
|
700
|
+
"no credentials — set ANTHROPIC_API_KEY (an API key from console.anthropic.com),\n" +
|
|
701
|
+
" or give the agent its own `provider:`. `foldrun check` works without either.",
|
|
704
702
|
);
|
|
705
703
|
}
|
|
706
704
|
|
|
@@ -1066,12 +1064,32 @@ function promptVisible(question) {
|
|
|
1066
1064
|
// should not shadow the credentials file with nothing.
|
|
1067
1065
|
const env = (name) => process.env[name] || undefined;
|
|
1068
1066
|
|
|
1069
|
-
|
|
1067
|
+
/** The profile a command acts as: --profile by name, else the one whose URL
|
|
1068
|
+
* was asked for, else the current one. */
|
|
1069
|
+
function chosenProfile(flags) {
|
|
1070
|
+
if (typeof flags.profile === "string") {
|
|
1071
|
+
const p = profileByName(flags.profile);
|
|
1072
|
+
if (!p) {
|
|
1073
|
+
const names = listProfiles().map((x) => x.name);
|
|
1074
|
+
throw new Error(`no profile called "${flags.profile}"${names.length ? ` — try ${names.join(", ")}` : " — sign in with `foldrun login`"}`);
|
|
1075
|
+
}
|
|
1076
|
+
return p;
|
|
1077
|
+
}
|
|
1078
|
+
const url = flags.url ?? env("FOLDRUN_URL");
|
|
1079
|
+
if (url) return credentialFor(url);
|
|
1080
|
+
return currentProfile();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const remoteUrl = (flags) =>
|
|
1084
|
+
(typeof flags.profile === "string" ? chosenProfile(flags)?.url : undefined) ??
|
|
1085
|
+
flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? undefined;
|
|
1070
1086
|
|
|
1071
1087
|
const NOT_SIGNED_IN = "not signed in — run `foldrun login`, or set FOLDRUN_TOKEN / pass --token";
|
|
1072
1088
|
|
|
1073
1089
|
function tokenFor(url, flags) {
|
|
1074
|
-
|
|
1090
|
+
// --token and the environment win, always: a CI job with a key in the
|
|
1091
|
+
// environment must never read a file, whatever is stored in it.
|
|
1092
|
+
const token = flags.token ?? env("FOLDRUN_TOKEN") ?? chosenProfile(flags)?.token ?? credentialFor(url)?.token;
|
|
1075
1093
|
if (!token) throw new Error(NOT_SIGNED_IN);
|
|
1076
1094
|
return token;
|
|
1077
1095
|
}
|
|
@@ -1608,8 +1626,9 @@ async function login(flags) {
|
|
|
1608
1626
|
// Verify before storing: a wrong key stored is a wrong key on every
|
|
1609
1627
|
// later command, each failing one step further from the cause.
|
|
1610
1628
|
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})`)}
|
|
1629
|
+
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
|
+
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
|
+
console.log(` ${c.dim(`stored as the account "${name}" — \`foldrun accounts\` lists them, \`foldrun use ${name}\` switches`)}\n`);
|
|
1613
1632
|
return 0;
|
|
1614
1633
|
}
|
|
1615
1634
|
|
|
@@ -1649,9 +1668,9 @@ async function login(flags) {
|
|
|
1649
1668
|
// `minted`: this key exists because of this login, so logout may end
|
|
1650
1669
|
// it. A key stored with --token was made elsewhere and may be in use
|
|
1651
1670
|
// elsewhere; logout only forgets it.
|
|
1652
|
-
saveCredential(url, { token: poll.token, email: poll.email, account: poll.account, role: poll.role, minted: true });
|
|
1671
|
+
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
1672
|
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(
|
|
1673
|
+
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
1674
|
return 0;
|
|
1656
1675
|
}
|
|
1657
1676
|
}
|
|
@@ -1666,16 +1685,15 @@ async function login(flags) {
|
|
|
1666
1685
|
* be in use elsewhere.
|
|
1667
1686
|
*/
|
|
1668
1687
|
async function logout(flags) {
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
}
|
|
1674
|
-
const entry = credentialFor(url);
|
|
1688
|
+
// One account, not one machine: --profile names which, else the one this
|
|
1689
|
+
// shell is acting as. Signing out of a customer must not sign you out of
|
|
1690
|
+
// the other three.
|
|
1691
|
+
const entry = chosenProfile(flags);
|
|
1675
1692
|
if (!entry) {
|
|
1676
|
-
console.log(`\n ${c.dim(
|
|
1693
|
+
console.log(`\n ${c.dim("not signed in anywhere")}\n`);
|
|
1677
1694
|
return 0;
|
|
1678
1695
|
}
|
|
1696
|
+
const url = entry.url;
|
|
1679
1697
|
let revoked = false;
|
|
1680
1698
|
if (entry.minted) {
|
|
1681
1699
|
try {
|
|
@@ -1688,9 +1706,11 @@ async function logout(flags) {
|
|
|
1688
1706
|
// Not allowed, or unreachable. The local copy still goes.
|
|
1689
1707
|
}
|
|
1690
1708
|
}
|
|
1691
|
-
removeCredential(url);
|
|
1709
|
+
removeCredential(entry.name ?? url);
|
|
1692
1710
|
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)}
|
|
1711
|
+
console.log(`\n ${c.green("✓")} signed out of ${c.bold(entry.name ?? url)} ${c.dim(`(${entry.account} · ${url})`)}${c.dim(note)}`);
|
|
1712
|
+
const left = listProfiles();
|
|
1713
|
+
console.log(left.length ? ` ${c.dim(`still signed in as: ${left.map((p) => p.name).join(", ")}`)}\n` : "");
|
|
1694
1714
|
return 0;
|
|
1695
1715
|
}
|
|
1696
1716
|
|
|
@@ -1705,8 +1725,11 @@ async function whoami(flags) {
|
|
|
1705
1725
|
console.log(` account ${me.account}${me.owner ? c.dim(` (owner ${me.owner})`) : ""}`);
|
|
1706
1726
|
console.log(` role ${me.role}`);
|
|
1707
1727
|
console.log(` workspaces ${me.workspaces === null ? "all" : me.workspaces.join(", ") || "none"}`);
|
|
1708
|
-
const
|
|
1709
|
-
|
|
1728
|
+
const profile = flags.token || env("FOLDRUN_TOKEN") ? null : chosenProfile(flags);
|
|
1729
|
+
const source = flags.token ? "--token" : env("FOLDRUN_TOKEN") ? "FOLDRUN_TOKEN" : `~/.foldrun/credentials.json as "${profile?.name ?? "?"}"`;
|
|
1730
|
+
console.log(` ${c.dim(`credential from ${source}`)}`);
|
|
1731
|
+
const others = listProfiles().filter((p) => p.name !== profile?.name);
|
|
1732
|
+
console.log(others.length ? ` ${c.dim(`also signed in as: ${others.map((p) => p.name).join(", ")} — \`foldrun accounts\``)}\n` : "\n");
|
|
1710
1733
|
return 0;
|
|
1711
1734
|
}
|
|
1712
1735
|
|
|
@@ -1762,6 +1785,115 @@ async function keysCmd(positional, flags) {
|
|
|
1762
1785
|
throw new Error(`keys: unknown verb "${verb}" — ls, create, revoke`);
|
|
1763
1786
|
}
|
|
1764
1787
|
|
|
1788
|
+
/**
|
|
1789
|
+
* `foldrun source` — the files themselves, on the platform, one at a time.
|
|
1790
|
+
*
|
|
1791
|
+
* source ls [dir] the workspace's tree (or one folder of it)
|
|
1792
|
+
* source cat <path> one file, to stdout
|
|
1793
|
+
* source put <path> [--file f] write a file: from --file, else from stdin
|
|
1794
|
+
* (--message "why" goes on the revision)
|
|
1795
|
+
* source mv <from> <to>
|
|
1796
|
+
* source rm <path>
|
|
1797
|
+
*
|
|
1798
|
+
* The same door the dashboard's editor uses, so every write is a revision
|
|
1799
|
+
* with who and why, and the workspace's history shows it. For a whole
|
|
1800
|
+
* tree, `foldrun deploy`; for what agents PRODUCE (storage/), the
|
|
1801
|
+
* dashboard's Storage page — source is what you wrote, storage is what
|
|
1802
|
+
* they wrote.
|
|
1803
|
+
*/
|
|
1804
|
+
async function sourceCmd(positional, flags) {
|
|
1805
|
+
const [verb, a, b] = positional;
|
|
1806
|
+
const url = remoteUrl(flags);
|
|
1807
|
+
if (!url) throw new Error("source reads a workspace on a platform — pass --url, or `foldrun login` first");
|
|
1808
|
+
const ws = flags.to;
|
|
1809
|
+
if (!ws) throw new Error("which workspace? pass --to <workspace>");
|
|
1810
|
+
const base = `/api/workspaces/${encodeURIComponent(ws)}/source`;
|
|
1811
|
+
|
|
1812
|
+
if (verb === "ls" || verb === "list" || verb === undefined) {
|
|
1813
|
+
const { files } = await remoteCall(url, flags, base);
|
|
1814
|
+
const prefix = a ? a.replace(/\/+$/, "") + "/" : "";
|
|
1815
|
+
const shown = files.filter((f) => !prefix || f.startsWith(prefix));
|
|
1816
|
+
if (!shown.length) {
|
|
1817
|
+
console.log(`\n ${c.dim(prefix ? `nothing under ${prefix}` : "an empty workspace")}\n`);
|
|
1818
|
+
return 0;
|
|
1819
|
+
}
|
|
1820
|
+
console.log("");
|
|
1821
|
+
for (const f of shown) console.log(` ${f}`);
|
|
1822
|
+
console.log(`\n ${c.dim(`${shown.length} file${shown.length === 1 ? "" : "s"} · ${ws} · ${url}`)}\n`);
|
|
1823
|
+
return 0;
|
|
1824
|
+
}
|
|
1825
|
+
if (verb === "cat" || verb === "read" || verb === "get") {
|
|
1826
|
+
if (!a) throw new Error("which file? foldrun source cat flows/daily.md --to <workspace>");
|
|
1827
|
+
const { content } = await remoteCall(url, flags, `${base}?path=${encodeURIComponent(a)}`);
|
|
1828
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
1829
|
+
return 0;
|
|
1830
|
+
}
|
|
1831
|
+
if (verb === "put" || verb === "write") {
|
|
1832
|
+
if (!a) throw new Error("which file? foldrun source put flows/daily.md --file ./daily.md --to <workspace>");
|
|
1833
|
+
const content = typeof flags.file === "string" ? fs.readFileSync(flags.file, "utf8") : fs.readFileSync(0, "utf8");
|
|
1834
|
+
if (!content.trim()) throw new Error("refusing to write an empty file — `foldrun source rm` is the deliberate way to remove one");
|
|
1835
|
+
const body = { path: a, content, ...(typeof flags.message === "string" ? { message: flags.message } : {}) };
|
|
1836
|
+
await remoteCall(url, flags, base, { method: "PUT", body: JSON.stringify(body) });
|
|
1837
|
+
console.log(`\n ${c.green("✓")} ${ws}/${a} ${c.dim(`${content.length} chars · revision recorded${flags.message ? ` · "${flags.message}"` : ""}`)}\n`);
|
|
1838
|
+
return 0;
|
|
1839
|
+
}
|
|
1840
|
+
if (verb === "mv" || verb === "move") {
|
|
1841
|
+
if (!a || !b) throw new Error("foldrun source mv <from> <to> --to <workspace>");
|
|
1842
|
+
await remoteCall(url, flags, base, { method: "PATCH", body: JSON.stringify({ from: a, to: b }) });
|
|
1843
|
+
console.log(`\n ${c.green("✓")} ${a} → ${b}\n`);
|
|
1844
|
+
return 0;
|
|
1845
|
+
}
|
|
1846
|
+
if (verb === "rm" || verb === "delete") {
|
|
1847
|
+
if (!a) throw new Error("foldrun source rm <path> --to <workspace>");
|
|
1848
|
+
await remoteCall(url, flags, base, { method: "DELETE", body: JSON.stringify({ path: a }) });
|
|
1849
|
+
console.log(`\n ${c.green("✓")} removed ${a}\n`);
|
|
1850
|
+
return 0;
|
|
1851
|
+
}
|
|
1852
|
+
throw new Error(`source: unknown verb "${verb}" — ls, cat, put, mv, rm`);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* `foldrun accounts` — every account this machine is signed in to, and
|
|
1857
|
+
* which one a bare command talks as. `foldrun use <name>` switches.
|
|
1858
|
+
*
|
|
1859
|
+
* The list is the point: someone looking after four customers on one
|
|
1860
|
+
* platform could see only the last one they signed in as, and had to paste
|
|
1861
|
+
* --token to reach the others.
|
|
1862
|
+
*/
|
|
1863
|
+
function accountsCmd(positional) {
|
|
1864
|
+
const profiles = listProfiles();
|
|
1865
|
+
if (!profiles.length) {
|
|
1866
|
+
console.log(`\n ${c.dim("not signed in anywhere — `foldrun login`")}\n`);
|
|
1867
|
+
return 0;
|
|
1868
|
+
}
|
|
1869
|
+
const verb = positional[0];
|
|
1870
|
+
if (verb && verb !== "ls" && verb !== "list") throw new Error(`accounts: unknown verb "${verb}" — it takes none; \`foldrun use <name>\` switches`);
|
|
1871
|
+
const width = Math.max(...profiles.map((p) => p.name.length));
|
|
1872
|
+
console.log("");
|
|
1873
|
+
for (const p of profiles) {
|
|
1874
|
+
const mark = p.current ? c.green("●") : " ";
|
|
1875
|
+
const who = p.email ?? c.dim("api key");
|
|
1876
|
+
console.log(` ${mark} ${c.bold(p.name.padEnd(width))} ${p.account} ${c.dim(`${p.role ?? "?"} · ${who} · ${p.url}`)}`);
|
|
1877
|
+
}
|
|
1878
|
+
console.log(`\n ${c.dim("● is the one a bare command talks as. `foldrun use <name>` switches; --profile <name> is one command.")}\n`);
|
|
1879
|
+
return 0;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function useCmd(positional) {
|
|
1883
|
+
const name = positional[0];
|
|
1884
|
+
if (!name) {
|
|
1885
|
+
const names = listProfiles().map((p) => p.name);
|
|
1886
|
+
throw new Error(`which account? ${names.length ? names.join(", ") : "none stored — `foldrun login` first"}`);
|
|
1887
|
+
}
|
|
1888
|
+
const p = useProfile(name);
|
|
1889
|
+
if (!p) {
|
|
1890
|
+
const names = listProfiles().map((x) => x.name);
|
|
1891
|
+
throw new Error(`no account called "${name}"${names.length ? ` — try ${names.join(", ")}` : ""}`);
|
|
1892
|
+
}
|
|
1893
|
+
console.log(`\n ${c.green("✓")} now acting as ${c.bold(p.name)} ${c.dim(`(${p.account}, ${p.role} · ${p.url})`)}\n`);
|
|
1894
|
+
return 0;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1765
1897
|
export async function run(command, positional, flags, workspace) {
|
|
1766
1898
|
switch (command) {
|
|
1767
1899
|
case "login":
|
|
@@ -1772,6 +1904,12 @@ export async function run(command, positional, flags, workspace) {
|
|
|
1772
1904
|
return whoami(flags);
|
|
1773
1905
|
case "keys":
|
|
1774
1906
|
return keysCmd(positional, flags);
|
|
1907
|
+
case "accounts":
|
|
1908
|
+
case "profiles":
|
|
1909
|
+
return accountsCmd(positional);
|
|
1910
|
+
case "use":
|
|
1911
|
+
case "switch":
|
|
1912
|
+
return useCmd(positional);
|
|
1775
1913
|
case "init":
|
|
1776
1914
|
return init(workspace, flags.from);
|
|
1777
1915
|
case "check":
|
|
@@ -1794,6 +1932,8 @@ export async function run(command, positional, flags, workspace) {
|
|
|
1794
1932
|
return logsCmd(positional, flags);
|
|
1795
1933
|
case "invoke":
|
|
1796
1934
|
return invoke(positional[0], flags);
|
|
1935
|
+
case "source":
|
|
1936
|
+
return sourceCmd(positional, flags);
|
|
1797
1937
|
case "open":
|
|
1798
1938
|
return openCmd(positional, flags);
|
|
1799
1939
|
default:
|
package/src/credentials.mjs
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
// Where `foldrun login` puts what it was given.
|
|
2
2
|
//
|
|
3
|
-
// `~/.foldrun/credentials.json`, 0600
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// `~/.foldrun/credentials.json`, 0600. A PROFILE is one signed-in account on
|
|
4
|
+
// one platform: a name, the URL, the key, and who that key turned out to be.
|
|
5
|
+
// `current` names the one a bare command talks to. The shape every CLI that
|
|
6
|
+
// signs in keeps, so the person who has used Vercel's or Fly's finds nothing
|
|
7
|
+
// to learn. FOLDRUN_HOME moves the directory, for tests and for machines
|
|
8
|
+
// where HOME is not where things should be kept.
|
|
9
|
+
//
|
|
10
|
+
// It was one credential per URL, which is the same thing until somebody
|
|
11
|
+
// looks after more than one customer on the same platform — an agency, a
|
|
12
|
+
// reseller, us. Then a second account on the same host had nowhere to live
|
|
13
|
+
// and every command needed --token pasted in. Profiles are keyed by name,
|
|
14
|
+
// so two accounts on one platform are two profiles.
|
|
15
|
+
//
|
|
16
|
+
// The old shape is still read, and still written for the newest profile on
|
|
17
|
+
// each URL, so a machine that moves between CLI versions keeps working.
|
|
8
18
|
//
|
|
9
19
|
// The value stored is an ordinary API key, minted at approval and listed on
|
|
10
20
|
// Settings → API keys with the machine's name. Nothing here is a second
|
|
@@ -24,53 +34,147 @@ export const normaliseUrl = (url) => {
|
|
|
24
34
|
return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}`;
|
|
25
35
|
};
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
function readRaw() {
|
|
28
38
|
try {
|
|
29
39
|
const parsed = JSON.parse(fs.readFileSync(file(), "utf8"));
|
|
30
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
current: parsed.current ?? null,
|
|
42
|
+
default: parsed.default ?? null,
|
|
43
|
+
platforms: parsed.platforms ?? {},
|
|
44
|
+
profiles: parsed.profiles ?? {},
|
|
45
|
+
};
|
|
31
46
|
} catch {
|
|
32
|
-
return { default: null, platforms: {} };
|
|
47
|
+
return { current: null, default: null, platforms: {}, profiles: {} };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every profile, with the old per-URL entries folded in under the account
|
|
53
|
+
* name they carry. Reading migrates; nothing is written until a command
|
|
54
|
+
* that changes something writes.
|
|
55
|
+
*/
|
|
56
|
+
export function readCredentials() {
|
|
57
|
+
const raw = readRaw();
|
|
58
|
+
const profiles = { ...raw.profiles };
|
|
59
|
+
for (const [url, entry] of Object.entries(raw.platforms)) {
|
|
60
|
+
if (Object.values(profiles).some((p) => normaliseUrl(p.url) === url && p.token === entry.token)) continue;
|
|
61
|
+
profiles[nameFor(profiles, entry.account, url)] = { ...entry, url };
|
|
62
|
+
}
|
|
63
|
+
const names = Object.keys(profiles);
|
|
64
|
+
let current = raw.current && profiles[raw.current] ? raw.current : null;
|
|
65
|
+
if (!current && raw.default) {
|
|
66
|
+
current = names.find((n) => normaliseUrl(profiles[n].url) === normaliseUrl(raw.default)) ?? null;
|
|
33
67
|
}
|
|
68
|
+
return { current: current ?? names[0] ?? null, profiles };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A name nobody is using: the account, else the account and the host —
|
|
72
|
+
* two customers called "acme" on two platforms are two profiles. */
|
|
73
|
+
function nameFor(profiles, account, url) {
|
|
74
|
+
const base = (account ?? "account").trim() || "account";
|
|
75
|
+
if (!profiles[base]) return base;
|
|
76
|
+
const host = new URL(url).host;
|
|
77
|
+
const withHost = `${base}@${host}`;
|
|
78
|
+
if (!profiles[withHost]) return withHost;
|
|
79
|
+
for (let i = 2; ; i++) if (!profiles[`${withHost}-${i}`]) return `${withHost}-${i}`;
|
|
34
80
|
}
|
|
35
81
|
|
|
36
|
-
function
|
|
82
|
+
function write(current, profiles) {
|
|
83
|
+
// The old shape goes on being written for the newest profile on each URL,
|
|
84
|
+
// so a machine that moves between CLI versions is not signed out by the
|
|
85
|
+
// move. It is a mirror, never the source.
|
|
86
|
+
const platforms = {};
|
|
87
|
+
for (const p of Object.values(profiles).sort((a, b) => String(a.loggedInAt ?? "").localeCompare(String(b.loggedInAt ?? "")))) {
|
|
88
|
+
platforms[normaliseUrl(p.url)] = { token: p.token, email: p.email ?? null, account: p.account, role: p.role, ...(p.minted ? { minted: true } : {}), loggedInAt: p.loggedInAt };
|
|
89
|
+
}
|
|
37
90
|
fs.mkdirSync(credentialsDir(), { recursive: true, mode: 0o700 });
|
|
38
|
-
fs.writeFileSync(
|
|
91
|
+
fs.writeFileSync(
|
|
92
|
+
file(),
|
|
93
|
+
JSON.stringify({ current, default: current && profiles[current] ? normaliseUrl(profiles[current].url) : null, profiles, platforms }, null, 2) + "\n",
|
|
94
|
+
{ mode: 0o600 },
|
|
95
|
+
);
|
|
39
96
|
}
|
|
40
97
|
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Remember a signed-in account. A second login to the same URL for the same
|
|
100
|
+
* ACCOUNT replaces that profile rather than making a near-duplicate; a
|
|
101
|
+
* different account on the same URL is a new one.
|
|
102
|
+
*/
|
|
103
|
+
export function saveCredential(url, entry, { makeDefault = true, name } = {}) {
|
|
44
104
|
const key = normaliseUrl(url);
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
105
|
+
const { current, profiles } = readCredentials();
|
|
106
|
+
const existing = name ?? Object.keys(profiles).find((n) => normaliseUrl(profiles[n].url) === key && profiles[n].account === entry.account);
|
|
107
|
+
const profileName = existing ?? nameFor(profiles, entry.account, key);
|
|
108
|
+
profiles[profileName] = { ...entry, url: key, loggedInAt: new Date().toISOString() };
|
|
109
|
+
write(makeDefault || !current ? profileName : current, profiles);
|
|
110
|
+
return profileName;
|
|
50
111
|
}
|
|
51
112
|
|
|
52
|
-
/** Forget
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
113
|
+
/** Forget one. By name, else by URL (the newest there). Returns what was
|
|
114
|
+
* stored, for a best-effort revoke. */
|
|
115
|
+
export function removeCredential(urlOrName) {
|
|
116
|
+
const { current, profiles } = readCredentials();
|
|
117
|
+
const key = (() => {
|
|
118
|
+
if (profiles[urlOrName]) return urlOrName;
|
|
119
|
+
try {
|
|
120
|
+
const u = normaliseUrl(urlOrName);
|
|
121
|
+
const here = Object.keys(profiles).filter((n) => normaliseUrl(profiles[n].url) === u);
|
|
122
|
+
return here.sort((a, b) => String(profiles[a].loggedInAt ?? "").localeCompare(String(profiles[b].loggedInAt ?? ""))).at(-1) ?? null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
if (!key) return null;
|
|
128
|
+
const gone = profiles[key];
|
|
129
|
+
delete profiles[key];
|
|
130
|
+
const names = Object.keys(profiles);
|
|
131
|
+
write(current === key ? names[0] ?? null : current, profiles);
|
|
60
132
|
return gone;
|
|
61
133
|
}
|
|
62
134
|
|
|
63
|
-
/** The stored entry for a URL
|
|
135
|
+
/** The stored entry for a URL — the newest account signed in there. */
|
|
64
136
|
export function credentialFor(url) {
|
|
65
137
|
if (!url) return null;
|
|
66
138
|
try {
|
|
67
|
-
|
|
139
|
+
const { profiles } = readCredentials();
|
|
140
|
+
const here = Object.keys(profiles).filter((n) => normaliseUrl(profiles[n].url) === normaliseUrl(url));
|
|
141
|
+
const newest = here.sort((a, b) => String(profiles[a].loggedInAt ?? "").localeCompare(String(profiles[b].loggedInAt ?? ""))).at(-1);
|
|
142
|
+
return newest ? { ...profiles[newest], name: newest } : null;
|
|
68
143
|
} catch {
|
|
69
144
|
return null;
|
|
70
145
|
}
|
|
71
146
|
}
|
|
72
147
|
|
|
73
|
-
/**
|
|
148
|
+
/** One profile by name. */
|
|
149
|
+
export function profileByName(name) {
|
|
150
|
+
if (!name) return null;
|
|
151
|
+
const { profiles } = readCredentials();
|
|
152
|
+
return profiles[name] ? { ...profiles[name], name } : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The profile a bare command talks as. */
|
|
156
|
+
export function currentProfile() {
|
|
157
|
+
const { current, profiles } = readCredentials();
|
|
158
|
+
return current && profiles[current] ? { ...profiles[current], name: current } : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Make one the profile a bare command talks as. */
|
|
162
|
+
export function useProfile(name) {
|
|
163
|
+
const { profiles } = readCredentials();
|
|
164
|
+
if (!profiles[name]) return null;
|
|
165
|
+
write(name, profiles);
|
|
166
|
+
return { ...profiles[name], name };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** All of them, newest sign-in last. */
|
|
170
|
+
export function listProfiles() {
|
|
171
|
+
const { current, profiles } = readCredentials();
|
|
172
|
+
return Object.entries(profiles)
|
|
173
|
+
.map(([name, p]) => ({ ...p, name, current: name === current }))
|
|
174
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The platform a bare command talks to: the current profile's. */
|
|
74
178
|
export function defaultPlatform() {
|
|
75
|
-
return
|
|
179
|
+
return currentProfile()?.url ?? null;
|
|
76
180
|
}
|