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 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.4.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
- * Credentials come from ANTHROPIC_API_KEY or, more often on a laptop, from an
694
- * existing Claude Code login. Requiring the key outright would lock out anyone
695
- * already authenticated — a bad first run for the most likely user.
696
- */
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, or log in with Claude Code.\n" +
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
- const remoteUrl = (flags) => flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? undefined;
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
- const token = flags.token ?? env("FOLDRUN_TOKEN") ?? credentialFor(url)?.token;
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})`)}\n`);
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("Stored in ~/.foldrun/credentials.json. `foldrun whoami` shows it; `foldrun logout` removes it.")}\n`);
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
- const url = remoteUrl(flags);
1670
- if (!url) {
1671
- console.log(`\n ${c.dim("not signed in anywhere")}\n`);
1672
- return 0;
1673
- }
1674
- const entry = credentialFor(url);
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(`not signed in to ${url}`)}\n`);
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)}\n`);
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 source = flags.token ? "--token" : env("FOLDRUN_TOKEN") ? "FOLDRUN_TOKEN" : "~/.foldrun/credentials.json";
1709
- console.log(` ${c.dim(`credential from ${source}`)}\n`);
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:
@@ -1,10 +1,20 @@
1
1
  // Where `foldrun login` puts what it was given.
2
2
  //
3
- // `~/.foldrun/credentials.json`, 0600, one entry per platform URL, and a
4
- // `default` naming the one a bare command talks to — the same shape every
5
- // CLI that signs in keeps, so the person who has used Vercel's or Fly's
6
- // finds nothing to learn. FOLDRUN_HOME moves the directory, for tests and
7
- // for machines where HOME is not where things should be kept.
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
- export function readCredentials() {
37
+ function readRaw() {
28
38
  try {
29
39
  const parsed = JSON.parse(fs.readFileSync(file(), "utf8"));
30
- return { default: parsed.default ?? null, platforms: parsed.platforms ?? {} };
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 writeCredentials(creds) {
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(file(), JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
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
- /** Remember a platform; it becomes the default unless one is already set
42
- * and the caller says to leave it. */
43
- export function saveCredential(url, entry, { makeDefault = true } = {}) {
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 creds = readCredentials();
46
- creds.platforms[key] = { ...entry, loggedInAt: new Date().toISOString() };
47
- if (makeDefault || !creds.default) creds.default = key;
48
- writeCredentials(creds);
49
- return key;
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 a platform. Returns what was there, for a best-effort revoke. */
53
- export function removeCredential(url) {
54
- const key = normaliseUrl(url);
55
- const creds = readCredentials();
56
- const gone = creds.platforms[key] ?? null;
57
- delete creds.platforms[key];
58
- if (creds.default === key) creds.default = Object.keys(creds.platforms)[0] ?? null;
59
- writeCredentials(creds);
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, or null. */
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
- return readCredentials().platforms[normaliseUrl(url)] ?? null;
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
- /** The platform a bare command talks to: the last one signed in to. */
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 readCredentials().default;
179
+ return currentProfile()?.url ?? null;
76
180
  }