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.
@@ -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,150 @@ 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
+ * @param {string} url
103
+ * @param {Record<string, any>} entry
104
+ * @param {{ makeDefault?: boolean, name?: string }} [opts]
105
+ */
106
+ export function saveCredential(url, entry, { makeDefault = true, name } = {}) {
44
107
  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;
108
+ const { current, profiles } = readCredentials();
109
+ const existing = name ?? Object.keys(profiles).find((n) => normaliseUrl(profiles[n].url) === key && profiles[n].account === entry.account);
110
+ const profileName = existing ?? nameFor(profiles, entry.account, key);
111
+ profiles[profileName] = { ...entry, url: key, loggedInAt: new Date().toISOString() };
112
+ write(makeDefault || !current ? profileName : current, profiles);
113
+ return profileName;
50
114
  }
51
115
 
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);
116
+ /** Forget one. By name, else by URL (the newest there). Returns what was
117
+ * stored, for a best-effort revoke. */
118
+ export function removeCredential(urlOrName) {
119
+ const { current, profiles } = readCredentials();
120
+ const key = (() => {
121
+ if (profiles[urlOrName]) return urlOrName;
122
+ try {
123
+ const u = normaliseUrl(urlOrName);
124
+ const here = Object.keys(profiles).filter((n) => normaliseUrl(profiles[n].url) === u);
125
+ return here.sort((a, b) => String(profiles[a].loggedInAt ?? "").localeCompare(String(profiles[b].loggedInAt ?? ""))).at(-1) ?? null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ })();
130
+ if (!key) return null;
131
+ const gone = profiles[key];
132
+ delete profiles[key];
133
+ const names = Object.keys(profiles);
134
+ write(current === key ? names[0] ?? null : current, profiles);
60
135
  return gone;
61
136
  }
62
137
 
63
- /** The stored entry for a URL, or null. */
138
+ /** The stored entry for a URL — the newest account signed in there. */
64
139
  export function credentialFor(url) {
65
140
  if (!url) return null;
66
141
  try {
67
- return readCredentials().platforms[normaliseUrl(url)] ?? null;
142
+ const { profiles } = readCredentials();
143
+ const here = Object.keys(profiles).filter((n) => normaliseUrl(profiles[n].url) === normaliseUrl(url));
144
+ const newest = here.sort((a, b) => String(profiles[a].loggedInAt ?? "").localeCompare(String(profiles[b].loggedInAt ?? ""))).at(-1);
145
+ return newest ? { ...profiles[newest], name: newest } : null;
68
146
  } catch {
69
147
  return null;
70
148
  }
71
149
  }
72
150
 
73
- /** The platform a bare command talks to: the last one signed in to. */
151
+ /** One profile by name. */
152
+ export function profileByName(name) {
153
+ if (!name) return null;
154
+ const { profiles } = readCredentials();
155
+ return profiles[name] ? { ...profiles[name], name } : null;
156
+ }
157
+
158
+ /** The profile a bare command talks as. */
159
+ export function currentProfile() {
160
+ const { current, profiles } = readCredentials();
161
+ return current && profiles[current] ? { ...profiles[current], name: current } : null;
162
+ }
163
+
164
+ /** Make one the profile a bare command talks as. */
165
+ export function useProfile(name) {
166
+ const { profiles } = readCredentials();
167
+ if (!profiles[name]) return null;
168
+ write(name, profiles);
169
+ return { ...profiles[name], name };
170
+ }
171
+
172
+ /** All of them, newest sign-in last. */
173
+ export function listProfiles() {
174
+ const { current, profiles } = readCredentials();
175
+ return Object.entries(profiles)
176
+ .map(([name, p]) => ({ ...p, name, current: name === current }))
177
+ .sort((a, b) => a.name.localeCompare(b.name));
178
+ }
179
+
180
+ /** The platform a bare command talks to: the current profile's. */
74
181
  export function defaultPlatform() {
75
- return readCredentials().default;
182
+ return currentProfile()?.url ?? null;
76
183
  }