sproutboat 0.53.1 → 0.54.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,106 +1,479 @@
1
1
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
2
+ import { homedir, platform } from "node:os";
3
3
  import { isAbsolute, resolve } from "node:path";
4
+ import { isString, jsonObject } from "./json";
4
5
 
5
- type Credentials = { version: 1; activeApiUrl?: string; profiles: Record<string, { token: string }> };
6
+ export type EndpointKind = "cloud" | "community";
7
+
8
+ /** One Cloud team the user is logged in to: an account API key the device login minted. */
9
+ export type StoredTeam = { token: string; id: string; name: string; role: string; keyId: string; keyName: string };
10
+
11
+ /**
12
+ * One named profile: an endpoint, the credential stored for it and the default team. `token` is the default
13
+ * team's key (or the only credential of a Community endpoint).
14
+ */
15
+ export type Profile = {
16
+ apiUrl: string;
17
+ token: string;
18
+ kind?: EndpointKind;
19
+ team?: string;
20
+ teams: Record<string, StoredTeam>;
21
+ };
22
+
23
+ /** A profile without its endpoint: what `credentials.json` keeps per endpoint, and what a profile can carry itself. */
24
+ type Credential = Omit<Profile, "apiUrl">;
25
+
26
+ /**
27
+ * Storage. Every CLI up to 0.53 reads `credentials.json` as version 1: `activeApiUrl` plus one credential per
28
+ * endpoint URL. That file keeps exactly that shape forever, so a project that still has an older CLI pinned keeps
29
+ * authenticating after the newest CLI logs in, logs out or switches profile. Profiles are a second file next to it,
30
+ * `profiles.json`: a name, the endpoint, and (only when the profile is not the one `credentials.json` holds for that
31
+ * endpoint) its own credential. Tokens are never copied between the files.
32
+ *
33
+ * In memory every profile has its full credential; `holders` says which profile each endpoint's entry in
34
+ * `credentials.json` belongs to (the one most recently saved or switched to on that endpoint).
35
+ */
36
+ type Credentials = { activeProfile?: string; profiles: Record<string, Profile>; holders: Record<string, string> };
37
+
38
+ /** The profile an existing machine-wide active endpoint becomes. */
39
+ export const DEFAULT_PROFILE = "default";
40
+ export const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
6
41
 
7
42
  type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
8
43
  type JsonObject = { [key: string]: JsonValue };
9
44
 
10
- function isString(value: JsonValue | undefined): value is string {
11
- return value !== undefined && value === String(value);
45
+ function str(value: JsonValue | undefined): string | undefined {
46
+ return isString(value) ? value : undefined;
47
+ }
48
+ function obj(value: JsonValue | undefined): JsonObject | undefined {
49
+ return value === undefined ? undefined : jsonObject(value);
50
+ }
51
+
52
+ function parseTeam(value: JsonValue | undefined): StoredTeam | undefined {
53
+ const input = obj(value);
54
+ const token = str(input?.token);
55
+ if (!input || token === undefined) return undefined;
56
+ return {
57
+ token,
58
+ id: str(input.id) ?? "",
59
+ name: str(input.name) ?? "",
60
+ role: str(input.role) ?? "",
61
+ keyId: str(input.keyId) ?? "",
62
+ keyName: str(input.keyName) ?? "",
63
+ };
64
+ }
65
+
66
+ function parseCredential(value: JsonValue | undefined): Credential | undefined {
67
+ const input = obj(value);
68
+ const token = str(input?.token);
69
+ if (!input || token === undefined) return undefined;
70
+ const teams: Record<string, StoredTeam> = {};
71
+ for (const [slug, teamValue] of Object.entries(obj(input.teams) ?? {})) {
72
+ const team = parseTeam(teamValue);
73
+ if (team) teams[slug] = team;
74
+ }
75
+ const kind = input.kind === "cloud" || input.kind === "community" ? input.kind : undefined;
76
+ return { token, kind, team: str(input.team), teams };
77
+ }
78
+
79
+ /** A name for an endpoint that has none: its host, as `dash.sproutboat.com` or `localhost-8787`. */
80
+ export function profileNameForUrl(apiUrl: string, taken: ReadonlySet<string>): string {
81
+ let base: string;
82
+ try {
83
+ base = new URL(apiUrl).host;
84
+ } catch {
85
+ base = apiUrl;
86
+ }
87
+ base =
88
+ base
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9._-]+/g, "-")
91
+ .replace(/^[^a-z0-9]+/, "")
92
+ .slice(0, 60) || "endpoint";
93
+ let name = base;
94
+ for (let n = 2; taken.has(name); n += 1) name = `${base}-${n}`;
95
+ return name;
12
96
  }
13
97
 
14
- type CredentialsInput = JsonObject;
15
- type ProfileInput = JsonObject;
98
+ /** The version 1 file as an older CLI reads it. */
99
+ type V1 = { activeApiUrl?: string; endpoints: Record<string, Credential> };
100
+ /** `profiles.json`: `credential` is absent for the profile that `credentials.json` holds for its endpoint. */
101
+ type ProfilesFile = { activeProfile?: string; profiles: Record<string, { endpoint: string; credential?: Credential }> };
16
102
 
17
- function parseCredentials(value: JsonValue): Credentials | undefined {
18
- if (!(value instanceof Object) || Array.isArray(value)) return undefined;
19
- const input: CredentialsInput = value;
20
- if (input.version !== 1 || !(input.profiles instanceof Object) || Array.isArray(input.profiles)) return undefined;
21
- const profiles: Record<string, { token: string }> = {};
22
- for (const [apiUrl, profileValue] of Object.entries(input.profiles)) {
23
- if (!(profileValue instanceof Object) || Array.isArray(profileValue)) return undefined;
24
- const profile: ProfileInput = profileValue;
25
- if (!isString(profile.token)) return undefined;
26
- profiles[apiUrl] = { token: profile.token };
103
+ /**
104
+ * Today's credentials become profiles. The active endpoint is the `default` profile; every other stored
105
+ * endpoint is named after its host. Nothing is dropped and nothing is repointed: with no active endpoint there is
106
+ * no default profile, as before.
107
+ */
108
+ export function migrateV1(activeApiUrl: string | undefined, byUrl: Record<string, Profile>): Credentials {
109
+ const profiles: Record<string, Profile> = {};
110
+ const holders: Record<string, string> = {};
111
+ let activeProfile: string | undefined;
112
+ if (activeApiUrl !== undefined && byUrl[activeApiUrl]) {
113
+ profiles[DEFAULT_PROFILE] = byUrl[activeApiUrl];
114
+ holders[activeApiUrl] = DEFAULT_PROFILE;
115
+ activeProfile = DEFAULT_PROFILE;
116
+ }
117
+ for (const [apiUrl, profile] of Object.entries(byUrl)) {
118
+ if (apiUrl === activeApiUrl && activeProfile !== undefined) continue;
119
+ const name = profileNameForUrl(apiUrl, new Set(Object.keys(profiles)));
120
+ profiles[name] = profile;
121
+ holders[apiUrl] = name;
122
+ }
123
+ return { activeProfile, profiles, holders };
124
+ }
125
+
126
+ function parseV1(input: JsonObject, rawProfiles: JsonObject): V1 | undefined {
127
+ const endpoints: Record<string, Credential> = {};
128
+ for (const [apiUrl, value] of Object.entries(rawProfiles)) {
129
+ const credential = parseCredential(value);
130
+ if (!credential) return undefined;
131
+ endpoints[apiUrl] = credential;
132
+ }
133
+ return { activeApiUrl: str(input.activeApiUrl), endpoints };
134
+ }
135
+
136
+ function parseProfilesFile(text: string | undefined): ProfilesFile | undefined {
137
+ if (text === undefined) return undefined;
138
+ let input: JsonObject | undefined;
139
+ try {
140
+ input = obj(JSON.parse(text));
141
+ } catch {
142
+ return undefined; // unreadable names only: the credentials themselves are in credentials.json
143
+ }
144
+ const raw = obj(input?.profiles);
145
+ if (!input || input.version !== 1 || !raw) return undefined;
146
+ const profiles: ProfilesFile["profiles"] = {};
147
+ for (const [name, value] of Object.entries(raw)) {
148
+ const entry = obj(value);
149
+ const endpoint = str(entry?.endpoint);
150
+ if (!entry || endpoint === undefined) continue;
151
+ profiles[name] = { endpoint, credential: parseCredential(entry.credential) };
27
152
  }
28
- return { version: 1, activeApiUrl: isString(input.activeApiUrl) ? input.activeApiUrl : undefined, profiles };
153
+ return { activeProfile: str(input.activeProfile), profiles };
29
154
  }
30
155
 
156
+ /** Put the endpoint credentials of `credentials.json` and the profiles of `profiles.json` together. */
157
+ function combine(v1: V1, file: ProfilesFile | undefined): Credentials {
158
+ if (file === undefined || Object.keys(file.profiles).length === 0) {
159
+ const byUrl: Record<string, Profile> = {};
160
+ for (const [apiUrl, credential] of Object.entries(v1.endpoints)) byUrl[apiUrl] = { apiUrl, ...credential };
161
+ return migrateV1(v1.activeApiUrl, byUrl);
162
+ }
163
+ const profiles: Record<string, Profile> = {};
164
+ const holders: Record<string, string> = {};
165
+ for (const [name, entry] of Object.entries(file.profiles)) {
166
+ if (entry.credential) {
167
+ profiles[name] = { apiUrl: entry.endpoint, ...entry.credential };
168
+ continue;
169
+ }
170
+ const held = v1.endpoints[entry.endpoint];
171
+ if (!held) continue; // an older CLI logged out of that endpoint
172
+ profiles[name] = { apiUrl: entry.endpoint, ...held };
173
+ holders[entry.endpoint] ??= name;
174
+ }
175
+ // An endpoint stored by an older CLI after `profiles.json` was written has no profile yet.
176
+ for (const [apiUrl, credential] of Object.entries(v1.endpoints)) {
177
+ if (holders[apiUrl] !== undefined) continue;
178
+ const name = profileNameForUrl(apiUrl, new Set(Object.keys(profiles)));
179
+ profiles[name] = { apiUrl, ...credential };
180
+ holders[apiUrl] = name;
181
+ }
182
+ // The version 1 file owns "which endpoint is active" (an older CLI changes it); the name only picks among profiles.
183
+ let activeProfile: string | undefined;
184
+ if (v1.activeApiUrl !== undefined) {
185
+ const named = file.activeProfile === undefined ? undefined : profiles[file.activeProfile];
186
+ if (named && named.apiUrl === v1.activeApiUrl) activeProfile = file.activeProfile;
187
+ else
188
+ activeProfile =
189
+ holders[v1.activeApiUrl] ?? Object.keys(profiles).find((name) => profiles[name]!.apiUrl === v1.activeApiUrl);
190
+ }
191
+ return { activeProfile, profiles, holders };
192
+ }
193
+
194
+ /** Version 2 as pre-release builds of this change wrote it: profiles keyed by name in `credentials.json`. */
195
+ function parsePreRelease(input: JsonObject, rawProfiles: JsonObject): Credentials | undefined {
196
+ const profiles: Record<string, Profile> = {};
197
+ for (const [name, value] of Object.entries(rawProfiles)) {
198
+ const entry = obj(value);
199
+ const credential = parseCredential(entry);
200
+ const apiUrl = str(entry?.apiUrl);
201
+ if (!credential || apiUrl === undefined) return undefined;
202
+ profiles[name] = { apiUrl, ...credential };
203
+ }
204
+ const active = str(input.activeProfile);
205
+ const activeProfile = active !== undefined && profiles[active] ? active : undefined;
206
+ return {
207
+ activeProfile,
208
+ profiles,
209
+ holders: activeProfile === undefined ? {} : { [profiles[activeProfile].apiUrl]: activeProfile },
210
+ };
211
+ }
212
+
213
+ type Parsed = { credentials: Credentials; preRelease: boolean };
214
+
215
+ function parseFiles(value: JsonValue, profilesText: string | undefined): Parsed | undefined {
216
+ const input = obj(value);
217
+ const rawProfiles = obj(input?.profiles);
218
+ if (!input || !rawProfiles) return undefined;
219
+ if (input.version === 1) {
220
+ const v1 = parseV1(input, rawProfiles);
221
+ return v1 && { credentials: combine(v1, parseProfilesFile(profilesText)), preRelease: false };
222
+ }
223
+ if (input.version !== 2) return undefined;
224
+ const credentials = parsePreRelease(input, rawProfiles);
225
+ return credentials && { credentials, preRelease: true };
226
+ }
227
+
228
+ /**
229
+ * Where the credentials live, mirroring Wrangler (xdg-app-paths): `SPROUTBOAT_CONFIG_DIR` or an absolute
230
+ * `XDG_CONFIG_HOME` first, else `~/Library/Preferences/sproutboat` on macOS and `~/.config/sproutboat` elsewhere.
231
+ */
31
232
  export function configDirectory(): string {
32
233
  const configured = process.env.SPROUTBOAT_CONFIG_DIR || process.env.XDG_CONFIG_HOME;
33
234
  if (configured && isAbsolute(configured)) return resolve(configured, "sproutboat");
34
- return resolve(homedir(), ".config", "sproutboat");
235
+ if (platform() === "darwin") return resolve(home(), "Library", "Preferences", "sproutboat");
236
+ return resolve(home(), ".config", "sproutboat");
35
237
  }
36
238
 
37
239
  function credentialsPath(): string {
38
240
  return resolve(configDirectory(), "credentials.json");
39
241
  }
40
242
 
243
+ /** Earlier versions kept the file in ~/.config/sproutboat on every platform. Read it when the new place has none. */
244
+ function legacyCredentialsPath(): string | undefined {
245
+ if (process.env.SPROUTBOAT_CONFIG_DIR || process.env.XDG_CONFIG_HOME) return undefined;
246
+ const legacy = resolve(home(), ".config", "sproutboat", "credentials.json");
247
+ return legacy === credentialsPath() ? undefined : legacy;
248
+ }
249
+
250
+ function profilesPath(): string {
251
+ return resolve(configDirectory(), "profiles.json");
252
+ }
253
+
41
254
  function emptyCredentials(): Credentials {
42
- return { version: 1, profiles: {} };
255
+ return { profiles: {}, holders: {} };
43
256
  }
44
257
 
45
- async function readCredentials(): Promise<Credentials> {
258
+ async function readFileOrMissing(path: string): Promise<string | undefined> {
46
259
  try {
47
- return parseCredentials(JSON.parse(await readFile(credentialsPath(), "utf8"))) || emptyCredentials();
260
+ return await readFile(path, "utf8");
48
261
  } catch (error) {
49
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyCredentials();
262
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
263
+ throw new Error("could not read local Sproutboat credentials");
264
+ }
265
+ }
266
+
267
+ async function readCredentials(): Promise<Credentials> {
268
+ let text = await readFileOrMissing(credentialsPath());
269
+ // profiles.json only ever sits next to the credentials.json that is in use; the old macOS place never had one.
270
+ let profilesText = text === undefined ? undefined : await readFileOrMissing(profilesPath());
271
+ if (text === undefined) {
272
+ const legacy = legacyCredentialsPath();
273
+ text = legacy === undefined ? undefined : await readFileOrMissing(legacy);
274
+ profilesText = undefined;
275
+ }
276
+ if (text === undefined) return emptyCredentials();
277
+ let parsed: Parsed | undefined;
278
+ try {
279
+ parsed = parseFiles(JSON.parse(text), profilesText);
280
+ } catch {
50
281
  throw new Error("could not read local Sproutboat credentials");
51
282
  }
283
+ if (!parsed) return emptyCredentials();
284
+ if (parsed.preRelease) {
285
+ // A pre-release build wrote version 2, which no CLI reads: put credentials.json back to version 1 right away.
286
+ try {
287
+ await writeCredentials(parsed.credentials);
288
+ } catch {
289
+ // read-only config directory: the next write converts it
290
+ }
291
+ }
292
+ return parsed.credentials;
293
+ }
294
+
295
+ /**
296
+ * The profile chosen with `--profile` or `SPROUTBOAT_PROFILE` for this run, set by `main.ts`. Every lookup and
297
+ * write below goes to it; with none chosen they find a profile by endpoint (the active one first), and a login to
298
+ * an endpoint nothing is stored for makes a new profile.
299
+ */
300
+ let selected: string | undefined;
301
+
302
+ export function selectProfile(name: string | undefined): void {
303
+ selected = name;
304
+ }
305
+
306
+ const sameUrl = (a: string, b: string) => a.replace(/\/$/, "") === b.replace(/\/$/, "");
307
+
308
+ /** The stored profile for an endpoint: the selected one, else the active one, else the first one on that URL. */
309
+ function findProfile(credentials: Credentials, apiUrl: string): { name: string; profile: Profile } | undefined {
310
+ if (selected !== undefined) {
311
+ const profile = credentials.profiles[selected];
312
+ return profile && sameUrl(profile.apiUrl, apiUrl) ? { name: selected, profile } : undefined;
313
+ }
314
+ const active = credentials.activeProfile;
315
+ if (active !== undefined && sameUrl(credentials.profiles[active].apiUrl, apiUrl))
316
+ return { name: active, profile: credentials.profiles[active] };
317
+ const found = Object.entries(credentials.profiles).find(([, profile]) => sameUrl(profile.apiUrl, apiUrl));
318
+ return found ? { name: found[0], profile: found[1] } : undefined;
319
+ }
320
+
321
+ /** Where a save for `apiUrl` goes: the selected profile, the profile already on that URL, or a new one. */
322
+ function writeTarget(credentials: Credentials, apiUrl: string): string {
323
+ if (selected !== undefined) {
324
+ const existing = credentials.profiles[selected];
325
+ if (existing && !sameUrl(existing.apiUrl, apiUrl))
326
+ throw new Error(
327
+ `the profile "${selected}" is for ${existing.apiUrl}, not ${apiUrl}; log out of it first, or use another profile name`,
328
+ );
329
+ return selected;
330
+ }
331
+ const found = findProfile(credentials, apiUrl);
332
+ if (found) return found.name;
333
+ const names = Object.keys(credentials.profiles);
334
+ return names.length === 0 ? DEFAULT_PROFILE : profileNameForUrl(apiUrl, new Set(names));
335
+ }
336
+
337
+ /** The first profile ever saved is the active one; a later login keeps it, and so does a failed one (#204). */
338
+ function activateIfNone(credentials: Credentials, name: string): void {
339
+ if (credentials.activeProfile === undefined || !credentials.profiles[credentials.activeProfile])
340
+ credentials.activeProfile = name;
341
+ }
342
+
343
+ export type ProfileSummary = { name: string; active: boolean; profile: Profile };
344
+
345
+ export async function listProfiles(): Promise<ProfileSummary[]> {
346
+ const credentials = await readCredentials();
347
+ return Object.entries(credentials.profiles).map(([name, profile]) => ({
348
+ name,
349
+ active: name === credentials.activeProfile,
350
+ profile,
351
+ }));
352
+ }
353
+
354
+ export async function getProfile(name: string): Promise<Profile | undefined> {
355
+ return (await readCredentials()).profiles[name];
356
+ }
357
+
358
+ /** The machine's default profile: what a command uses when nothing names another one. */
359
+ export async function activeProfileName(): Promise<string | undefined> {
360
+ return (await readCredentials()).activeProfile;
361
+ }
362
+
363
+ /** `profile use`: make `name` the default profile. False when there is no such profile. */
364
+ export async function useProfile(name: string): Promise<boolean> {
365
+ const credentials = await readCredentials();
366
+ if (!credentials.profiles[name]) return false;
367
+ credentials.activeProfile = name;
368
+ credentials.holders[credentials.profiles[name].apiUrl] = name;
369
+ await writeCredentials(credentials);
370
+ return true;
371
+ }
372
+
373
+ /** The name of the profile a command would use for this endpoint, if one is stored. */
374
+ export async function savedProfileName(apiUrl: string): Promise<string | undefined> {
375
+ return findProfile(await readCredentials(), apiUrl)?.name;
52
376
  }
53
377
 
54
378
  export async function savedToken(apiUrl: string): Promise<string | undefined> {
55
- return (await readCredentials()).profiles[apiUrl]?.token;
379
+ return findProfile(await readCredentials(), apiUrl)?.profile.token;
380
+ }
381
+
382
+ export async function savedProfile(apiUrl: string): Promise<Profile | undefined> {
383
+ return findProfile(await readCredentials(), apiUrl)?.profile;
56
384
  }
57
385
 
386
+ /** The endpoint of the machine's default profile. */
58
387
  export async function activeApiUrl(): Promise<string | undefined> {
59
- return (await readCredentials()).activeApiUrl;
388
+ const credentials = await readCredentials();
389
+ return credentials.activeProfile === undefined ? undefined : credentials.profiles[credentials.activeProfile].apiUrl;
60
390
  }
61
391
 
62
392
  /**
63
- * #79 `logout`: drop one endpoint's token. Returns `removed: false` when none
64
- * was stored. baronunread/sproutboat#204 — used to repoint `activeApiUrl` to
65
- * an arbitrary surviving profile chosen by object key order; now it just
66
- * clears the active endpoint, so the next command falls through to
67
- * `resolveApiUrl()`'s documented default instead of a silently-picked one.
393
+ * #79 `logout`: drop one endpoint's credential (the selected profile's, if one is selected). With `team`, only
394
+ * that team's key is dropped from a Cloud profile; the default moves to a remaining team, and the profile goes only
395
+ * when none is left. Returns `removed: false` when nothing matched. baronunread/sproutboat#204: dropping the
396
+ * active profile clears it rather than picking another one by object key order.
68
397
  */
69
- export async function forgetToken(apiUrl: string): Promise<{ removed: boolean; activeApiUrl?: string }> {
398
+ export async function forgetToken(
399
+ apiUrl: string,
400
+ team?: string,
401
+ ): Promise<{ removed: boolean; activeApiUrl?: string; remaining: string[] }> {
70
402
  const credentials = await readCredentials();
71
- if (!credentials.profiles[apiUrl]) return { removed: false };
72
- delete credentials.profiles[apiUrl];
73
- if (credentials.activeApiUrl === apiUrl) credentials.activeApiUrl = undefined;
403
+ const found = findProfile(credentials, apiUrl);
404
+ const activeUrl = () =>
405
+ credentials.activeProfile === undefined ? undefined : credentials.profiles[credentials.activeProfile]?.apiUrl;
406
+ if (!found) return { removed: false, remaining: [] };
407
+ const { name, profile } = found;
408
+ if (team !== undefined && !profile.teams[team]) return { removed: false, remaining: Object.keys(profile.teams) };
409
+ if (team !== undefined) {
410
+ delete profile.teams[team];
411
+ const left = Object.keys(profile.teams);
412
+ if (left.length > 0) {
413
+ const next = profile.team === team || profile.team === undefined ? left[0]! : profile.team;
414
+ profile.team = next;
415
+ profile.token = profile.teams[next]!.token;
416
+ await writeCredentials(credentials);
417
+ return { removed: true, activeApiUrl: activeUrl(), remaining: left };
418
+ }
419
+ }
420
+ delete credentials.profiles[name];
421
+ if (credentials.activeProfile === name) credentials.activeProfile = undefined;
74
422
  await writeCredentials(credentials);
75
- return { removed: true, activeApiUrl: credentials.activeApiUrl };
423
+ return { removed: true, activeApiUrl: activeUrl(), remaining: [] };
424
+ }
425
+
426
+ export type SaveResult = { activeApiUrl: string; profile: string; activeProfile: string };
427
+
428
+ function saved(credentials: Credentials, profile: string): SaveResult {
429
+ const activeProfile = credentials.activeProfile!;
430
+ return { activeApiUrl: credentials.profiles[activeProfile].apiUrl, profile, activeProfile };
76
431
  }
77
432
 
78
433
  /**
79
- * baronunread/sproutboat#204 — used to unconditionally repoint the
80
- * machine-wide active endpoint to whatever was just logged into, so logging
81
- * into a local dev control plane for one project silently became the deploy
82
- * target for every unrelated project on the machine. Now the active endpoint
83
- * is only set the first time (nothing saved yet); a later login to a second
84
- * endpoint keeps the existing active endpoint and reports it, so the caller
85
- * can say so instead of silently switching.
434
+ * baronunread/sproutboat#204: the machine's default profile is only set the first time (nothing saved yet);
435
+ * a later login to a second endpoint keeps the existing one and reports it.
86
436
  */
87
- export async function saveToken(apiUrl: string, token: string): Promise<{ activeApiUrl: string }> {
437
+ export async function saveToken(apiUrl: string, token: string, kind?: EndpointKind): Promise<SaveResult> {
88
438
  const credentials = await readCredentials();
89
- credentials.profiles[apiUrl] = { token };
90
- if (!credentials.activeApiUrl) credentials.activeApiUrl = apiUrl;
439
+ const name = writeTarget(credentials, apiUrl);
440
+ credentials.profiles[name] = { apiUrl, token, kind: kind ?? credentials.profiles[name]?.kind, teams: {} };
441
+ credentials.holders[apiUrl] = name;
442
+ activateIfNone(credentials, name);
91
443
  await writeCredentials(credentials);
92
- return { activeApiUrl: credentials.activeApiUrl };
444
+ return saved(credentials, name);
93
445
  }
94
446
 
95
- /** Atomic 0600 write of the whole credentials file. */
96
- async function writeCredentials(credentials: Credentials): Promise<void> {
97
- const directory = configDirectory();
98
- const path = credentialsPath();
99
- await mkdir(directory, { recursive: true, mode: 0o700 });
100
- await chmod(directory, 0o700);
447
+ /** Store one team's key for a Cloud profile. The newest login becomes that profile's default team. */
448
+ export async function saveTeam(apiUrl: string, slug: string, team: StoredTeam): Promise<SaveResult> {
449
+ const credentials = await readCredentials();
450
+ const name = writeTarget(credentials, apiUrl);
451
+ const teams = { ...credentials.profiles[name]?.teams, [slug]: team };
452
+ credentials.profiles[name] = { apiUrl, token: team.token, kind: "cloud", team: slug, teams };
453
+ credentials.holders[apiUrl] = name;
454
+ activateIfNone(credentials, name);
455
+ await writeCredentials(credentials);
456
+ return saved(credentials, name);
457
+ }
458
+
459
+ /** Decide which profile `credentials.json` holds for each endpoint: the one most recently saved or switched to there. */
460
+ function settleHolders(credentials: Credentials): void {
461
+ const holders: Record<string, string> = {};
462
+ for (const [name, profile] of Object.entries(credentials.profiles)) {
463
+ const current = credentials.holders[profile.apiUrl];
464
+ if (holders[profile.apiUrl] === undefined || current === name) holders[profile.apiUrl] = name;
465
+ }
466
+ credentials.holders = holders;
467
+ }
468
+
469
+ function credentialOf({ token, kind, team, teams }: Profile): Credential {
470
+ return { token, kind, team, teams };
471
+ }
472
+
473
+ async function writeAtomic(directory: string, path: string, text: string): Promise<void> {
101
474
  const temporary = `${path}.tmp-${crypto.randomUUID()}`;
102
475
  try {
103
- await writeFile(temporary, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
476
+ await writeFile(temporary, text, { mode: 0o600 });
104
477
  await chmod(temporary, 0o600);
105
478
  await rename(temporary, path);
106
479
  await chmod(path, 0o600);
@@ -108,3 +481,33 @@ async function writeCredentials(credentials: Credentials): Promise<void> {
108
481
  await rm(temporary, { force: true });
109
482
  }
110
483
  }
484
+
485
+ /**
486
+ * Atomic 0600 write of both files. `credentials.json` stays version 1 (see `Credentials`) and is written first, since
487
+ * it is the one older CLIs depend on.
488
+ */
489
+ async function writeCredentials(credentials: Credentials): Promise<void> {
490
+ const directory = configDirectory();
491
+ await mkdir(directory, { recursive: true, mode: 0o700 });
492
+ await chmod(directory, 0o700);
493
+ settleHolders(credentials);
494
+ const endpoints: Record<string, Credential> = {};
495
+ const profiles: Record<string, JsonObject> = {};
496
+ for (const [name, profile] of Object.entries(credentials.profiles)) {
497
+ const held = credentials.holders[profile.apiUrl] === name;
498
+ if (held) endpoints[profile.apiUrl] = credentialOf(profile);
499
+ profiles[name] = held
500
+ ? { endpoint: profile.apiUrl }
501
+ : { endpoint: profile.apiUrl, credential: credentialOf(profile) };
502
+ }
503
+ const active = credentials.activeProfile === undefined ? undefined : credentials.profiles[credentials.activeProfile];
504
+ const v1 = { version: 1, activeApiUrl: active?.apiUrl, profiles: endpoints };
505
+ await writeAtomic(directory, credentialsPath(), `${JSON.stringify(v1, null, 2)}\n`);
506
+ const file = { version: 1, activeProfile: credentials.activeProfile, profiles };
507
+ await writeAtomic(directory, profilesPath(), `${JSON.stringify(file, null, 2)}\n`);
508
+ }
509
+
510
+ /** $HOME when set (as Wrangler's xdg-app-paths does), else the account's home directory. */
511
+ function home(): string {
512
+ return process.env.HOME || homedir();
513
+ }