cf-orbs 0.1.1 → 0.2.2
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/README.md +51 -15
- package/dist/commands/deploy.js +4 -2
- package/dist/commands/doctor.js +13 -3
- package/dist/commands/init.js +365 -18
- package/dist/commands/upgrade.js +11 -2
- package/dist/config.js +1 -1
- package/dist/file-checks.js +194 -0
- package/dist/index.js +6 -1
- package/dist/project.js +34 -3
- package/dist/wrangler-template.js +3 -0
- package/dist/wrangler.js +3 -1
- package/package.json +1 -1
- package/templates/cf-orbs.config.example.jsonc +15 -1
- package/templates/dispatch/dispatch.sh +3 -3
- package/templates/image/Dockerfile +2 -2
- package/templates/provider/Dockerfile +2 -2
- package/templates/provider/entry.sh +1 -1
- package/templates/worker/src/api.ts +1 -1
- package/templates/worker/src/core/config.ts +3 -2
- package/templates/worker/src/core/types.ts +7 -3
package/dist/commands/init.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// cf-orbs init [dir]: zero to a live provider. plan() is pure (options + probes → steps); runInit() executes.
|
|
2
2
|
// Re-runnable: reuses the control token, keeps the Amp token secret when none is given, keeps user files.
|
|
3
|
+
// --no-deploy (git mode): no Docker, no deploy. Scaffold a repo for Workers Builds, create the Worker with its
|
|
4
|
+
// secrets, print the dashboard steps. Workers Builds then builds the images and deploys on each push.
|
|
3
5
|
import { existsSync } from "node:fs";
|
|
4
6
|
import { readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
5
8
|
import { expectOk, makeApi, providerInfo } from "../api.js";
|
|
6
9
|
import { flagBool, flagString } from "../args.js";
|
|
7
10
|
import { CONFIG_FILE, findTopLevelValue, instanceForVcpu, loadConfig, setTopLevelJsonc, } from "../config.js";
|
|
8
11
|
import { CliError, registerSecret } from "../errors.js";
|
|
12
|
+
import { filesWithSecrets, gitTrackedFiles } from "../file-checks.js";
|
|
9
13
|
import { dockerOk, nodeMajor, whoami } from "../probes.js";
|
|
10
|
-
import { generateToken, materialize, readControlToken, readState, writeControlToken, writeState, } from "../project.js";
|
|
14
|
+
import { cliVersion, GIT_DEPLOY_COMMAND, generateToken, gitPackageJson, materialize, missingDockerfiles, readControlToken, readState, withSecretsFile, writeControlToken, writeState, writeWranglerConfig, } from "../project.js";
|
|
11
15
|
import { checkSubdomain, registerSubdomain, suggestSubdomain, wranglerBearer } from "../subdomain.js";
|
|
12
16
|
import { ask, askHidden } from "../ui.js";
|
|
13
|
-
import { makeWrangler } from "../wrangler.js";
|
|
17
|
+
import { classifyDeployFailure, deployFailureError, lastErrorLine, makeWrangler, WRANGLER_PIN, } from "../wrangler.js";
|
|
14
18
|
import { deployProject, startPrewarm } from "./deploy.js";
|
|
15
19
|
export const PROVIDER_WAIT_MS = 90_000;
|
|
20
|
+
/** How long init waits for the prewarm instances (a first start on a cold host pulls the image). */
|
|
21
|
+
export const SELFTEST_WAIT_MS = 180_000;
|
|
16
22
|
/** Provider must stay up this long with no reject, no second-provider line and no restart. */
|
|
17
23
|
export const PROVIDER_STABLE_MS = 15_000;
|
|
18
24
|
const CRON_PERIOD_MS = 5 * 60_000;
|
|
@@ -29,7 +35,13 @@ export function plan(o, p) {
|
|
|
29
35
|
error: new CliError(`Node ${p.nodeVersion} is too old; cf-orbs needs Node 22 or later`, "install Node 22+ (https://nodejs.org) and rerun"),
|
|
30
36
|
});
|
|
31
37
|
}
|
|
32
|
-
if (
|
|
38
|
+
if (o.noDeploy && o.mode === "external") {
|
|
39
|
+
fails.push({
|
|
40
|
+
kind: "fail",
|
|
41
|
+
error: new CliError("--no-deploy supports only --provider cloudflare", "drop --provider external, or run cf-orbs init --provider external without --no-deploy"),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!p.docker && !o.noDeploy) {
|
|
33
45
|
fails.push({
|
|
34
46
|
kind: "fail",
|
|
35
47
|
error: new CliError("Docker is not running (wrangler builds the orb image locally)", "start Docker Desktop or the docker daemon, check `docker info`, then rerun"),
|
|
@@ -80,7 +92,17 @@ export function plan(o, p) {
|
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
let ampSource;
|
|
83
|
-
|
|
95
|
+
const hadProvider = p.prevMode === "cloudflare" || p.prevMode === "git";
|
|
96
|
+
if (o.noDeploy) {
|
|
97
|
+
// the Amp token is optional here: set it now or later, the Worker starts the provider once it has one
|
|
98
|
+
if (o.tokenFile)
|
|
99
|
+
ampSource = "file";
|
|
100
|
+
else if (p.envToken)
|
|
101
|
+
ampSource = "env";
|
|
102
|
+
else if (interactive)
|
|
103
|
+
ampSource = "prompt";
|
|
104
|
+
}
|
|
105
|
+
else if (o.mode === "cloudflare") {
|
|
84
106
|
if (o.tokenFile)
|
|
85
107
|
ampSource = "file";
|
|
86
108
|
else if (p.envToken)
|
|
@@ -88,7 +110,7 @@ export function plan(o, p) {
|
|
|
88
110
|
else if (interactive)
|
|
89
111
|
ampSource = "prompt";
|
|
90
112
|
// Keep only a secret that exists: external mode deletes it from the Worker.
|
|
91
|
-
else if (p.deployed &&
|
|
113
|
+
else if (p.deployed && hadProvider)
|
|
92
114
|
ampSource = "keep";
|
|
93
115
|
else if (p.deployed) {
|
|
94
116
|
fails.push({
|
|
@@ -105,15 +127,24 @@ export function plan(o, p) {
|
|
|
105
127
|
}
|
|
106
128
|
if (fails.length > 0)
|
|
107
129
|
return fails;
|
|
108
|
-
const steps = [
|
|
130
|
+
const steps = [
|
|
131
|
+
account,
|
|
132
|
+
{ kind: "plan-check" },
|
|
133
|
+
{ kind: "scaffold", mode: o.mode, git: Boolean(o.noDeploy) },
|
|
134
|
+
];
|
|
109
135
|
steps.push({ kind: "control-token", action: p.hasControlToken ? "reuse" : "generate" });
|
|
110
136
|
if (ampSource)
|
|
111
137
|
steps.push({ kind: "amp-token", source: ampSource });
|
|
138
|
+
if (o.noDeploy) {
|
|
139
|
+
steps.push({ kind: "subdomain" }, { kind: "git-files" }, { kind: "git-secrets" }, { kind: "selftest" }, { kind: "git-next" });
|
|
140
|
+
steps.push({ kind: "done", command: helloCommand(o.providerName) });
|
|
141
|
+
return steps;
|
|
142
|
+
}
|
|
112
143
|
const secrets = ["CONTROL_TOKEN"];
|
|
113
144
|
// "prompt" may still end as keep (Enter on a re-run); the executor drops the secret then.
|
|
114
145
|
if (ampSource && ampSource !== "keep")
|
|
115
146
|
secrets.push("AMP_ORB_PROVIDER_TOKEN");
|
|
116
|
-
steps.push({ kind: "subdomain" }, { kind: "deploy", secrets }, { kind: "health" }, { kind: "status" });
|
|
147
|
+
steps.push({ kind: "subdomain" }, { kind: "deploy", secrets }, { kind: "health" }, { kind: "status" }, { kind: "selftest" });
|
|
117
148
|
if (o.mode === "cloudflare") {
|
|
118
149
|
steps.push({ kind: "start-provider" }, { kind: "wait-provider", timeoutMs: PROVIDER_WAIT_MS });
|
|
119
150
|
}
|
|
@@ -122,9 +153,10 @@ export function plan(o, p) {
|
|
|
122
153
|
steps.push({ kind: "done", command: helloCommand(o.providerName) });
|
|
123
154
|
return steps;
|
|
124
155
|
}
|
|
125
|
-
|
|
156
|
+
/** noDeploy: git mode needs no Docker, so the probe is skipped. */
|
|
157
|
+
export async function gatherProbes(ctx, o = {}) {
|
|
126
158
|
const [docker, who, ct, state] = await Promise.all([
|
|
127
|
-
dockerOk(ctx.run),
|
|
159
|
+
o.noDeploy ? false : dockerOk(ctx.run),
|
|
128
160
|
whoami(ctx.run),
|
|
129
161
|
readControlToken(ctx.paths),
|
|
130
162
|
readState(ctx.paths),
|
|
@@ -150,6 +182,41 @@ function checkAmpToken(ctx, t) {
|
|
|
150
182
|
registerSecret(t);
|
|
151
183
|
return t;
|
|
152
184
|
}
|
|
185
|
+
function warmLast(body) {
|
|
186
|
+
const b = (body && typeof body === "object" ? body : {});
|
|
187
|
+
return (b.last && typeof b.last === "object" ? b.last : {});
|
|
188
|
+
}
|
|
189
|
+
/** One finished prewarm run: ok > 0 passes, ok = 0 fails init. */
|
|
190
|
+
function reportSelftest(ui, view) {
|
|
191
|
+
const count = Number(view.count) || 0;
|
|
192
|
+
const ok = Number(view.ok) || 0;
|
|
193
|
+
const ms = Array.isArray(view.msPerInstance) ? view.msPerInstance : [];
|
|
194
|
+
if (ok === 0) {
|
|
195
|
+
const jobs = Array.isArray(view.jobs) ? view.jobs : [];
|
|
196
|
+
const why = jobs.find((j) => typeof j.error === "string")?.error;
|
|
197
|
+
throw new CliError(`orb self-test failed: no instance started from the new image${why ? ` (${String(why)})` : ""}`, "run cf-orbs logs --provider and cf-orbs status, check the image build, then run cf-orbs deploy");
|
|
198
|
+
}
|
|
199
|
+
const t = ms.length > 0 ? ` in ${ms.map((m) => `${(m / 1000).toFixed(0)} s`).join(" and ")}` : "";
|
|
200
|
+
if (ok < count)
|
|
201
|
+
ui.warn(`self-test: only ${ok} of ${count} instances started${t}`);
|
|
202
|
+
else
|
|
203
|
+
ui.step(`self-test: orb image starts (${ok} of ${count} ready${t})`);
|
|
204
|
+
}
|
|
205
|
+
/** --no-deploy: the last prewarm run of an already built Worker, or undefined. Never throws. */
|
|
206
|
+
async function lastPrewarm(url, token, ctx) {
|
|
207
|
+
if (!url || !token)
|
|
208
|
+
return undefined;
|
|
209
|
+
try {
|
|
210
|
+
const r = await makeApi(url, token, ctx.fetch).get("/v1/warm");
|
|
211
|
+
if (r.status !== 200)
|
|
212
|
+
return undefined;
|
|
213
|
+
const last = warmLast(r.body);
|
|
214
|
+
return typeof last.at === "string" ? last : undefined;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
153
220
|
export async function runInit(ctx, o, steps) {
|
|
154
221
|
const { ui, paths } = ctx;
|
|
155
222
|
let state = await readState(paths);
|
|
@@ -165,6 +232,11 @@ export async function runInit(ctx, o, steps) {
|
|
|
165
232
|
let providerStartedByCron = false;
|
|
166
233
|
let accountName;
|
|
167
234
|
let providerName = o.providerName;
|
|
235
|
+
let prewarmStarted = -1;
|
|
236
|
+
let prewarmAt = 0;
|
|
237
|
+
let subdomain;
|
|
238
|
+
let repoExisted = false;
|
|
239
|
+
let git;
|
|
168
240
|
// Show every failed precondition in one run, not one per rerun.
|
|
169
241
|
const fails = steps.filter((s) => s.kind === "fail");
|
|
170
242
|
for (const f of fails.slice(0, -1))
|
|
@@ -197,6 +269,7 @@ export async function runInit(ctx, o, steps) {
|
|
|
197
269
|
break; // unknown auth type: wrangler deploy reports a missing subdomain itself
|
|
198
270
|
const c = await checkSubdomain(ctx.fetch, accountId, bearer);
|
|
199
271
|
if (c.kind === "has") {
|
|
272
|
+
subdomain = c.subdomain;
|
|
200
273
|
ui.step(`workers.dev subdomain: ${c.subdomain}`);
|
|
201
274
|
break;
|
|
202
275
|
}
|
|
@@ -211,6 +284,7 @@ export async function runInit(ctx, o, steps) {
|
|
|
211
284
|
name = (await ask(`This account has no workers.dev subdomain. Name for it [${def}]: `)) || def;
|
|
212
285
|
}
|
|
213
286
|
await registerSubdomain(ctx.fetch, accountId, bearer, name);
|
|
287
|
+
subdomain = name;
|
|
214
288
|
ui.step(`workers.dev subdomain: registered ${name}.workers.dev`);
|
|
215
289
|
break;
|
|
216
290
|
}
|
|
@@ -218,6 +292,7 @@ export async function runInit(ctx, o, steps) {
|
|
|
218
292
|
const r = await materialize(paths, {
|
|
219
293
|
templates: ctx.templates,
|
|
220
294
|
configOverrides: { providerHost: s.mode },
|
|
295
|
+
git: s.git,
|
|
221
296
|
});
|
|
222
297
|
if (r.wroteConfig)
|
|
223
298
|
ui.step(`scaffold: wrote ${CONFIG_FILE}`);
|
|
@@ -231,6 +306,11 @@ export async function runInit(ctx, o, steps) {
|
|
|
231
306
|
config = await loadConfig(paths.config);
|
|
232
307
|
ui.step(`scaffold: providerHost set to ${s.mode}`);
|
|
233
308
|
}
|
|
309
|
+
if (o.name !== undefined && config.name !== o.name) {
|
|
310
|
+
await writeFile(paths.config, setTopLevelJsonc(await readFile(paths.config, "utf8"), "name", o.name));
|
|
311
|
+
config = await loadConfig(paths.config);
|
|
312
|
+
ui.step(`scaffold: name set to ${o.name}`);
|
|
313
|
+
}
|
|
234
314
|
// --vcpu overwrites "instance"; without it a re-run keeps what the config has
|
|
235
315
|
if (o.vcpu !== undefined && !sameInstance(config.instance, instanceForVcpu(o.vcpu))) {
|
|
236
316
|
const t = await readFile(paths.config, "utf8");
|
|
@@ -238,7 +318,13 @@ export async function runInit(ctx, o, steps) {
|
|
|
238
318
|
config = await loadConfig(paths.config);
|
|
239
319
|
ui.step(`scaffold: instance set to ${o.vcpu} vCPU, ${o.vcpu * 3} GiB, 20 GB disk`);
|
|
240
320
|
}
|
|
241
|
-
state = {
|
|
321
|
+
state = {
|
|
322
|
+
...state,
|
|
323
|
+
accountId,
|
|
324
|
+
providerName: o.providerName,
|
|
325
|
+
mode: s.git ? "git" : s.mode,
|
|
326
|
+
workerName: config.name,
|
|
327
|
+
};
|
|
242
328
|
await writeState(paths, state);
|
|
243
329
|
break;
|
|
244
330
|
}
|
|
@@ -253,6 +339,10 @@ export async function runInit(ctx, o, steps) {
|
|
|
253
339
|
ui.step("control token: reusing .cf-orbs/control-token");
|
|
254
340
|
break;
|
|
255
341
|
case "amp-token":
|
|
342
|
+
// No interactive name question here: say so if the name is a fallback, not given
|
|
343
|
+
if (s.source !== "prompt" &&
|
|
344
|
+
(o.providerNameSource === "config" || o.providerNameSource === "default"))
|
|
345
|
+
ui.warn(`provider name ${providerName} was not given explicitly; it must match the name in Amp Settings → Orbs (pass --provider-name <name> if it differs)`);
|
|
256
346
|
if (s.source === "env")
|
|
257
347
|
ampToken = checkAmpToken(ctx, (ctx.env.AMP_ORB_PROVIDER_TOKEN ?? "").trim());
|
|
258
348
|
else if (s.source === "file") {
|
|
@@ -263,9 +353,16 @@ export async function runInit(ctx, o, steps) {
|
|
|
263
353
|
}
|
|
264
354
|
else if (s.source === "prompt") {
|
|
265
355
|
// Keep only a secret that exists: external mode deleted it from the Worker.
|
|
266
|
-
const keep = Boolean(state.url) && prevMode === "cloudflare";
|
|
267
|
-
const
|
|
268
|
-
|
|
356
|
+
const keep = Boolean(state.url) && (prevMode === "cloudflare" || prevMode === "git");
|
|
357
|
+
const enter = o.noDeploy
|
|
358
|
+
? "; Enter skips (set it later)"
|
|
359
|
+
: keep
|
|
360
|
+
? "; Enter keeps the current one"
|
|
361
|
+
: "";
|
|
362
|
+
const t = await askHidden(`Amp provider token (aop_live_…, not shown; create or copy it in Amp Settings → Orbs → Add Provider)${enter}: `);
|
|
363
|
+
if (t === "" && o.noDeploy)
|
|
364
|
+
ui.step("amp token: not given; set it later (see the steps below)");
|
|
365
|
+
else if (t === "" && keep)
|
|
269
366
|
ui.step("amp token: keeping the current Worker secret");
|
|
270
367
|
else
|
|
271
368
|
ampToken = checkAmpToken(ctx, t);
|
|
@@ -320,8 +417,69 @@ export async function runInit(ctx, o, steps) {
|
|
|
320
417
|
lastStatus = expectOk(await api.get("/v1/status"), "status check");
|
|
321
418
|
ui.step("health: /v1/status ok (control token accepted)");
|
|
322
419
|
if (config)
|
|
323
|
-
await startPrewarm(ctx, api, config);
|
|
420
|
+
prewarmStarted = (await startPrewarm(ctx, api, config)).started;
|
|
421
|
+
prewarmAt = ctx.now();
|
|
324
422
|
break;
|
|
423
|
+
case "selftest": {
|
|
424
|
+
if (o.noDeploy) {
|
|
425
|
+
// Workers Builds deploys later, so this run started no prewarm. Report an earlier run if the
|
|
426
|
+
// Worker has one (a rerun after a build); else skip.
|
|
427
|
+
const last = await lastPrewarm(url, controlToken, ctx);
|
|
428
|
+
if (last && Number(last.pending) === 0 && Number(last.count) > 0) {
|
|
429
|
+
// an old failed run does not fail init: the next build may fix the image
|
|
430
|
+
if (Number(last.ok) > 0)
|
|
431
|
+
reportSelftest(ui, last);
|
|
432
|
+
else
|
|
433
|
+
ui.warn("self-test: the last prewarm run started no instance; check the build, then cf-orbs status");
|
|
434
|
+
}
|
|
435
|
+
else
|
|
436
|
+
ui.step("self-test: skipped (Workers Builds deploys later; after the first build, cf-orbs status shows the prewarm result)");
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
// The prewarm run starts throwaway orbs from the new image. If one becomes ready, the image,
|
|
440
|
+
// the registry and the container runtime work. No Amp thread and no debug route needed.
|
|
441
|
+
if (prewarmStarted === 0) {
|
|
442
|
+
ui.step('self-test: skipped (no prewarm instance was started; set "prewarm" above 0 to enable)');
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
const until = ctx.now() + SELFTEST_WAIT_MS;
|
|
446
|
+
let view = {};
|
|
447
|
+
for (;;) {
|
|
448
|
+
const r = await api.get("/v1/warm");
|
|
449
|
+
const last = warmLast(r.body);
|
|
450
|
+
// Ignore the result of an earlier run.
|
|
451
|
+
if (typeof last.at === "string" && Date.parse(last.at) >= prewarmAt - 10_000) {
|
|
452
|
+
view = last;
|
|
453
|
+
if (Number(last.pending) === 0)
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (ctx.now() >= until)
|
|
457
|
+
break;
|
|
458
|
+
await ctx.sleep(3_000);
|
|
459
|
+
}
|
|
460
|
+
if (Number(view.count) > 0 && Number(view.pending) === 0)
|
|
461
|
+
reportSelftest(ui, view);
|
|
462
|
+
else
|
|
463
|
+
ui.warn("self-test: still running after 3 min; the first start on a new host pulls the image. Check cf-orbs status");
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case "plan-check": {
|
|
467
|
+
// Before the image build: a Free account fails here in seconds, not after a 1.8 GB push.
|
|
468
|
+
// Best effort: only a clear plan or login error stops init; any other error is a warning.
|
|
469
|
+
const r = await makeWrangler(ctx.run, { accountId }).run(["containers", "list", "--json"], {
|
|
470
|
+
timeoutMs: 60_000,
|
|
471
|
+
});
|
|
472
|
+
if (r.code === 0) {
|
|
473
|
+
ui.step("plan: Containers are available on this account");
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
477
|
+
const kind = classifyDeployFailure(out);
|
|
478
|
+
if (kind === "paid" || kind === "auth")
|
|
479
|
+
throw deployFailureError(kind, out);
|
|
480
|
+
ui.warn(`plan check skipped (wrangler containers list failed: ${lastErrorLine(out)})`);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
325
483
|
case "start-provider": {
|
|
326
484
|
const r = await api.post("/v1/provider/start");
|
|
327
485
|
if (r.status === 404 || r.status === 405) {
|
|
@@ -365,6 +523,52 @@ export async function runInit(ctx, o, steps) {
|
|
|
365
523
|
}
|
|
366
524
|
break;
|
|
367
525
|
}
|
|
526
|
+
case "git-files": {
|
|
527
|
+
if (!config)
|
|
528
|
+
throw new Error("init plan bug: git-files before scaffold");
|
|
529
|
+
const missing = missingDockerfiles(paths, config);
|
|
530
|
+
if (missing.length > 0)
|
|
531
|
+
throw new CliError(`image Dockerfile missing: ${missing.join(", ")}`, "create it, or remove its key from images in cf-orbs.config.jsonc");
|
|
532
|
+
await writeWranglerConfig(paths, config, accountId);
|
|
533
|
+
ui.step("git: wrote .cf-orbs/wrangler.jsonc (Workers Builds deploys with it)");
|
|
534
|
+
if (!existsSync(paths.packageJson)) {
|
|
535
|
+
await writeFile(paths.packageJson, gitPackageJson(config.name, WRANGLER_PIN));
|
|
536
|
+
ui.step(`git: wrote package.json (wrangler ${WRANGLER_PIN} for Workers Builds)`);
|
|
537
|
+
}
|
|
538
|
+
await writeLockfile(ctx);
|
|
539
|
+
repoExisted = await gitCommitNew(ctx, ampToken);
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
case "git-secrets": {
|
|
543
|
+
if (!config || !controlToken)
|
|
544
|
+
throw new Error("init plan bug: git-secrets before scaffold");
|
|
545
|
+
const secrets = { CONTROL_TOKEN: controlToken };
|
|
546
|
+
if (ampToken)
|
|
547
|
+
secrets.AMP_ORB_PROVIDER_TOKEN = ampToken;
|
|
548
|
+
await putSecrets(ctx, config.name, accountId, secrets);
|
|
549
|
+
url = subdomain ? `https://${config.name}.${subdomain}.workers.dev` : state.url;
|
|
550
|
+
if (!url)
|
|
551
|
+
ui.warn("Failed: could not read the account's workers.dev subdomain, so state.json has no URL — fix: after the first build, pass --url https://<name>.<subdomain>.workers.dev to status and doctor");
|
|
552
|
+
state = {
|
|
553
|
+
...state,
|
|
554
|
+
url,
|
|
555
|
+
accountId,
|
|
556
|
+
mode: "git",
|
|
557
|
+
workerName: config.name,
|
|
558
|
+
cliVersion: await cliVersion(),
|
|
559
|
+
};
|
|
560
|
+
await writeState(paths, state);
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case "git-next":
|
|
564
|
+
git = gitNext({
|
|
565
|
+
worker: config?.name ?? "cf-orbs",
|
|
566
|
+
repoExisted,
|
|
567
|
+
ampTokenGiven: Boolean(ampToken),
|
|
568
|
+
});
|
|
569
|
+
for (const l of git.lines)
|
|
570
|
+
ui.out(l);
|
|
571
|
+
break;
|
|
368
572
|
case "print-external-env":
|
|
369
573
|
ui.out("External provider mode. On an always-on host with the amp CLI, run:");
|
|
370
574
|
ui.out(" export AMP_ORB_PROVIDER_TOKEN=<your aop_live_ token>");
|
|
@@ -379,7 +583,123 @@ export async function runInit(ctx, o, steps) {
|
|
|
379
583
|
break;
|
|
380
584
|
}
|
|
381
585
|
}
|
|
382
|
-
return { url, command, providerName };
|
|
586
|
+
return { url, command, providerName, ...(git ? { git } : {}) };
|
|
587
|
+
}
|
|
588
|
+
/** Workers Builds settings for the dashboard (Settings → Builds → Connect), as tested on 2026-11-02. */
|
|
589
|
+
export const GIT_PATH_FILTERS = ["image/**", ".cf-orbs/**", "cf-orbs.config.jsonc", "package*.json"];
|
|
590
|
+
/** The next steps init --no-deploy prints. Pure. */
|
|
591
|
+
export function gitNext(o) {
|
|
592
|
+
const settings = {
|
|
593
|
+
worker: o.worker,
|
|
594
|
+
branch: "main",
|
|
595
|
+
buildCommand: "",
|
|
596
|
+
deployCommand: GIT_DEPLOY_COMMAND,
|
|
597
|
+
rootDirectory: "/",
|
|
598
|
+
previews: false,
|
|
599
|
+
apiToken: "default build token",
|
|
600
|
+
buildWatchPaths: GIT_PATH_FILTERS,
|
|
601
|
+
};
|
|
602
|
+
const push = o.repoExisted
|
|
603
|
+
? [
|
|
604
|
+
"1. Commit these files, then push the repo to GitHub or GitLab:",
|
|
605
|
+
" git add -A && git commit -m 'cf-orbs init'",
|
|
606
|
+
" git push",
|
|
607
|
+
]
|
|
608
|
+
: [
|
|
609
|
+
"1. Make an empty repo on GitHub or GitLab. Push this directory to it:",
|
|
610
|
+
" git remote add origin <repo URL>",
|
|
611
|
+
" git push -u origin main",
|
|
612
|
+
];
|
|
613
|
+
const lines = [
|
|
614
|
+
"",
|
|
615
|
+
"Next steps. Workers Builds builds the images in your Cloudflare account; this machine needs no Docker.",
|
|
616
|
+
...push,
|
|
617
|
+
`2. Connect the repo: Cloudflare dashboard → Workers & Pages → ${o.worker} → Settings → Builds → Connect.`,
|
|
618
|
+
" Repository: the repo from step 1",
|
|
619
|
+
` Branch: ${settings.branch}${o.repoExisted ? " (the branch you push)" : ""}`,
|
|
620
|
+
" Build command: (leave empty)",
|
|
621
|
+
` Deploy command: ${settings.deployCommand}`,
|
|
622
|
+
` Root directory: ${settings.rootDirectory}`,
|
|
623
|
+
" Previews (builds for non-production branches): off",
|
|
624
|
+
" API token: the default build token",
|
|
625
|
+
` Build watch paths: include ${settings.buildWatchPaths.join(", ")}`,
|
|
626
|
+
" If no build starts, push a commit. Measured: first build 10.6 min, plus 2-4 min in the queue.",
|
|
627
|
+
"3. After the first build: cf-orbs status",
|
|
628
|
+
" The 5-min cron starts the provider. The first spawn on each host pulls the image (40-50 s).",
|
|
629
|
+
...(o.ampTokenGiven
|
|
630
|
+
? []
|
|
631
|
+
: [
|
|
632
|
+
"4. No Amp token given. If the Worker has none yet, set it with:",
|
|
633
|
+
" cf-orbs init --no-deploy --token-file <file> (no build needed)",
|
|
634
|
+
]),
|
|
635
|
+
"Upgrades: edit image/Dockerfile or cf-orbs.config.jsonc (or cf-orbs upgrade --digest sha256:<hex> --no-deploy), commit, push.",
|
|
636
|
+
"Docs: https://github.com/meirdick/cf-orbs/blob/main/docs/install-workers-builds.md",
|
|
637
|
+
"Then try it (spends Amp credits):",
|
|
638
|
+
];
|
|
639
|
+
return { settings, lines };
|
|
640
|
+
}
|
|
641
|
+
/** package-lock.json for npm clean-install in Workers Builds. A failure is one warning. */
|
|
642
|
+
async function writeLockfile(ctx) {
|
|
643
|
+
const { paths, ui } = ctx;
|
|
644
|
+
if (existsSync(join(paths.dir, "package-lock.json")))
|
|
645
|
+
return;
|
|
646
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
647
|
+
const r = await ctx.run(npm, ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
|
648
|
+
cwd: paths.dir,
|
|
649
|
+
timeoutMs: 180_000,
|
|
650
|
+
});
|
|
651
|
+
if (r.code === 0)
|
|
652
|
+
ui.step("git: wrote package-lock.json");
|
|
653
|
+
else
|
|
654
|
+
ui.warn(`Failed: could not write package-lock.json (${lastErrorLine(`${r.stdout}\n${r.stderr}`)}) — fix: run npm install --package-lock-only in ${paths.dir}, then commit it`);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* git init + first commit when dir is in no git work tree. Returns true when a repo was there already.
|
|
658
|
+
* Checks the staged files for tokens before the commit.
|
|
659
|
+
*/
|
|
660
|
+
async function gitCommitNew(ctx, ampToken) {
|
|
661
|
+
const { paths, ui } = ctx;
|
|
662
|
+
const g = (args) => ctx.run("git", ["-C", paths.dir, ...args], { timeoutMs: 60_000 });
|
|
663
|
+
if ((await g(["rev-parse", "--is-inside-work-tree"])).code === 0) {
|
|
664
|
+
ui.step("git: this directory is in a git repo already; commit and push the changes yourself");
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
const init = await g(["init", "-b", "main"]);
|
|
668
|
+
if (init.code !== 0) {
|
|
669
|
+
ui.warn(`Failed: git init did not run (${lastErrorLine(`${init.stdout}\n${init.stderr}`)}) — fix: install git, then run git init -b main && git add -A && git commit -m 'cf-orbs init' in ${paths.dir}`);
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
await g(["add", "-A"]);
|
|
673
|
+
const ct = await readControlToken(paths);
|
|
674
|
+
const staged = (await gitTrackedFiles(ctx.run, paths.dir)) ?? [];
|
|
675
|
+
const hits = await filesWithSecrets(paths.dir, staged, [
|
|
676
|
+
...(ct ? [ct] : []),
|
|
677
|
+
...(ampToken ? [ampToken] : []),
|
|
678
|
+
]);
|
|
679
|
+
if (hits.length > 0) {
|
|
680
|
+
await g(["reset", "-q"]);
|
|
681
|
+
throw new CliError(`a token is in file(s) staged for the first commit: ${hits.join(", ")}`, "remove the token from those files (or add them to .gitignore), then rerun cf-orbs init --no-deploy");
|
|
682
|
+
}
|
|
683
|
+
const c = await g(["commit", "-q", "-m", "cf-orbs init (Workers Builds)"]);
|
|
684
|
+
if (c.code === 0)
|
|
685
|
+
ui.step(`git: made a repo on branch main with a first commit (${staged.length} files)`);
|
|
686
|
+
else
|
|
687
|
+
ui.warn(`Failed: git commit did not run (${lastErrorLine(`${c.stdout}\n${c.stderr}`)}) — fix: set git config user.name and user.email, then git commit -m 'cf-orbs init' in ${paths.dir}`);
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
/** wrangler secret bulk from a 0600 temp file. Creates the Worker (a placeholder) when it does not exist. */
|
|
691
|
+
async function putSecrets(ctx, name, accountId, secrets) {
|
|
692
|
+
const w = makeWrangler(ctx.run, { configPath: ctx.paths.wrangler, accountId });
|
|
693
|
+
const r = await withSecretsFile(secrets, (file) => w.run(["secret", "bulk", file], { env: { CI: "1" }, timeoutMs: 120_000 }));
|
|
694
|
+
const out = `${r.stdout}\n${r.stderr}`;
|
|
695
|
+
if (r.code !== 0 || /Secrets failed to upload|No content found/i.test(out)) {
|
|
696
|
+
throw classifyDeployFailure(out) === "auth"
|
|
697
|
+
? new CliError("wrangler is not logged in or the token lacks permissions", "run npx wrangler login, then rerun")
|
|
698
|
+
: new CliError(`wrangler secret bulk failed: ${lastErrorLine(out)}`, "check npx wrangler whoami, then rerun cf-orbs init --no-deploy");
|
|
699
|
+
}
|
|
700
|
+
if (/Creating new Worker/i.test(out))
|
|
701
|
+
ctx.ui.step(`worker: created ${name} (a placeholder until the first build)`);
|
|
702
|
+
ctx.ui.step(`secrets: set ${Object.keys(secrets).join(" and ")} on Worker ${name}`);
|
|
383
703
|
}
|
|
384
704
|
/**
|
|
385
705
|
* Read the provider log tail from /v1/status. Only lines since the last container start count.
|
|
@@ -425,16 +745,22 @@ export function parseVcpuFlag(v) {
|
|
|
425
745
|
throw new CliError(`--vcpu must be 1, 2, 3 or 4, got ${JSON.stringify(v)}`, "pass --vcpu 4 (default, 12 GiB) or --vcpu 2 (6 GiB, about half the cost)");
|
|
426
746
|
return Number(v);
|
|
427
747
|
}
|
|
748
|
+
const WORKER_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
428
749
|
export function initOptions(ctx, existing) {
|
|
429
750
|
const modeFlag = flagString(ctx.args, "provider");
|
|
430
751
|
if (modeFlag !== undefined && modeFlag !== "cloudflare" && modeFlag !== "external") {
|
|
431
752
|
throw new CliError(`--provider must be cloudflare or external, got ${modeFlag}`, "pass --provider cloudflare (default) or --provider external");
|
|
432
753
|
}
|
|
754
|
+
const name = flagString(ctx.args, "name");
|
|
755
|
+
if (name !== undefined && !WORKER_NAME_RE.test(name))
|
|
756
|
+
throw new CliError(`--name ${JSON.stringify(name)} is not a valid Worker name (a-z, 0-9, -; max 63)`, "pass --name cf-orbs or another lowercase name");
|
|
433
757
|
const explicitName = flagString(ctx.args, "provider-name") ?? ctx.env.AMP_ORB_PROVIDER_NAME ?? existing?.providerName;
|
|
434
758
|
return {
|
|
435
759
|
providerNameExplicit: explicitName !== undefined,
|
|
436
760
|
subdomain: flagString(ctx.args, "subdomain") ?? ctx.env.CF_ORBS_SUBDOMAIN ?? undefined,
|
|
437
761
|
vcpu: parseVcpuFlag(flagString(ctx.args, "vcpu")),
|
|
762
|
+
name,
|
|
763
|
+
noDeploy: flagBool(ctx.args, "no-deploy"),
|
|
438
764
|
mode: modeFlag ?? existing?.providerHost ?? "cloudflare",
|
|
439
765
|
nonInteractive: flagBool(ctx.args, "non-interactive") || Boolean(ctx.env.CI),
|
|
440
766
|
tokenFile: flagString(ctx.args, "token-file"),
|
|
@@ -444,6 +770,15 @@ export function initOptions(ctx, existing) {
|
|
|
444
770
|
existing?.providerName ??
|
|
445
771
|
existing?.name ??
|
|
446
772
|
"cf-orbs",
|
|
773
|
+
providerNameSource: flagString(ctx.args, "provider-name") !== undefined
|
|
774
|
+
? "flag"
|
|
775
|
+
: ctx.env.AMP_ORB_PROVIDER_NAME !== undefined
|
|
776
|
+
? "env"
|
|
777
|
+
: existing?.providerName !== undefined
|
|
778
|
+
? "saved"
|
|
779
|
+
: existing?.name !== undefined
|
|
780
|
+
? "config"
|
|
781
|
+
: "default",
|
|
447
782
|
};
|
|
448
783
|
}
|
|
449
784
|
export async function initCommand(ctx) {
|
|
@@ -455,11 +790,23 @@ export async function initCommand(ctx) {
|
|
|
455
790
|
}
|
|
456
791
|
existing.providerName = (await readState(paths)).providerName;
|
|
457
792
|
const o = initOptions(ctx, existing);
|
|
458
|
-
ui.step(`cf-orbs init in ${paths.dir} (provider host: ${o.mode})`);
|
|
459
|
-
const probes = await gatherProbes(ctx);
|
|
793
|
+
ui.step(`cf-orbs init in ${paths.dir} (provider host: ${o.mode}${o.noDeploy ? "; --no-deploy: Workers Builds deploys" : ""})`);
|
|
794
|
+
const probes = await gatherProbes(ctx, o);
|
|
460
795
|
const steps = plan(o, probes);
|
|
461
796
|
const r = await runInit(ctx, o, steps);
|
|
462
|
-
|
|
797
|
+
if (r.git)
|
|
798
|
+
ui.result({
|
|
799
|
+
ok: true,
|
|
800
|
+
mode: "git",
|
|
801
|
+
providerHost: o.mode,
|
|
802
|
+
url: r.url,
|
|
803
|
+
providerName: r.providerName,
|
|
804
|
+
settings: r.git.settings,
|
|
805
|
+
steps: r.git.lines.filter((l) => l !== ""),
|
|
806
|
+
next: r.command,
|
|
807
|
+
});
|
|
808
|
+
else
|
|
809
|
+
ui.result({ ok: true, url: r.url, mode: o.mode, providerName: r.providerName, next: r.command });
|
|
463
810
|
if (r.command)
|
|
464
811
|
ui.out(r.command);
|
|
465
812
|
return 0;
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -8,7 +8,7 @@ import { makeApi } from "../api.js";
|
|
|
8
8
|
import { flagBool, flagString } from "../args.js";
|
|
9
9
|
import { AMP_DEFAULT_IMAGE_REPO, CONFIG_FILE, loadConfig, parseConfigText, setTopLevelJsonc, } from "../config.js";
|
|
10
10
|
import { CliError } from "../errors.js";
|
|
11
|
-
import { readState, requireControlToken } from "../project.js";
|
|
11
|
+
import { materialize, readState, requireControlToken, writeWranglerConfig } from "../project.js";
|
|
12
12
|
import { deployProject, restartProviderAfterDeploy, startPrewarm } from "./deploy.js";
|
|
13
13
|
import { AMP_CACHE_STEPS } from "./snapshots.js";
|
|
14
14
|
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
@@ -73,7 +73,16 @@ export async function upgradeCommand(ctx) {
|
|
|
73
73
|
ui.warn("paused orbs may not restore after upgrade (their snapshots name image key orb, which is now the new image); push or commit their work first");
|
|
74
74
|
ui.warn("awake orbs keep the old Worker code until they pause; keep the old key until their snapshots expire (30 days)");
|
|
75
75
|
if (flagBool(ctx.args, "no-deploy")) {
|
|
76
|
-
|
|
76
|
+
const state = await readState(paths);
|
|
77
|
+
if (state.mode === "git") {
|
|
78
|
+
// Workers Builds deploys the committed wrangler.jsonc, so write it now with the new images entry
|
|
79
|
+
await materialize(paths, { templates: ctx.templates, git: true });
|
|
80
|
+
await writeWranglerConfig(paths, await loadConfig(paths.config), state.accountId);
|
|
81
|
+
ui.step("upgrade: wrote .cf-orbs/wrangler.jsonc");
|
|
82
|
+
}
|
|
83
|
+
ui.out(state.mode === "git"
|
|
84
|
+
? "Commit and push: Workers Builds builds and deploys the new image."
|
|
85
|
+
: "Run cf-orbs deploy to build and push the new image.");
|
|
77
86
|
for (const l of AMP_CACHE_STEPS)
|
|
78
87
|
ui.out(l);
|
|
79
88
|
ui.result({ ok: true, changed: true, deployed: false, oldKey: p.oldKey, nextSteps: AMP_CACHE_STEPS });
|
package/dist/config.js
CHANGED
|
@@ -307,7 +307,7 @@ export function validateInstance(v) {
|
|
|
307
307
|
export function instanceMemoryMib(i) {
|
|
308
308
|
if (typeof i === "object")
|
|
309
309
|
return i.memoryMib;
|
|
310
|
-
return {
|
|
310
|
+
return { "standard-1": 4096, "standard-2": 6144, "standard-3": 8192, "standard-4": 12288 }[i];
|
|
311
311
|
}
|
|
312
312
|
/** fastTmpfsGb: 0-8, and at most the orb memory minus 3 GiB. Same rule as worker validateFastTmpfs. */
|
|
313
313
|
export function validateFastTmpfs(v, instance) {
|