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,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat deploy` against the Sproutboat Cloud: upload the compiled module by digest, the assets by hash,
|
|
3
|
+
* write the app, and wait for a host to apply it. The same calls tools/sbcloud-deploy makes in the cloud repo.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { readAssetRules, walkAssets, type AssetManifest } from "./assets";
|
|
9
|
+
import type { SproutboatConfig } from "./config";
|
|
10
|
+
import { authHeaders, CloudError, explainApiFailure, parseBody, type CloudIo } from "./cloud";
|
|
11
|
+
import { isSafeInteger, isString, jsonObject, type JsonObject, type JsonValue } from "./json";
|
|
12
|
+
import { dim, ok } from "./style";
|
|
13
|
+
|
|
14
|
+
export const CLOUD_PROJECT_RE = /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/;
|
|
15
|
+
|
|
16
|
+
/** Compiler progress lines ("parsed", "generated IR", "[12.3ms] optimized") say how far it got, not why it stopped. */
|
|
17
|
+
const PROGRESS =
|
|
18
|
+
/^(\[[^\]]*\]\s*)?(\S+\s+)?(parsed|generated IR|optimi[sz]ed|compiled|linked|assembled|lowered|wrote)\b[^a-z]*$/i;
|
|
19
|
+
const REASON = /error|fail|time(d)? ?out|exceed|reject|unsupported|cannot|can't|not allowed|killed|stopped/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The one line the final "build failed:" shows: the most informative line of the Cloud's message. A reason beats progress
|
|
23
|
+
* lines, the last reason beats the first (the compiler's own message comes after its warnings), and with no reason at all the
|
|
24
|
+
* first line is shown. The full text stays in the build log above.
|
|
25
|
+
*/
|
|
26
|
+
export function buildFailureHeadline(message: string | undefined): string {
|
|
27
|
+
const lines = (message ?? "")
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((l) => l.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
if (lines.length === 0) return "no reason given";
|
|
32
|
+
const real = lines.filter((l) => !PROGRESS.test(l));
|
|
33
|
+
const reasons = real.filter((l) => REASON.test(l));
|
|
34
|
+
return (reasons.at(-1) ?? real.at(-1) ?? lines[0])!;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const hostFor = (project: string, team: string): string => `${project}.${team}.sproutboat.app`;
|
|
38
|
+
|
|
39
|
+
export type AssetBundle = {
|
|
40
|
+
/** The manifest JSON as it is uploaded; its sha256 names it. */
|
|
41
|
+
manifestText: string;
|
|
42
|
+
manifestSha: string;
|
|
43
|
+
/** Blob digest to the file that holds it. */
|
|
44
|
+
blobs: Map<string, string>;
|
|
45
|
+
files: number;
|
|
46
|
+
bytes: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The same manifest `sproutboat build` writes as assets.json, read straight from the project's assets directory. */
|
|
50
|
+
export function buildAssetBundle(projectDir: string, assets: NonNullable<SproutboatConfig["assets"]>): AssetBundle {
|
|
51
|
+
const dir = resolve(projectDir, assets.directory);
|
|
52
|
+
if (!existsSync(dir))
|
|
53
|
+
throw new CloudError(`assets.directory "${assets.directory}" not found; run your site build first`);
|
|
54
|
+
const manifest: AssetManifest = {
|
|
55
|
+
notFound: assets.not_found_handling ?? "none",
|
|
56
|
+
runSproutFirst: assets.run_sprout_first ?? false,
|
|
57
|
+
files: walkAssets(dir),
|
|
58
|
+
};
|
|
59
|
+
const rules = readAssetRules(dir);
|
|
60
|
+
if (rules.headers.length) manifest.headers = rules.headers;
|
|
61
|
+
if (rules.redirects.length) manifest.redirects = rules.redirects;
|
|
62
|
+
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
63
|
+
const blobs = new Map<string, string>();
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
for (const [key, entry] of Object.entries(manifest.files)) {
|
|
66
|
+
blobs.set(entry.hash, resolve(dir, `.${key}`));
|
|
67
|
+
bytes += entry.size;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
manifestText,
|
|
71
|
+
manifestSha: createHash("sha256").update(manifestText).digest("hex"),
|
|
72
|
+
blobs,
|
|
73
|
+
files: Object.keys(manifest.files).length,
|
|
74
|
+
bytes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The platform runtime gates service bindings between Cloud apps behind this compatibility date. */
|
|
79
|
+
export const SERVICE_BINDINGS_MIN_DATE = "2026-11-18";
|
|
80
|
+
|
|
81
|
+
export type ServiceRef = { binding: string; service: string };
|
|
82
|
+
type ServiceBindingEntry = { name: string; kind: "service"; resource_id: null; config: Record<string, never> };
|
|
83
|
+
/** What the app spec carries for service bindings: the binding entries and the callee app id per binding. */
|
|
84
|
+
export type ServiceWiring = { bindings: ServiceBindingEntry[]; services: Record<string, string> };
|
|
85
|
+
|
|
86
|
+
/** What a deploy adds to the app spec beyond module, hosts and assets. */
|
|
87
|
+
export type SpecExtras = {
|
|
88
|
+
/** Service bindings from sproutboat.jsonc (`services`), resolved against the team's Cloud apps before anything is written. */
|
|
89
|
+
services?: ServiceRef[];
|
|
90
|
+
compatibilityDate?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Merged into the app's secrets, never replacing the set. A module deploy sends them as `secrets_patch.set` in its PUT;
|
|
93
|
+
* a build deploy (whose spec is stored in plaintext) sets them with PATCH /v1/apps/<app>/secrets once the build is ready.
|
|
94
|
+
*/
|
|
95
|
+
secrets?: Record<string, string>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Refuse, before anything is uploaded, a service binding the Cloud would not run: too old a date, or the app itself. */
|
|
99
|
+
export function checkServiceBindings(project: string, services: ServiceRef[], compatibilityDate: string | undefined) {
|
|
100
|
+
if (services.length === 0) return;
|
|
101
|
+
for (const { binding, service } of services)
|
|
102
|
+
if (service === project)
|
|
103
|
+
throw new CloudError(
|
|
104
|
+
`service binding ${binding} points at this app (${project}); an app cannot bind itself, bind a different app`,
|
|
105
|
+
);
|
|
106
|
+
if (compatibilityDate === undefined || compatibilityDate < SERVICE_BINDINGS_MIN_DATE)
|
|
107
|
+
throw new CloudError(
|
|
108
|
+
`service bindings between Cloud apps need compatibility_date ${SERVICE_BINDINGS_MIN_DATE} or later (sproutboat.jsonc has ${compatibilityDate ?? "none"}); set compatibility_date to ${SERVICE_BINDINGS_MIN_DATE} or later`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Match each `service` to the team's Cloud app of that project name (GET /v1/apps); a missing callee fails the deploy. */
|
|
113
|
+
export async function resolveServiceBindings(
|
|
114
|
+
call: CallFn,
|
|
115
|
+
project: string,
|
|
116
|
+
services: ServiceRef[],
|
|
117
|
+
): Promise<ServiceWiring | undefined> {
|
|
118
|
+
if (services.length === 0) return undefined;
|
|
119
|
+
const listed = (await call({ method: "GET", path: "/v1/apps" })).body?.apps;
|
|
120
|
+
const ids = new Map<string, string>();
|
|
121
|
+
for (const entry of Array.isArray(listed) ? listed : []) {
|
|
122
|
+
const app = jsonObject(entry);
|
|
123
|
+
if (app && isString(app.project) && isString(app.app_id)) ids.set(app.project, app.app_id);
|
|
124
|
+
}
|
|
125
|
+
const wiring: ServiceWiring = { bindings: [], services: {} };
|
|
126
|
+
for (const { binding, service } of services) {
|
|
127
|
+
const id = ids.get(service);
|
|
128
|
+
if (id === undefined)
|
|
129
|
+
throw new CloudError(
|
|
130
|
+
`service binding ${binding} needs the app "${service}", which is not deployed in this team: deploy it first (run \`sproutboat deploy\` in its directory), then deploy ${project} again`,
|
|
131
|
+
);
|
|
132
|
+
wiring.bindings.push({ name: binding, kind: "service", resource_id: null, config: {} });
|
|
133
|
+
wiring.services[binding] = id;
|
|
134
|
+
}
|
|
135
|
+
return wiring;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function applyExtras(
|
|
139
|
+
spec: AppSpec | BuildSpec,
|
|
140
|
+
wiring: ServiceWiring | undefined,
|
|
141
|
+
secrets: Record<string, string> | undefined,
|
|
142
|
+
) {
|
|
143
|
+
if (wiring) {
|
|
144
|
+
spec.bindings = wiring.bindings;
|
|
145
|
+
spec.services = wiring.services;
|
|
146
|
+
}
|
|
147
|
+
if (secrets && Object.keys(secrets).length > 0) spec.secrets_patch = { set: secrets };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type CloudDeployPlan = SpecExtras & {
|
|
151
|
+
apiUrl: string;
|
|
152
|
+
token: string;
|
|
153
|
+
tokenFromEnv: boolean;
|
|
154
|
+
project: string;
|
|
155
|
+
hosts: string[];
|
|
156
|
+
modulePath: string;
|
|
157
|
+
assets?: AssetBundle;
|
|
158
|
+
noWait: boolean;
|
|
159
|
+
/** Milliseconds to wait for a host to apply the record. */
|
|
160
|
+
waitMs?: number;
|
|
161
|
+
pollMs?: number;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type CloudDeployResult = {
|
|
165
|
+
created: boolean;
|
|
166
|
+
generation: number;
|
|
167
|
+
url: string | undefined;
|
|
168
|
+
applied: boolean | "skipped";
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
type SpecAdditions = {
|
|
172
|
+
bindings?: ServiceBindingEntry[];
|
|
173
|
+
services?: Record<string, string>;
|
|
174
|
+
secrets_patch?: { set: Record<string, string> };
|
|
175
|
+
};
|
|
176
|
+
type AppSpec = { module: { sha256: string }; hosts: string[]; assets_manifest_ref?: string } & SpecAdditions;
|
|
177
|
+
type Call = { method: string; path: string; body?: BodyInit; json?: object; ok?: number[] };
|
|
178
|
+
type CallResult = { status: number; text: string; body?: JsonObject; headers: Headers };
|
|
179
|
+
type CallFn = (c: Call) => Promise<CallResult>;
|
|
180
|
+
export type CloudCredentials = Pick<CloudDeployPlan, "apiUrl" | "token" | "tokenFromEnv">;
|
|
181
|
+
|
|
182
|
+
/** One authenticated call to the Cloud API; a non-2xx status that is not in `ok` becomes a CloudError. */
|
|
183
|
+
export function makeCall(plan: CloudCredentials): CallFn {
|
|
184
|
+
return async (c) => {
|
|
185
|
+
const headers = new Headers(authHeaders(plan.token));
|
|
186
|
+
let body = c.body;
|
|
187
|
+
if (c.json !== undefined) {
|
|
188
|
+
headers.set("content-type", "application/json");
|
|
189
|
+
body = JSON.stringify(c.json);
|
|
190
|
+
} else if (body !== undefined) headers.set("content-type", "application/octet-stream");
|
|
191
|
+
let response: Response;
|
|
192
|
+
try {
|
|
193
|
+
response = await fetch(`${plan.apiUrl}${c.path}`, { method: c.method, headers, body });
|
|
194
|
+
} catch {
|
|
195
|
+
throw new CloudError(`cannot reach ${plan.apiUrl}; check the address and your network`);
|
|
196
|
+
}
|
|
197
|
+
const text = await response.text();
|
|
198
|
+
if (!response.ok && !(c.ok ?? []).includes(response.status))
|
|
199
|
+
throw new CloudError(explainApiFailure(response.status, text, plan.tokenFromEnv));
|
|
200
|
+
return { status: response.status, text, body: parseBody(text), headers: response.headers };
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** What the app holds now: tells a first deploy from an update, and keeps its assets when this deploy has none. */
|
|
205
|
+
async function resolveAssets(
|
|
206
|
+
call: CallFn,
|
|
207
|
+
plan: { project: string; assets?: AssetBundle },
|
|
208
|
+
io: CloudIo,
|
|
209
|
+
): Promise<{ created: boolean; assetsRef: string | undefined }> {
|
|
210
|
+
const current = await call({ method: "GET", path: `/v1/apps/${plan.project}`, ok: [404] });
|
|
211
|
+
const created = current.status === 404;
|
|
212
|
+
let assetsRef: string | undefined;
|
|
213
|
+
if (plan.assets) {
|
|
214
|
+
await pushAssets(call, plan.assets, io);
|
|
215
|
+
assetsRef = `assets/${plan.assets.manifestSha}`;
|
|
216
|
+
} else if (!created) {
|
|
217
|
+
const spec = jsonObject(current.body?.spec ?? null);
|
|
218
|
+
const ref = spec?.assets_manifest_ref;
|
|
219
|
+
if (isString(ref)) {
|
|
220
|
+
assetsRef = ref;
|
|
221
|
+
io.log(` assets keeping the current ${ref.slice(0, 19)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { created, assetsRef };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Wait for a host to apply `generation`; false at the deadline. */
|
|
228
|
+
export async function waitForApply(
|
|
229
|
+
call: CallFn,
|
|
230
|
+
plan: { project: string; waitMs?: number; pollMs?: number },
|
|
231
|
+
generation: number,
|
|
232
|
+
io: CloudIo,
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<boolean> {
|
|
235
|
+
const deadline = Date.now() + (plan.waitMs ?? 120_000);
|
|
236
|
+
io.log(` record generation ${generation} written; waiting for a host to apply it`);
|
|
237
|
+
for (;;) {
|
|
238
|
+
if (signal?.aborted)
|
|
239
|
+
throw new Interrupted(`interrupted; generation ${generation} is saved and applies when a host is available`);
|
|
240
|
+
const state = await call({ method: "GET", path: `/v1/apps/${plan.project}` });
|
|
241
|
+
const apply = jsonObject(state.body?.apply ?? null);
|
|
242
|
+
const phase = apply?.state;
|
|
243
|
+
if (phase === "failed" || phase === "fenced")
|
|
244
|
+
throw new CloudError(
|
|
245
|
+
`the host did not apply generation ${generation} (${phase}): ${isString(apply?.error) ? apply.error : "no reason given"}`,
|
|
246
|
+
);
|
|
247
|
+
const applied = apply?.applied_generation;
|
|
248
|
+
if (phase === "applied" && isSafeInteger(applied) && applied >= generation) return true;
|
|
249
|
+
if (Date.now() >= deadline) return false;
|
|
250
|
+
await io.sleep(plan.pollMs ?? 2000);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function deployToCloud(plan: CloudDeployPlan, io: CloudIo): Promise<CloudDeployResult> {
|
|
255
|
+
const call = makeCall(plan);
|
|
256
|
+
checkServiceBindings(plan.project, plan.services ?? [], plan.compatibilityDate);
|
|
257
|
+
const wiring = await resolveServiceBindings(call, plan.project, plan.services ?? []);
|
|
258
|
+
const { created, assetsRef } = await resolveAssets(call, plan, io);
|
|
259
|
+
|
|
260
|
+
const bytes = new Uint8Array(readFileSync(plan.modulePath));
|
|
261
|
+
const sha = createHash("sha256").update(bytes).digest("hex");
|
|
262
|
+
io.log(` module ${sha.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KiB)`);
|
|
263
|
+
await call({ method: "PUT", path: `/v1/modules/${sha}`, body: bytes });
|
|
264
|
+
|
|
265
|
+
const spec: AppSpec = { module: { sha256: sha }, hosts: plan.hosts };
|
|
266
|
+
if (assetsRef !== undefined) spec.assets_manifest_ref = assetsRef;
|
|
267
|
+
applyExtras(spec, wiring, plan.secrets);
|
|
268
|
+
const put = await call({ method: "PUT", path: `/v1/apps/${plan.project}`, json: spec });
|
|
269
|
+
const generation = put.body?.record_generation;
|
|
270
|
+
if (!isSafeInteger(generation)) throw new CloudError("the Cloud's reply carried no record generation");
|
|
271
|
+
const url = plan.hosts[0] === undefined ? undefined : `https://${plan.hosts[0]}`;
|
|
272
|
+
if (plan.noWait) return { created, generation, url, applied: "skipped" };
|
|
273
|
+
|
|
274
|
+
const applied = await waitForApply(call, plan, generation, io);
|
|
275
|
+
return { created, generation, url, applied };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function pushAssets(call: CallFn, bundle: AssetBundle, io: CloudIo): Promise<void> {
|
|
279
|
+
const hashes = [...bundle.blobs.keys()];
|
|
280
|
+
const missingBody = (await call({ method: "POST", path: "/v1/assets/missing", json: { sha256: hashes } })).body
|
|
281
|
+
?.missing;
|
|
282
|
+
const missing = Array.isArray(missingBody) ? missingBody.filter(isString) : [];
|
|
283
|
+
const queue = [...missing];
|
|
284
|
+
let uploaded = 0;
|
|
285
|
+
await Promise.all(
|
|
286
|
+
Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
287
|
+
for (let hash = queue.shift(); hash !== undefined; hash = queue.shift()) {
|
|
288
|
+
const file = bundle.blobs.get(hash);
|
|
289
|
+
if (file === undefined) throw new CloudError("the Cloud asked for a blob the manifest does not list");
|
|
290
|
+
await call({ method: "PUT", path: `/v1/assets/blobs/${hash}`, body: new Uint8Array(readFileSync(file)) });
|
|
291
|
+
uploaded++;
|
|
292
|
+
}
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
await call({
|
|
296
|
+
method: "PUT",
|
|
297
|
+
path: `/v1/assets/manifests/${bundle.manifestSha}`,
|
|
298
|
+
body: new TextEncoder().encode(bundle.manifestText),
|
|
299
|
+
});
|
|
300
|
+
io.log(` assets ${bundle.files} files, ${uploaded} of ${hashes.length} blobs uploaded`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export type ModuleInputs = {
|
|
304
|
+
project: string;
|
|
305
|
+
/** Emits sprout.generated.js for the project and returns its path. */
|
|
306
|
+
emit: () => Promise<string>;
|
|
307
|
+
sproutd?: string;
|
|
308
|
+
generation?: string;
|
|
309
|
+
out: string;
|
|
310
|
+
/** The bun to run compile-app with. */
|
|
311
|
+
bun: string;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Operators only: compile the app into a module the way tools/sbcloud-deploy does, the CLI's standalone emit and
|
|
316
|
+
* then sproutd's compile-app against a built generation. Regular deploys send the source to the Cloud to build.
|
|
317
|
+
*/
|
|
318
|
+
export async function compileModule(input: ModuleInputs): Promise<void> {
|
|
319
|
+
const { sproutd, generation } = input;
|
|
320
|
+
if (!sproutd || !generation)
|
|
321
|
+
throw new CloudError(
|
|
322
|
+
[
|
|
323
|
+
"compiling a module here needs both a sproutd checkout and a built generation (operators only):",
|
|
324
|
+
" sproutboat deploy --sproutd <sproutd checkout> --generation <built generation>",
|
|
325
|
+
" (or SPROUTBOAT_SPROUTD and SPROUTBOAT_GENERATION in the environment)",
|
|
326
|
+
"Regular deploys are built by the Cloud: run `sproutboat deploy` without these flags.",
|
|
327
|
+
].join("\n"),
|
|
328
|
+
);
|
|
329
|
+
const compiler = resolve(sproutd, "build/compile-app.ts");
|
|
330
|
+
if (!existsSync(compiler)) throw new CloudError(`${compiler} not found (--sproutd)`);
|
|
331
|
+
if (!existsSync(resolve(generation, "manifest.json")))
|
|
332
|
+
throw new CloudError(`${resolve(generation)} is not a built generation (--generation)`);
|
|
333
|
+
const unit = await input.emit();
|
|
334
|
+
const run = Bun.spawnSync([input.bun, compiler, resolve(generation), unit, input.out, "--name", input.project], {
|
|
335
|
+
stdout: "pipe",
|
|
336
|
+
stderr: "pipe",
|
|
337
|
+
});
|
|
338
|
+
const stdout = run.stdout.toString();
|
|
339
|
+
if (run.exitCode !== 0)
|
|
340
|
+
throw new CloudError(
|
|
341
|
+
`the compile was not admitted (exit ${run.exitCode}):\n${(stdout + run.stderr.toString()).trim().slice(-2000)}`,
|
|
342
|
+
);
|
|
343
|
+
if (!existsSync(input.out) || Bun.file(input.out).size === 0)
|
|
344
|
+
throw new CloudError(`the compiler reported success but wrote no module to ${input.out}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- Deploy from source: the Cloud builds the module (docs/builds.md in the cloud repo) ----
|
|
348
|
+
|
|
349
|
+
/** The Cloud accepts a source of at most 8 MiB. */
|
|
350
|
+
export const SOURCE_MAX_BYTES = 8 * 1024 * 1024;
|
|
351
|
+
|
|
352
|
+
/** Ctrl-C while the build runs: the build was asked to cancel (exit 130). */
|
|
353
|
+
export class Interrupted extends CloudError {}
|
|
354
|
+
|
|
355
|
+
type BuildSpec = { hosts: string[]; assets_manifest_ref?: string } & SpecAdditions;
|
|
356
|
+
type BuildCli = { version: string; runtime?: string; toolchain?: string };
|
|
357
|
+
|
|
358
|
+
export type BuildPlan = CloudCredentials &
|
|
359
|
+
SpecExtras & {
|
|
360
|
+
project: string;
|
|
361
|
+
hosts: string[];
|
|
362
|
+
/** The bundled source (sprout.generated.js, the "generated" input kind). */
|
|
363
|
+
sourcePath: string;
|
|
364
|
+
cli: BuildCli;
|
|
365
|
+
assets?: AssetBundle;
|
|
366
|
+
noWait: boolean;
|
|
367
|
+
waitMs?: number;
|
|
368
|
+
pollMs?: number;
|
|
369
|
+
/** Pause before an event stream is reopened. */
|
|
370
|
+
retryMs?: number;
|
|
371
|
+
/** Event stream reconnects before falling back to polling. */
|
|
372
|
+
maxReconnects?: number;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
export type BuildView = {
|
|
376
|
+
id: string;
|
|
377
|
+
state: string;
|
|
378
|
+
cached: boolean;
|
|
379
|
+
position: number | null;
|
|
380
|
+
/** Why a queued build is held back (not a failure), e.g. waiting for memory. */
|
|
381
|
+
waiting: string | null;
|
|
382
|
+
error: { code?: string; message: string } | null;
|
|
383
|
+
recordGeneration: number | undefined;
|
|
384
|
+
stats: { wallMs: number | null; peakMemoryBytes: number | null; memoryCapBytes: number | null };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
export type BuildDeployResult = {
|
|
388
|
+
created: boolean;
|
|
389
|
+
/** Undefined when --no-wait returned before the build finished. */
|
|
390
|
+
generation: number | undefined;
|
|
391
|
+
url: string | undefined;
|
|
392
|
+
applied: boolean | "skipped";
|
|
393
|
+
buildId: string;
|
|
394
|
+
build: BuildView;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const int = (value: JsonValue | undefined): number | null => (isSafeInteger(value) ? value : null);
|
|
398
|
+
|
|
399
|
+
export function parseBuildView(body: JsonObject | undefined): BuildView {
|
|
400
|
+
const id = body?.id;
|
|
401
|
+
const state = body?.state;
|
|
402
|
+
if (!isString(id) || !isString(state)) throw new CloudError("the Cloud's reply was not a build");
|
|
403
|
+
const error = jsonObject(body?.error ?? null);
|
|
404
|
+
const stats = jsonObject(body?.stats ?? null);
|
|
405
|
+
return {
|
|
406
|
+
id,
|
|
407
|
+
state,
|
|
408
|
+
cached: body?.cached === true,
|
|
409
|
+
position: int(body?.position),
|
|
410
|
+
waiting: isString(body?.waiting) ? body.waiting : null,
|
|
411
|
+
error:
|
|
412
|
+
error && isString(error.message)
|
|
413
|
+
? { code: isString(error.code) ? error.code : undefined, message: error.message }
|
|
414
|
+
: null,
|
|
415
|
+
recordGeneration: isSafeInteger(body?.record_generation) ? body.record_generation : undefined,
|
|
416
|
+
stats: {
|
|
417
|
+
wallMs: int(stats?.wall_ms),
|
|
418
|
+
peakMemoryBytes: int(stats?.peak_memory_bytes),
|
|
419
|
+
memoryCapBytes: int(stats?.memory_cap_bytes),
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export const formatKiB = (bytes: number): string => `${Math.max(1, Math.round(bytes / 1024))} KiB`;
|
|
425
|
+
export const formatMB = (bytes: number): string => `${Math.round(bytes / 1048576)} MB`;
|
|
426
|
+
export const formatSeconds = (ms: number): string => `${(ms / 1000).toFixed(1)} s`;
|
|
427
|
+
|
|
428
|
+
/** The text after the tick: "Built in 4.1 s · peak memory 742 MB (cap 1200 MB)". */
|
|
429
|
+
export function formatBuildSummary(view: BuildView): string {
|
|
430
|
+
if (view.cached) return "Built (cached, identical to an earlier build)";
|
|
431
|
+
const parts: string[] = [];
|
|
432
|
+
const { wallMs, peakMemoryBytes, memoryCapBytes } = view.stats;
|
|
433
|
+
if (wallMs !== null) parts.push(`Built in ${formatSeconds(wallMs)}`);
|
|
434
|
+
if (peakMemoryBytes !== null) {
|
|
435
|
+
const cap = memoryCapBytes !== null ? ` (cap ${formatMB(memoryCapBytes)})` : "";
|
|
436
|
+
parts.push(`peak memory ${formatMB(peakMemoryBytes)}${cap}`);
|
|
437
|
+
}
|
|
438
|
+
return parts.length === 0
|
|
439
|
+
? "Built"
|
|
440
|
+
: parts[0]!.startsWith("Built")
|
|
441
|
+
? parts.join(" · ")
|
|
442
|
+
: `Built · ${parts.join(" · ")}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The "Building" status line for a queued or running build. */
|
|
446
|
+
export function formatBuildState(state: string, position: number | null, waiting: string | null = null): string {
|
|
447
|
+
if (state === "queued") {
|
|
448
|
+
const queued = position === null ? "queued" : `queued (position ${position})`;
|
|
449
|
+
return waiting ? `${queued}, ${waiting}` : queued;
|
|
450
|
+
}
|
|
451
|
+
if (state === "building") return "building";
|
|
452
|
+
return state;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Each line of a log chunk behind the design's " | " gutter. */
|
|
456
|
+
export function formatLogLines(text: string): string[] {
|
|
457
|
+
return text
|
|
458
|
+
.replace(/\n+$/, "")
|
|
459
|
+
.split("\n")
|
|
460
|
+
.map((line) => `${dim(" |")} ${line}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export type SseFrame = { event: string; id?: string; data: string };
|
|
464
|
+
export type SseParse = { frames: SseFrame[]; rest: string };
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Split a Server-Sent Events buffer into complete frames (blank-line separated). The unfinished tail comes back
|
|
468
|
+
* as `rest` to be joined with the next chunk. Comment lines (": keepalive") are dropped.
|
|
469
|
+
*/
|
|
470
|
+
export function parseSseFrames(buffer: string): SseParse {
|
|
471
|
+
const text = buffer.replace(/\r\n/g, "\n");
|
|
472
|
+
const parts = text.split("\n\n");
|
|
473
|
+
const rest = parts.pop() ?? "";
|
|
474
|
+
const frames: SseFrame[] = [];
|
|
475
|
+
for (const part of parts) {
|
|
476
|
+
let event = "message";
|
|
477
|
+
let id: string | undefined;
|
|
478
|
+
const data: string[] = [];
|
|
479
|
+
let seen = false;
|
|
480
|
+
for (const line of part.split("\n")) {
|
|
481
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
482
|
+
const colon = line.indexOf(":");
|
|
483
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
484
|
+
const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
485
|
+
if (field === "event") event = value;
|
|
486
|
+
else if (field === "id") id = value;
|
|
487
|
+
else if (field === "data") data.push(value);
|
|
488
|
+
else continue;
|
|
489
|
+
seen = true;
|
|
490
|
+
}
|
|
491
|
+
if (seen) frames.push({ event, id, data: data.join("\n") });
|
|
492
|
+
}
|
|
493
|
+
return { frames, rest };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** DELETE /v1/builds/<id>: ask the Cloud to cancel. Safe to call on a build that already finished. */
|
|
497
|
+
export async function cancelCloudBuild(credentials: CloudCredentials, id: string): Promise<BuildView> {
|
|
498
|
+
const reply = await makeCall(credentials)({ method: "DELETE", path: `/v1/builds/${id}` });
|
|
499
|
+
return parseBuildView(reply.body);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const TERMINAL = new Set(["ready", "failed", "canceled"]);
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Follow a build to its end: the event stream (resumed with ?after= when it drops, up to `maxReconnects` times),
|
|
506
|
+
* then plain polling of GET /v1/builds/<id>. Returns the final view.
|
|
507
|
+
*/
|
|
508
|
+
async function followBuild(
|
|
509
|
+
plan: Pick<BuildPlan, "apiUrl" | "token" | "tokenFromEnv" | "retryMs" | "maxReconnects" | "pollMs">,
|
|
510
|
+
call: CallFn,
|
|
511
|
+
first: BuildView,
|
|
512
|
+
io: CloudIo,
|
|
513
|
+
onState: (state: string, position: number | null, waiting: string | null) => void,
|
|
514
|
+
signal: AbortSignal | undefined,
|
|
515
|
+
): Promise<BuildView> {
|
|
516
|
+
const headers = new Headers(authHeaders(plan.token));
|
|
517
|
+
headers.set("accept", "text/event-stream");
|
|
518
|
+
let lastSeq = 0;
|
|
519
|
+
let reconnects = 0;
|
|
520
|
+
const limit = plan.maxReconnects ?? 5;
|
|
521
|
+
while (reconnects <= limit) {
|
|
522
|
+
if (signal?.aborted) throw new Interrupted("build canceled");
|
|
523
|
+
if (reconnects > 0) await io.sleep(plan.retryMs ?? 500);
|
|
524
|
+
reconnects++;
|
|
525
|
+
let response: Response;
|
|
526
|
+
try {
|
|
527
|
+
response = await fetch(`${plan.apiUrl}/v1/builds/${first.id}/events?after=${lastSeq}`, { headers, signal });
|
|
528
|
+
} catch {
|
|
529
|
+
if (signal?.aborted) throw new Interrupted("build canceled");
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (response.status === 401 || response.status === 403)
|
|
533
|
+
throw new CloudError(explainApiFailure(response.status, await response.text(), plan.tokenFromEnv));
|
|
534
|
+
// Any other failure of the stream route: try again, then poll.
|
|
535
|
+
if (!response.ok || !response.body) {
|
|
536
|
+
await response.body?.cancel();
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const decoder = new TextDecoder();
|
|
540
|
+
let pending = "";
|
|
541
|
+
try {
|
|
542
|
+
for await (const chunk of response.body) {
|
|
543
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
544
|
+
const parsed = parseSseFrames(pending);
|
|
545
|
+
pending = parsed.rest;
|
|
546
|
+
for (const frame of parsed.frames) {
|
|
547
|
+
const body = parseBody(frame.data);
|
|
548
|
+
if (frame.event === "state") {
|
|
549
|
+
onState(
|
|
550
|
+
isString(body?.state) ? body.state : "",
|
|
551
|
+
int(body?.position),
|
|
552
|
+
isString(body?.waiting) ? body.waiting : null,
|
|
553
|
+
);
|
|
554
|
+
} else if (frame.event === "log") {
|
|
555
|
+
const seq = Number(frame.id);
|
|
556
|
+
if (Number.isFinite(seq)) {
|
|
557
|
+
if (seq <= lastSeq) continue;
|
|
558
|
+
lastSeq = seq;
|
|
559
|
+
}
|
|
560
|
+
if (isString(body?.text)) for (const line of formatLogLines(body.text)) io.log(line);
|
|
561
|
+
} else if (frame.event === "done") {
|
|
562
|
+
return parseBuildView(body);
|
|
563
|
+
} else if (frame.event === "error") {
|
|
564
|
+
throw new CloudError(
|
|
565
|
+
isString(body?.message) ? body.message : isString(body?.error) ? body.error : "the build stream failed",
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error instanceof CloudError) throw error;
|
|
572
|
+
if (signal?.aborted) throw new Interrupted("build canceled");
|
|
573
|
+
}
|
|
574
|
+
// The stream ended before `done`: reconnect from the last log line seen.
|
|
575
|
+
}
|
|
576
|
+
for (;;) {
|
|
577
|
+
if (signal?.aborted) throw new Interrupted("build canceled");
|
|
578
|
+
const view = parseBuildView((await call({ method: "GET", path: `/v1/builds/${first.id}` })).body);
|
|
579
|
+
onState(view.state, view.position, view.waiting);
|
|
580
|
+
if (TERMINAL.has(view.state)) return view;
|
|
581
|
+
await io.sleep(plan.pollMs ?? 2000);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Follow a build that is already running (or finished) by id, from its first log line: what `deployments tail <id>`
|
|
587
|
+
* does, so a deploy that was detached with Ctrl-C can be picked up again. The build is never cancelled here; an abort
|
|
588
|
+
* only stops following (it throws Interrupted).
|
|
589
|
+
*/
|
|
590
|
+
export async function tailBuild(
|
|
591
|
+
credentials: CloudCredentials,
|
|
592
|
+
id: string,
|
|
593
|
+
io: CloudIo,
|
|
594
|
+
signal?: AbortSignal,
|
|
595
|
+
tune: Pick<BuildPlan, "retryMs" | "maxReconnects" | "pollMs"> = {},
|
|
596
|
+
): Promise<BuildView> {
|
|
597
|
+
const call = makeCall(credentials);
|
|
598
|
+
const reply = await call({ method: "GET", path: `/v1/builds/${encodeURIComponent(id)}`, ok: [404] });
|
|
599
|
+
if (reply.status === 404)
|
|
600
|
+
throw new CloudError(`no deployment "${id}" in this team; \`sproutboat deployments list\` shows the ids`);
|
|
601
|
+
const first = parseBuildView(reply.body);
|
|
602
|
+
let shown = "";
|
|
603
|
+
const onState = (state: string, position: number | null, waiting: string | null) => {
|
|
604
|
+
if (state === "") return;
|
|
605
|
+
const line = ` Building ${formatBuildState(state, position, waiting)}`;
|
|
606
|
+
if (line === shown || TERMINAL.has(state)) return;
|
|
607
|
+
shown = line;
|
|
608
|
+
io.log(line);
|
|
609
|
+
};
|
|
610
|
+
onState(first.state, first.position, first.waiting);
|
|
611
|
+
return followBuild({ ...credentials, ...tune }, call, first, io, onState, signal);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Secrets of a build deploy: the app record exists now, so merge them with PATCH /v1/apps/<app>/secrets (set only,
|
|
616
|
+
* nothing removed) and return the newer record generation to wait for. The app is briefly live before they land.
|
|
617
|
+
*/
|
|
618
|
+
async function patchSecretsAfterBuild(
|
|
619
|
+
call: CallFn,
|
|
620
|
+
project: string,
|
|
621
|
+
secrets: Record<string, string>,
|
|
622
|
+
built: number,
|
|
623
|
+
io: CloudIo,
|
|
624
|
+
): Promise<number> {
|
|
625
|
+
const names = Object.keys(secrets);
|
|
626
|
+
const reply = await call({ method: "PATCH", path: `/v1/apps/${project}/secrets`, json: { set: secrets }, ok: [403] });
|
|
627
|
+
if (reply.status === 403)
|
|
628
|
+
throw new CloudError(
|
|
629
|
+
`generation ${built} is deployed, but its secrets were not set: this key lacks the secret:write permission; set them with a key that has it (\`sproutboat secrets put\`)`,
|
|
630
|
+
);
|
|
631
|
+
const next = reply.body?.record_generation;
|
|
632
|
+
if (!isSafeInteger(next)) throw new CloudError("the Cloud's reply to the secrets write carried no record generation");
|
|
633
|
+
io.log(` secrets ${names.length} set (${names.join(", ")}); generation ${next} written`);
|
|
634
|
+
return next;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** 503 and 429 from the build request, as plain sentences. */
|
|
638
|
+
function explainBuildRefusal(result: CallResult): string {
|
|
639
|
+
const body = result.body;
|
|
640
|
+
const reason = body?.reason;
|
|
641
|
+
const message = isString(body?.message) ? body.message : isString(body?.error) ? body.error : `HTTP ${result.status}`;
|
|
642
|
+
if (result.status === 503) {
|
|
643
|
+
if (reason === "builds-disabled") return "this Cloud cannot build right now: builds are not enabled on it";
|
|
644
|
+
if (reason === "no-builder")
|
|
645
|
+
return "this Cloud cannot build right now: no builder is available; try again in a few minutes";
|
|
646
|
+
return `this Cloud cannot build right now: ${message}`;
|
|
647
|
+
}
|
|
648
|
+
return message;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Deploy from source: upload the bundled source by digest, ask the Cloud to build it, follow the log, and wait for
|
|
653
|
+
* a host to apply the record generation the build wrote.
|
|
654
|
+
*/
|
|
655
|
+
export async function buildAndDeploy(plan: BuildPlan, io: CloudIo, signal?: AbortSignal): Promise<BuildDeployResult> {
|
|
656
|
+
const call = makeCall(plan);
|
|
657
|
+
const bytes = new Uint8Array(readFileSync(plan.sourcePath));
|
|
658
|
+
if (bytes.byteLength > SOURCE_MAX_BYTES)
|
|
659
|
+
throw new CloudError(
|
|
660
|
+
`the bundled source is ${(bytes.byteLength / 1048576).toFixed(1)} MiB; the Cloud accepts at most 8 MiB`,
|
|
661
|
+
);
|
|
662
|
+
try {
|
|
663
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
664
|
+
} catch {
|
|
665
|
+
throw new CloudError("the bundled source is not valid UTF-8");
|
|
666
|
+
}
|
|
667
|
+
const sha = createHash("sha256").update(bytes).digest("hex");
|
|
668
|
+
|
|
669
|
+
// Everything that can refuse the deploy runs before the first upload or write.
|
|
670
|
+
checkServiceBindings(plan.project, plan.services ?? [], plan.compatibilityDate);
|
|
671
|
+
const secretNames = Object.keys(plan.secrets ?? {});
|
|
672
|
+
if (secretNames.length > 0 && plan.noWait)
|
|
673
|
+
throw new CloudError(
|
|
674
|
+
"--secrets-file is applied once the build is ready, so it cannot be combined with --no-wait; drop --no-wait, or set the secrets afterwards with `sproutboat secrets put`",
|
|
675
|
+
);
|
|
676
|
+
const wiring = await resolveServiceBindings(call, plan.project, plan.services ?? []);
|
|
677
|
+
const { created, assetsRef } = await resolveAssets(call, plan, io);
|
|
678
|
+
|
|
679
|
+
const missingBody = (await call({ method: "POST", path: "/v1/sources/missing", json: { sha256: [sha] } })).body
|
|
680
|
+
?.missing;
|
|
681
|
+
const needed = Array.isArray(missingBody) && missingBody.includes(sha);
|
|
682
|
+
const label = ` Uploading source ${sha.slice(0, 12)} (${formatKiB(bytes.byteLength)})`;
|
|
683
|
+
if (needed) {
|
|
684
|
+
io.log(label);
|
|
685
|
+
await call({ method: "PUT", path: `/v1/sources/${sha}`, body: bytes });
|
|
686
|
+
} else io.log(`${label} unchanged`);
|
|
687
|
+
|
|
688
|
+
const spec: BuildSpec = { hosts: plan.hosts };
|
|
689
|
+
if (assetsRef !== undefined) spec.assets_manifest_ref = assetsRef;
|
|
690
|
+
// The build spec is stored in plaintext by the Cloud: secrets never go in it (they follow the build, below).
|
|
691
|
+
applyExtras(spec, wiring, undefined);
|
|
692
|
+
const cli: BuildCli = { version: plan.cli.version };
|
|
693
|
+
if (plan.cli.runtime) cli.runtime = plan.cli.runtime;
|
|
694
|
+
if (plan.cli.toolchain) cli.toolchain = plan.cli.toolchain;
|
|
695
|
+
const build = { v: 1, name: plan.project, cli };
|
|
696
|
+
const accepted = await call({
|
|
697
|
+
method: "POST",
|
|
698
|
+
path: `/v1/apps/${plan.project}/builds`,
|
|
699
|
+
json: { source_sha256: sha, kind: "generated", build, spec },
|
|
700
|
+
ok: [429, 503],
|
|
701
|
+
});
|
|
702
|
+
if (accepted.status === 429 || accepted.status === 503) throw new CloudError(explainBuildRefusal(accepted));
|
|
703
|
+
let view = parseBuildView(accepted.body);
|
|
704
|
+
const url = plan.hosts[0] === undefined ? undefined : `https://${plan.hosts[0]}`;
|
|
705
|
+
|
|
706
|
+
let shown = "";
|
|
707
|
+
const onState = (state: string, position: number | null, waiting: string | null) => {
|
|
708
|
+
if (state === "") return;
|
|
709
|
+
const line = ` Building ${formatBuildState(state, position, waiting)}`;
|
|
710
|
+
if (line === shown || TERMINAL.has(state)) return;
|
|
711
|
+
shown = line;
|
|
712
|
+
io.log(line);
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
if (!TERMINAL.has(view.state)) {
|
|
716
|
+
onState(view.state, view.position, view.waiting);
|
|
717
|
+
if (plan.noWait) {
|
|
718
|
+
io.log(` Build ${view.id} accepted; status at /v1/builds/${view.id}`);
|
|
719
|
+
return { created, generation: undefined, url, applied: "skipped", buildId: view.id, build: view };
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
view = await followBuild(plan, call, view, io, onState, signal);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (error instanceof Interrupted) {
|
|
725
|
+
try {
|
|
726
|
+
await cancelCloudBuild(plan, view.id);
|
|
727
|
+
} catch {
|
|
728
|
+
// The cancel is best effort; the build ends on its own.
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (view.state === "canceled") throw new CloudError(`build canceled${view.error ? `: ${view.error.message}` : ""}`);
|
|
735
|
+
if (view.state === "failed") {
|
|
736
|
+
const { peakMemoryBytes, memoryCapBytes } = view.stats;
|
|
737
|
+
if (peakMemoryBytes !== null)
|
|
738
|
+
io.log(
|
|
739
|
+
` peak memory ${formatMB(peakMemoryBytes)}${memoryCapBytes === null ? "" : ` (cap ${formatMB(memoryCapBytes)})`}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
if (view.state === "failed") throw new CloudError(`build failed: ${buildFailureHeadline(view.error?.message)}`);
|
|
743
|
+
io.log(ok(formatBuildSummary(view)));
|
|
744
|
+
let generation = view.recordGeneration;
|
|
745
|
+
if (generation === undefined) throw new CloudError("the Cloud's reply carried no record generation");
|
|
746
|
+
if (secretNames.length > 0) {
|
|
747
|
+
generation = await patchSecretsAfterBuild(call, plan.project, plan.secrets!, generation, io);
|
|
748
|
+
}
|
|
749
|
+
if (plan.noWait) return { created, generation, url, applied: "skipped", buildId: view.id, build: view };
|
|
750
|
+
const applied = await waitForApply(call, plan, generation, io, signal);
|
|
751
|
+
return { created, generation, url, applied, buildId: view.id, build: view };
|
|
752
|
+
}
|