sproutboat 0.53.1 → 0.55.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/CHANGELOG.md +69 -1
- package/README.md +122 -9
- package/SURFACE.md +42 -22
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +100 -0
- package/src/build.ts +13 -2
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +794 -0
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +719 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +34 -18
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/dev.ts +67 -4
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/init.ts +328 -0
- package/src/main.ts +1247 -193
- package/src/options.ts +242 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +192 -64
- package/src/target.ts +95 -0
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
package/src/credentials.ts
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
return 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
|
-
|
|
15
|
-
type
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
255
|
+
return { profiles: {}, holders: {} };
|
|
43
256
|
}
|
|
44
257
|
|
|
45
|
-
async function
|
|
258
|
+
async function readFileOrMissing(path: string): Promise<string | undefined> {
|
|
46
259
|
try {
|
|
47
|
-
return
|
|
260
|
+
return await readFile(path, "utf8");
|
|
48
261
|
} catch (error) {
|
|
49
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") return
|
|
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()).
|
|
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
|
-
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* clears
|
|
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(
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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:
|
|
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
|
|
80
|
-
*
|
|
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<
|
|
437
|
+
export async function saveToken(apiUrl: string, token: string, kind?: EndpointKind): Promise<SaveResult> {
|
|
88
438
|
const credentials = await readCredentials();
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
444
|
+
return saved(credentials, name);
|
|
93
445
|
}
|
|
94
446
|
|
|
95
|
-
/**
|
|
96
|
-
async function
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
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
|
+
}
|