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.
- package/CHANGELOG.md +37 -1
- package/README.md +94 -5
- package/SURFACE.md +40 -20
- package/package.json +7 -7
- package/src/account-field.ts +69 -0
- package/src/build.ts +12 -1
- package/src/cloud-deploy.ts +752 -0
- package/src/cloud-fake.ts +633 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +16 -3
- 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/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/main.ts +1155 -81
- package/src/options.ts +233 -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 +186 -59
- package/src/target.ts +95 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets on the Sproutboat Cloud, Wrangler style: `secrets put/list/delete` act on the deployed app by name, and a
|
|
3
|
+
* deploy never wipes them. The Cloud routes: GET /v1/apps/<app>/secrets, PUT and DELETE /v1/apps/<app>/secrets/<NAME>.
|
|
4
|
+
*/
|
|
5
|
+
import { readEnvFile } from "./env-file";
|
|
6
|
+
import { CloudError } from "./cloud";
|
|
7
|
+
import { makeCall, type CloudCredentials } from "./cloud-deploy";
|
|
8
|
+
import { isSafeInteger, isString } from "./json";
|
|
9
|
+
|
|
10
|
+
export const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
11
|
+
|
|
12
|
+
type SecretAction = "list" | "write";
|
|
13
|
+
const PERMISSION = { list: "secret:list", write: "secret:write" } as const satisfies Record<SecretAction, string>;
|
|
14
|
+
|
|
15
|
+
const notDeployed = (project: string) =>
|
|
16
|
+
`${project} is not deployed yet, so it has nowhere to keep secrets: run \`sproutboat deploy\` first, or pass them on the first deploy with --secrets-file <file>`;
|
|
17
|
+
|
|
18
|
+
/** One call; the 403 and the missing app become sentences of their own. */
|
|
19
|
+
async function secretCall(
|
|
20
|
+
credentials: CloudCredentials,
|
|
21
|
+
project: string,
|
|
22
|
+
action: SecretAction,
|
|
23
|
+
c: { method: string; path: string; json?: object },
|
|
24
|
+
missingSecret?: string,
|
|
25
|
+
) {
|
|
26
|
+
const result = await makeCall(credentials)({ ...c, ok: [403, 404] });
|
|
27
|
+
if (result.status === 403)
|
|
28
|
+
throw new CloudError(
|
|
29
|
+
`this key lacks the ${PERMISSION[action]} permission; ask a team Owner or Admin, or use a key that has it (see \`sproutboat whoami\` for this key's access)`,
|
|
30
|
+
);
|
|
31
|
+
if (result.status === 404) {
|
|
32
|
+
const error = result.body?.error;
|
|
33
|
+
if (missingSecret !== undefined && isString(error) && error !== "no such app")
|
|
34
|
+
throw new CloudError(`${project} has no secret named ${missingSecret}`);
|
|
35
|
+
throw new CloudError(notDeployed(project));
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listCloudSecrets(credentials: CloudCredentials, project: string): Promise<string[]> {
|
|
41
|
+
const result = await secretCall(credentials, project, "list", {
|
|
42
|
+
method: "GET",
|
|
43
|
+
path: `/v1/apps/${project}/secrets`,
|
|
44
|
+
});
|
|
45
|
+
const names = result.body?.secrets;
|
|
46
|
+
return Array.isArray(names) ? names.filter(isString) : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns the record generation the write produced. */
|
|
50
|
+
export async function putCloudSecret(
|
|
51
|
+
credentials: CloudCredentials,
|
|
52
|
+
project: string,
|
|
53
|
+
name: string,
|
|
54
|
+
value: string,
|
|
55
|
+
): Promise<number | undefined> {
|
|
56
|
+
const result = await secretCall(credentials, project, "write", {
|
|
57
|
+
method: "PUT",
|
|
58
|
+
path: `/v1/apps/${project}/secrets/${name}`,
|
|
59
|
+
json: { value },
|
|
60
|
+
});
|
|
61
|
+
const generation = result.body?.record_generation;
|
|
62
|
+
return isSafeInteger(generation) ? generation : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type SecretsPatchBody = { set?: Record<string, string>; delete?: string[] };
|
|
66
|
+
|
|
67
|
+
/** One PATCH that sets and deletes many secrets at once; returns the record generation it produced. */
|
|
68
|
+
export async function patchCloudSecrets(
|
|
69
|
+
credentials: CloudCredentials,
|
|
70
|
+
project: string,
|
|
71
|
+
patch: { set: Record<string, string>; delete: string[] },
|
|
72
|
+
): Promise<number | undefined> {
|
|
73
|
+
const body: SecretsPatchBody = {};
|
|
74
|
+
if (Object.keys(patch.set).length > 0) body.set = patch.set;
|
|
75
|
+
if (patch.delete.length > 0) body.delete = patch.delete;
|
|
76
|
+
const result = await secretCall(credentials, project, "write", {
|
|
77
|
+
method: "PATCH",
|
|
78
|
+
path: `/v1/apps/${project}/secrets`,
|
|
79
|
+
json: body,
|
|
80
|
+
});
|
|
81
|
+
const generation = result.body?.record_generation;
|
|
82
|
+
return isSafeInteger(generation) ? generation : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function deleteCloudSecret(
|
|
86
|
+
credentials: CloudCredentials,
|
|
87
|
+
project: string,
|
|
88
|
+
name: string,
|
|
89
|
+
): Promise<number | undefined> {
|
|
90
|
+
const result = await secretCall(
|
|
91
|
+
credentials,
|
|
92
|
+
project,
|
|
93
|
+
"write",
|
|
94
|
+
{ method: "DELETE", path: `/v1/apps/${project}/secrets/${name}` },
|
|
95
|
+
name,
|
|
96
|
+
);
|
|
97
|
+
const generation = result.body?.record_generation;
|
|
98
|
+
return isSafeInteger(generation) ? generation : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The values of `deploy --secrets-file <file>`: a `.dev.vars` or `.env` file, read with `dev`'s dotenv parser. Only the
|
|
103
|
+
* file named is read; `.dev.vars` is never picked up implicitly on a deploy, as with the Community deploy. Names
|
|
104
|
+
* only in every message.
|
|
105
|
+
*/
|
|
106
|
+
export async function readSecretsFile(path: string): Promise<{ values: Record<string, string>; warnings: string[] }> {
|
|
107
|
+
const parsed = await readEnvFile(path);
|
|
108
|
+
const values: Record<string, string> = {};
|
|
109
|
+
const bad: string[] = [];
|
|
110
|
+
const skipped: string[] = [];
|
|
111
|
+
for (const entry of parsed.entries) {
|
|
112
|
+
if (!SECRET_NAME_RE.test(entry.key)) bad.push(entry.key);
|
|
113
|
+
else if (entry.value === "") skipped.push(entry.key);
|
|
114
|
+
else values[entry.key] = entry.value;
|
|
115
|
+
}
|
|
116
|
+
if (bad.length > 0)
|
|
117
|
+
throw new CloudError(`${path}: secret names must be UPPER_SNAKE_CASE; not valid: ${bad.join(", ")}`);
|
|
118
|
+
const warnings = [...parsed.warnings];
|
|
119
|
+
if (skipped.length > 0) warnings.push(`${path}: not sent, empty value: ${skipped.join(", ")}`);
|
|
120
|
+
return { values, warnings };
|
|
121
|
+
}
|
package/src/cloud.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sproutboat Cloud sessions: endpoint detection, the device-code login (RFC 8628), whoami, logout, and which
|
|
3
|
+
* team a command acts for. Nothing here exits the process or reads the terminal directly, so tests drive it
|
|
4
|
+
* against a fake server with an injected `CloudIo`.
|
|
5
|
+
*/
|
|
6
|
+
import { isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
|
|
7
|
+
import {
|
|
8
|
+
forgetToken,
|
|
9
|
+
saveTeam,
|
|
10
|
+
savedProfile,
|
|
11
|
+
type EndpointKind,
|
|
12
|
+
type Profile,
|
|
13
|
+
type SaveResult,
|
|
14
|
+
type StoredTeam,
|
|
15
|
+
} from "./credentials";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_API_URL = "https://dash.sproutboat.com";
|
|
18
|
+
export const CLI_CLIENT_ID = "sproutboat-cli";
|
|
19
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
20
|
+
|
|
21
|
+
/** A failure with a message fit to print as is. */
|
|
22
|
+
export class CloudError extends Error {}
|
|
23
|
+
|
|
24
|
+
export type CloudIo = {
|
|
25
|
+
log(line: string): void;
|
|
26
|
+
warn(line: string): void;
|
|
27
|
+
/** stdin and stdout are terminals: a person is here to answer a prompt. */
|
|
28
|
+
interactive: boolean;
|
|
29
|
+
/** Open a URL in a browser. False when this machine cannot (headless, SSH, no opener). */
|
|
30
|
+
openUrl(url: string): boolean;
|
|
31
|
+
sleep(ms: number): Promise<void>;
|
|
32
|
+
/** Pick one of `choices`; only called when `interactive`. */
|
|
33
|
+
choose(question: string, choices: string[]): Promise<string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function parseBody(text: string): JsonObject | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return jsonObject(parseJsonValue(text));
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The API's `{error}` text for a failed response, or a short fallback. */
|
|
45
|
+
function errorText(body: JsonObject | undefined, status: number): string {
|
|
46
|
+
const message = body?.error_description ?? body?.error;
|
|
47
|
+
return isString(message) ? message : `HTTP ${status}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchOrExplain(apiUrl: string, url: string, init: RequestInit): Promise<Response> {
|
|
51
|
+
try {
|
|
52
|
+
return await fetch(url, init);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new CloudError(`cannot reach ${apiUrl}; check the address and your network`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cloud or Community? The Cloud answers GET /v1/config; the Community control plane has no such route.
|
|
60
|
+
* Throws when the endpoint cannot be reached at all.
|
|
61
|
+
*/
|
|
62
|
+
export async function detectKind(apiUrl: string): Promise<EndpointKind> {
|
|
63
|
+
const response = await fetchOrExplain(apiUrl, `${apiUrl}/v1/config`, { signal: AbortSignal.timeout(8000) });
|
|
64
|
+
if (!response.ok) return "community";
|
|
65
|
+
const body = parseBody(await response.text());
|
|
66
|
+
return body !== undefined && "ingress" in body ? "cloud" : "community";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type DeviceLoginResult = { token: string; keyId: string; keyName: string; slug: string; team: StoredTeam };
|
|
70
|
+
|
|
71
|
+
export type DeviceLoginOptions = {
|
|
72
|
+
apiUrl: string;
|
|
73
|
+
io: CloudIo;
|
|
74
|
+
hostname: string;
|
|
75
|
+
os: string;
|
|
76
|
+
/** Asks the approval page to preselect this team. */
|
|
77
|
+
account?: string;
|
|
78
|
+
now?: () => number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The RFC 8628 flow: ask for a code, show it, poll until the person approves in a browser. */
|
|
82
|
+
export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLoginResult> {
|
|
83
|
+
const { apiUrl, io } = options;
|
|
84
|
+
const now = options.now ?? Date.now;
|
|
85
|
+
const started = await fetchOrExplain(apiUrl, `${apiUrl}/v1/device/code`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ client_id: CLI_CLIENT_ID, hostname: options.hostname, os: options.os }),
|
|
89
|
+
});
|
|
90
|
+
const startedBody = parseBody(await started.text());
|
|
91
|
+
if (started.status === 429) throw new CloudError("too many login attempts from this network; wait a few minutes");
|
|
92
|
+
const deviceCode = startedBody?.device_code;
|
|
93
|
+
const userCode = startedBody?.user_code;
|
|
94
|
+
const verification = startedBody?.verification_uri_complete ?? startedBody?.verification_uri;
|
|
95
|
+
if (!started.ok || !isString(deviceCode) || !isString(userCode) || !isString(verification))
|
|
96
|
+
throw new CloudError(`could not start the login: ${errorText(startedBody, started.status)}`);
|
|
97
|
+
const expiresIn = isSafeInteger(startedBody?.expires_in) ? startedBody.expires_in : 600;
|
|
98
|
+
let interval = isSafeInteger(startedBody?.interval) && startedBody.interval > 0 ? startedBody.interval : 5;
|
|
99
|
+
|
|
100
|
+
const url = new URL(verification);
|
|
101
|
+
if (options.account) url.searchParams.set("team", options.account);
|
|
102
|
+
const link = url.toString();
|
|
103
|
+
const opened = io.interactive && io.openUrl(link);
|
|
104
|
+
io.log(
|
|
105
|
+
opened
|
|
106
|
+
? "Opening your browser to approve this login."
|
|
107
|
+
: "Open this address in a browser, on any device where you are signed in:",
|
|
108
|
+
);
|
|
109
|
+
io.log(` ${link}`);
|
|
110
|
+
io.log(`Your code is ${userCode} (it expires in ${Math.round(expiresIn / 60)} minutes). Waiting for approval...`);
|
|
111
|
+
|
|
112
|
+
const deadline = now() + expiresIn * 1000;
|
|
113
|
+
let transient = 0;
|
|
114
|
+
while (now() < deadline) {
|
|
115
|
+
await io.sleep(interval * 1000);
|
|
116
|
+
let response: Response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetch(`${apiUrl}/v1/device/token`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: deviceCode, client_id: CLI_CLIENT_ID }),
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
if (++transient > 5) throw new CloudError(`lost contact with ${apiUrl} while waiting for approval`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
transient = 0;
|
|
128
|
+
const body = parseBody(await response.text());
|
|
129
|
+
if (response.ok) return deviceResult(body);
|
|
130
|
+
const code = body?.error;
|
|
131
|
+
if (response.status === 429 || code === "slow_down") {
|
|
132
|
+
interval += 5;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (code === "authorization_pending") continue;
|
|
136
|
+
if (code === "access_denied") throw new CloudError("the login was denied in the browser");
|
|
137
|
+
if (code === "expired_token")
|
|
138
|
+
throw new CloudError("the code expired before it was approved; run sproutboat login again");
|
|
139
|
+
throw new CloudError(`login failed: ${errorText(body, response.status)}`);
|
|
140
|
+
}
|
|
141
|
+
throw new CloudError("the code expired before it was approved; run sproutboat login again");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function deviceResult(body: JsonObject | undefined): DeviceLoginResult {
|
|
145
|
+
const organization = jsonObject(body?.organization ?? null);
|
|
146
|
+
const token = body?.access_token;
|
|
147
|
+
const slug = organization?.slug;
|
|
148
|
+
if (!isString(token) || !isString(slug)) throw new CloudError("the login reply did not carry a key and a team");
|
|
149
|
+
const team: StoredTeam = {
|
|
150
|
+
token,
|
|
151
|
+
id: isString(organization?.id) ? organization.id : "",
|
|
152
|
+
name: isString(organization?.name) ? organization.name : slug,
|
|
153
|
+
role: isString(body?.role) ? body.role : "",
|
|
154
|
+
keyId: isString(body?.key_id) ? body.key_id : "",
|
|
155
|
+
keyName: isString(body?.key_name) ? body.key_name : "",
|
|
156
|
+
};
|
|
157
|
+
return { token, keyId: team.keyId, keyName: team.keyName, slug, team };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type Whoami = {
|
|
161
|
+
organizationId: string;
|
|
162
|
+
slug: string;
|
|
163
|
+
name: string;
|
|
164
|
+
keyId: string;
|
|
165
|
+
keyName: string;
|
|
166
|
+
permissions: string;
|
|
167
|
+
person?: { name: string; email: string };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export function authHeaders(token: string) {
|
|
171
|
+
return { authorization: `Bearer ${token}` };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What a failed API response means to the person at the terminal. */
|
|
175
|
+
export function explainApiFailure(status: number, text: string, fromEnv: boolean): string {
|
|
176
|
+
const body = parseBody(text);
|
|
177
|
+
const detail = errorText(body, status);
|
|
178
|
+
if (status === 403 && body?.error === "module-upload-disabled")
|
|
179
|
+
return "modules are built by the Cloud; run `sproutboat deploy` without --module";
|
|
180
|
+
if (status === 401)
|
|
181
|
+
return fromEnv
|
|
182
|
+
? "SPROUTBOAT_API_TOKEN was rejected (invalid, expired or revoked); create a new key in Settings, API keys"
|
|
183
|
+
: "this machine is not logged in, or its key was revoked; run `sproutboat login`";
|
|
184
|
+
if (status === 403) {
|
|
185
|
+
const need = isString(body?.need) ? ` (needs ${body.need})` : "";
|
|
186
|
+
return `${detail}${need}: this key's role cannot do that; ask a team Owner or Admin, or use another team with --account`;
|
|
187
|
+
}
|
|
188
|
+
if (status === 413) return `${detail}: too large for the Cloud`;
|
|
189
|
+
if (status === 429 && body?.reason === "rate") {
|
|
190
|
+
const seconds = isSafeInteger(body.retry_after) ? ` in ${body.retry_after} s` : " shortly";
|
|
191
|
+
return `too many requests for this team; try again${seconds}`;
|
|
192
|
+
}
|
|
193
|
+
if (status === 503 && body?.reason === "audit-unavailable")
|
|
194
|
+
return "the Cloud could not record this request in its audit log, so it was not served; try again shortly";
|
|
195
|
+
return `${detail} (HTTP ${status})`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Ask the Cloud who a key belongs to. */
|
|
199
|
+
export async function fetchWhoami(apiUrl: string, token: string, fromEnv: boolean): Promise<Whoami> {
|
|
200
|
+
const response = await fetchOrExplain(apiUrl, `${apiUrl}/v1/whoami`, { headers: authHeaders(token) });
|
|
201
|
+
const text = await response.text();
|
|
202
|
+
if (!response.ok) throw new CloudError(explainApiFailure(response.status, text, fromEnv));
|
|
203
|
+
const body = parseBody(text);
|
|
204
|
+
const key = jsonObject(body?.key ?? null);
|
|
205
|
+
const slug = body?.organization_slug;
|
|
206
|
+
if (!isString(slug)) throw new CloudError("this key does not belong to a team (is it an account API key, sbk_...?)");
|
|
207
|
+
const created = jsonObject(body?.created_by ?? null);
|
|
208
|
+
const permissions = jsonObject(key?.permissions ?? null);
|
|
209
|
+
return {
|
|
210
|
+
organizationId: isString(body?.organization) ? body.organization : "",
|
|
211
|
+
slug,
|
|
212
|
+
name: isString(body?.organization_name) ? body.organization_name : slug,
|
|
213
|
+
keyId: isString(key?.id) ? key.id : "",
|
|
214
|
+
keyName: isString(key?.name) ? key.name : "",
|
|
215
|
+
permissions: permissions
|
|
216
|
+
? Object.entries(permissions)
|
|
217
|
+
.map(([resource, actions]) => `${resource}:${Array.isArray(actions) ? actions.join("/") : ""}`)
|
|
218
|
+
.join(" ")
|
|
219
|
+
: "",
|
|
220
|
+
person: isString(created?.email)
|
|
221
|
+
? { name: isString(created.name) ? created.name : "", email: created.email }
|
|
222
|
+
: undefined,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Delete the key on the server. `unreachable` and `already-gone` still allow removing the local copy. */
|
|
227
|
+
export async function revokeKey(apiUrl: string, token: string): Promise<"revoked" | "already-gone" | "unreachable"> {
|
|
228
|
+
let response: Response;
|
|
229
|
+
try {
|
|
230
|
+
response = await fetch(`${apiUrl}/v1/keys/self`, { method: "DELETE", headers: authHeaders(token) });
|
|
231
|
+
} catch {
|
|
232
|
+
return "unreachable";
|
|
233
|
+
}
|
|
234
|
+
await response.text();
|
|
235
|
+
if (response.ok) return "revoked";
|
|
236
|
+
if (response.status === 401) return "already-gone";
|
|
237
|
+
throw new CloudError(`the Cloud refused to revoke the key (HTTP ${response.status}); the local login was kept`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type TokenSource = "SPROUTBOAT_API_TOKEN" | "SPROUTBOAT_TOKEN" | "stored login";
|
|
241
|
+
|
|
242
|
+
/** The environment key, if any. SPROUTBOAT_API_TOKEN wins; SPROUTBOAT_TOKEN is the older name. */
|
|
243
|
+
export function envToken(
|
|
244
|
+
env: Record<string, string | undefined> = process.env,
|
|
245
|
+
): { token: string; source: TokenSource } | undefined {
|
|
246
|
+
if (env.SPROUTBOAT_API_TOKEN) return { token: env.SPROUTBOAT_API_TOKEN, source: "SPROUTBOAT_API_TOKEN" };
|
|
247
|
+
if (env.SPROUTBOAT_TOKEN) return { token: env.SPROUTBOAT_TOKEN, source: "SPROUTBOAT_TOKEN" };
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export type CloudSession = { apiUrl: string; token: string; source: TokenSource; slug: string; name: string };
|
|
252
|
+
|
|
253
|
+
export type SessionOptions = {
|
|
254
|
+
apiUrl: string;
|
|
255
|
+
io: CloudIo;
|
|
256
|
+
/** `--account`, then the `account` field of sproutboat.jsonc, already merged by the caller. */
|
|
257
|
+
account?: string;
|
|
258
|
+
env?: Record<string, string | undefined>;
|
|
259
|
+
/** When logged out in a terminal: run the login (and store it), then continue. */
|
|
260
|
+
login: (account?: string) => Promise<StoredLogin>;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export type StoredLogin = { slug: string; team: StoredTeam };
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The credential and team a Cloud command acts for. The environment key always wins (CI); its team is the key's
|
|
267
|
+
* own. Otherwise the stored teams: the requested one, the only one, or a choice when there are several.
|
|
268
|
+
*/
|
|
269
|
+
export async function resolveSession(options: SessionOptions): Promise<CloudSession> {
|
|
270
|
+
const { apiUrl, io } = options;
|
|
271
|
+
const env = options.env ?? process.env;
|
|
272
|
+
const requested = options.account ?? (env.SPROUTBOAT_ACCOUNT || undefined);
|
|
273
|
+
const fromEnv = envToken(env);
|
|
274
|
+
if (fromEnv) {
|
|
275
|
+
const who = await fetchWhoami(apiUrl, fromEnv.token, true);
|
|
276
|
+
if (requested && requested !== who.slug)
|
|
277
|
+
throw new CloudError(
|
|
278
|
+
`${fromEnv.source} belongs to the team "${who.slug}", but the account is "${requested}"; use a key from that team`,
|
|
279
|
+
);
|
|
280
|
+
return { apiUrl, token: fromEnv.token, source: fromEnv.source, slug: who.slug, name: who.name };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const profile = await savedProfile(apiUrl);
|
|
284
|
+
if (!profile) {
|
|
285
|
+
if (!io.interactive)
|
|
286
|
+
throw new CloudError(
|
|
287
|
+
`not logged in to ${apiUrl}. Run \`sproutboat login\`, or set SPROUTBOAT_API_TOKEN to an account API key (for CI)`,
|
|
288
|
+
);
|
|
289
|
+
io.log(`Not logged in to ${apiUrl}; starting the login.`);
|
|
290
|
+
const done = await options.login(requested);
|
|
291
|
+
return { apiUrl, token: done.team.token, source: "stored login", slug: done.slug, name: done.team.name };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const stored = await teamsOf(apiUrl, profile);
|
|
295
|
+
const slugs = Object.keys(stored);
|
|
296
|
+
let slug: string;
|
|
297
|
+
if (requested) {
|
|
298
|
+
if (!stored[requested])
|
|
299
|
+
throw new CloudError(
|
|
300
|
+
`not logged in to the team "${requested}" on ${apiUrl}; run \`sproutboat login --account ${requested}\``,
|
|
301
|
+
);
|
|
302
|
+
slug = requested;
|
|
303
|
+
} else if (slugs.length === 1) slug = slugs[0]!;
|
|
304
|
+
else if (io.interactive) slug = await io.choose("Which team should this deploy go to?", slugs);
|
|
305
|
+
else
|
|
306
|
+
throw new CloudError(
|
|
307
|
+
`you are logged in to ${slugs.length} teams (${slugs.join(", ")}); choose one with \`account\` in sproutboat.jsonc, --account or SPROUTBOAT_ACCOUNT`,
|
|
308
|
+
);
|
|
309
|
+
const team = stored[slug]!;
|
|
310
|
+
return { apiUrl, token: team.token, source: "stored login", slug, name: team.name || slug };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A profile written by `login --token` before the team was known has no teams: ask the Cloud which one it is. */
|
|
314
|
+
async function teamsOf(apiUrl: string, profile: Profile): Promise<Record<string, StoredTeam>> {
|
|
315
|
+
if (Object.keys(profile.teams).length > 0) return profile.teams;
|
|
316
|
+
const who = await fetchWhoami(apiUrl, profile.token, false);
|
|
317
|
+
return {
|
|
318
|
+
[who.slug]: {
|
|
319
|
+
token: profile.token,
|
|
320
|
+
id: who.organizationId,
|
|
321
|
+
name: who.name,
|
|
322
|
+
role: "",
|
|
323
|
+
keyId: who.keyId,
|
|
324
|
+
keyName: who.keyName,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Run the device flow and keep the key. */
|
|
330
|
+
export async function loginAndStore(
|
|
331
|
+
options: DeviceLoginOptions,
|
|
332
|
+
): Promise<StoredLogin & SaveResult & { keyName: string }> {
|
|
333
|
+
const result = await deviceLogin(options);
|
|
334
|
+
const saved = await saveTeam(options.apiUrl, result.slug, result.team);
|
|
335
|
+
if (options.account && options.account !== result.slug)
|
|
336
|
+
options.io.warn(`You approved the team "${result.slug}", not "${options.account}".`);
|
|
337
|
+
return { slug: result.slug, team: result.team, ...saved, keyName: result.keyName };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Store a key the person already holds (`login --token`), after asking the Cloud whose it is. */
|
|
341
|
+
export async function storeGivenKey(apiUrl: string, token: string): Promise<StoredLogin> {
|
|
342
|
+
const who = await fetchWhoami(apiUrl, token, false);
|
|
343
|
+
const team: StoredTeam = {
|
|
344
|
+
token,
|
|
345
|
+
id: who.organizationId,
|
|
346
|
+
name: who.name,
|
|
347
|
+
role: "",
|
|
348
|
+
keyId: who.keyId,
|
|
349
|
+
keyName: who.keyName,
|
|
350
|
+
};
|
|
351
|
+
await saveTeam(apiUrl, who.slug, team);
|
|
352
|
+
return { slug: who.slug, team };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export type LogoutOutcome =
|
|
356
|
+
| { kind: "none" }
|
|
357
|
+
| { kind: "done"; slug: string; server: "revoked" | "already-gone" | "unreachable"; remaining: string[] };
|
|
358
|
+
|
|
359
|
+
/** Revoke the key server side when possible, then forget it here. Without `account`, the default team. */
|
|
360
|
+
export async function cloudLogout(apiUrl: string, account?: string): Promise<LogoutOutcome> {
|
|
361
|
+
const profile = await savedProfile(apiUrl);
|
|
362
|
+
if (!profile) return { kind: "none" };
|
|
363
|
+
const slug = account ?? profile.team ?? Object.keys(profile.teams)[0];
|
|
364
|
+
const team = slug === undefined ? undefined : profile.teams[slug];
|
|
365
|
+
if (slug === undefined || !team) {
|
|
366
|
+
if (account !== undefined || Object.keys(profile.teams).length > 0) return { kind: "none" };
|
|
367
|
+
// A key stored without a team: revoke it and drop the endpoint.
|
|
368
|
+
const server = await revokeKey(apiUrl, profile.token);
|
|
369
|
+
await forgetToken(apiUrl);
|
|
370
|
+
return { kind: "done", slug: "(unknown team)", server, remaining: [] };
|
|
371
|
+
}
|
|
372
|
+
const server = await revokeKey(apiUrl, team.token);
|
|
373
|
+
const result = await forgetToken(apiUrl, slug);
|
|
374
|
+
return { kind: "done", slug, server, remaining: result.remaining };
|
|
375
|
+
}
|
package/src/compile.ts
CHANGED
|
@@ -123,6 +123,8 @@ export type CompileInput = {
|
|
|
123
123
|
* file. Anything that could be deployed stays at -O3.
|
|
124
124
|
*/
|
|
125
125
|
optimize?: "dev" | "release";
|
|
126
|
+
/** Link-time optimization on the alpha 22 standalone link; default on. See `BuildInput.lto`. */
|
|
127
|
+
lto?: boolean;
|
|
126
128
|
/** Cross-compiler for a Linux target. Not needed, and not used, for `host`. */
|
|
127
129
|
zigBin?: string;
|
|
128
130
|
/** #126 — `env.<binding> = { id, tag, timestamp }`, baked directly into the module. */
|
|
@@ -273,6 +275,16 @@ export function porfforArgs(
|
|
|
273
275
|
return args;
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Whether the alpha 22 link runs LTO: `SPROUTBOAT_FLTO` wins, then an explicit `lto`, then on for everything
|
|
280
|
+
* except a host `dev` build.
|
|
281
|
+
*/
|
|
282
|
+
export function ltoEnabled(input: { optimize?: "dev" | "release"; target?: string; lto?: boolean }): boolean {
|
|
283
|
+
if (process.env.SPROUTBOAT_FLTO) return process.env.SPROUTBOAT_FLTO === "1";
|
|
284
|
+
if (input.lto !== undefined) return input.lto;
|
|
285
|
+
return !(input.optimize === "dev" && input.target === "host");
|
|
286
|
+
}
|
|
287
|
+
|
|
276
288
|
/** The wrapped module the compiler sees: `generatedSource` if given, else the source wrapped here. */
|
|
277
289
|
async function generateSource(input: CompileInput): Promise<string> {
|
|
278
290
|
if (input.generatedSource !== undefined) return input.generatedSource;
|
|
@@ -505,9 +517,10 @@ async function compileSproutA21(
|
|
|
505
517
|
extraLink: [...(input.extraLink ?? []), ...build.restoreObjects],
|
|
506
518
|
extraCflags: [...(input.extraCflags ?? []), ...(input.withRestore ? ["-Dmain=sb_app_main"] : [])],
|
|
507
519
|
optimize: input.optimize === "dev" && host ? 0 : 3,
|
|
508
|
-
// LTO on for
|
|
509
|
-
// +7% on stress, and 4% smaller)
|
|
510
|
-
|
|
520
|
+
// LTO on for optimized builds, zig's musl cross builds included (romeo, M4: +11% req/s on hello,
|
|
521
|
+
// +7% on stress, and 4% smaller), except a standalone build without --release (`lto: false`),
|
|
522
|
+
// which skips it to link faster; SPROUTBOAT_FLTO=0 turns it off for measurements
|
|
523
|
+
flto: ltoEnabled(input),
|
|
511
524
|
musl: !host,
|
|
512
525
|
strip: true,
|
|
513
526
|
});
|