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.
@@ -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 (!p.docker) {
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
- if (o.mode === "cloudflare") {
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 && p.prevMode === "cloudflare")
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 = [account, { kind: "scaffold", mode: o.mode }];
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
- export async function gatherProbes(ctx) {
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 = { ...state, accountId, providerName: o.providerName, mode: s.mode, workerName: config.name };
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 t = await askHidden(`Amp provider token (aop_live_…, not shown)${keep ? "; Enter keeps the current one" : ""}: `);
268
- if (t === "" && keep)
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
- ui.result({ ok: true, url: r.url, mode: o.mode, providerName: r.providerName, next: r.command });
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;
@@ -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
- ui.out("Run cf-orbs deploy to build and push the new image.");
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 { lite: 256, "standard-1": 4096, "standard-2": 6144, "standard-3": 8192, "standard-4": 12288 }[i];
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) {