cf-orbs 0.1.0 → 0.2.1

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 CHANGED
@@ -35,7 +35,7 @@ When Amp pauses an orb, cf-orbs saves the disk as a snapshot and stops the conta
35
35
  | You need | Check |
36
36
  | --- | --- |
37
37
  | Node 22 or later (wrangler 4 needs it) | `node --version` |
38
- | Docker, running. wrangler builds the orb image on your machine | `docker info` |
38
+ | Docker, running. wrangler builds the orb image on your machine (not for the [Workers Builds path](#install-without-docker-workers-builds-preview)) | `docker info` |
39
39
  | A Cloudflare account on **Workers Paid** ($5/month; Containers need it) | Dashboard → Workers & Pages → Plans |
40
40
  | wrangler logged in to that account | `npx wrangler login`, then `npx wrangler whoami` |
41
41
  | An Amp provider token (`aop_live_…`) | Amp → Settings → Orbs → Add Provider. Note the provider name you type there |
@@ -95,6 +95,38 @@ node cli/bin/cf-orbs.js init ../my-orbs --provider-name <name> # asks for the
95
95
 
96
96
  Then use `node <repo>/cli/bin/cf-orbs.js` wherever this README says `cf-orbs`, with `--dir ../my-orbs` or from inside that directory.
97
97
 
98
+ ### Install without Docker (Workers Builds, preview)
99
+
100
+ No Docker on your machine? `init --no-deploy` makes a git repo and the Worker with its secrets, but does not deploy. You push the repo to GitHub or GitLab and connect it in the Cloudflare dashboard. Workers Builds then builds both images in your account and deploys on each push. Measured: first build 10.6 min, upgrade 5.6 min, plus 2-4 min in the queue.
101
+
102
+ ```sh
103
+ npx cf-orbs init --no-deploy --provider-name <name from Amp> --token-file <file>
104
+ ```
105
+
106
+ It prints the dashboard settings to use. Full procedure and caveats: [docs/install-workers-builds.md](https://github.com/meirdick/cf-orbs/blob/main/docs/install-workers-builds.md). This is a preview: the default install above (local Docker) and all defaults do not change.
107
+
108
+ ### What init validates
109
+
110
+ Before the deploy, `init` stops with one `Failed: … — fix: …` line when:
111
+
112
+ - Node is older than 22.
113
+ - Docker does not answer `docker info` (not checked with `--no-deploy`).
114
+ - wrangler is not logged in, sees no account, or sees several and you gave no `--account-id` (or an id that `wrangler whoami` does not list).
115
+ - There is no Amp token, or it is empty or has spaces (a token without `aop_live_` gives a warning). With `--no-deploy` the token is optional.
116
+ - `cf-orbs.config.jsonc` is not valid: unknown keys, sizes out of range, a bad name or image key.
117
+ - An extra image key has no `image/<key>.Dockerfile`.
118
+ - The account has no workers.dev subdomain and you gave no `--subdomain` (in a terminal, `init` asks for one).
119
+ - `wrangler containers list` fails with a clear plan or login error (Workers Paid is needed). Any other error is a warning.
120
+
121
+ After the deploy, `init` checks that `/v1/health` answers, that `/v1/status` accepts the control token, that at least one prewarm instance starts from the new image (self-test; skipped when `prewarm` is 0, and with `--no-deploy` until a build has run), and that the provider runs for 15 s with no token rejection and no second provider on the token.
122
+
123
+ `cf-orbs doctor` also checks the project files, in every mode:
124
+
125
+ - `image/Dockerfile` (and each extra image key) and the provider image use `FROM <image>@sha256:<digest>`.
126
+ - `debugExec` is `false` (WARN when `true`).
127
+ - No `aop_live_` token and no control token value is in a file that git tracks (SKIP outside a git repo).
128
+ - `--no-deploy` projects only: `.gitignore` keeps out `.cf-orbs/control-token` and `.cf-orbs/state.json`, and `package.json` pins the wrangler version that cf-orbs uses.
129
+
98
130
  ## What you get
99
131
 
100
132
  - Orbs that run on your Cloudflare account. The repo checkout, build output and saved disks stay there.
@@ -149,7 +181,7 @@ Example: 120 awake hours a month at 20% CPU costs about $27.50 a month in total
149
181
 
150
182
  | Command | Does |
151
183
  | --- | --- |
152
- | `cf-orbs init [dir]` | Scaffold, deploy and check a provider. Flags: `--provider cloudflare\|external`, `--token-file`, `--account-id`, `--provider-name`, `--subdomain`, `--vcpu <1\|2\|3\|4>` |
184
+ | `cf-orbs init [dir]` | Scaffold, deploy and check a provider. Flags: `--provider cloudflare\|external`, `--token-file`, `--account-id`, `--provider-name`, `--subdomain`, `--vcpu <1\|2\|3\|4>`, `--name <worker>`, `--no-deploy` (preview: [Workers Builds](https://github.com/meirdick/cf-orbs/blob/main/docs/install-workers-builds.md), no Docker) |
153
185
  | `cf-orbs status [orb]` | Install status (provider, awake orbs, snapshots), or one orb |
154
186
  | `cf-orbs logs <orb>` | Redacted bootstrap log of one orb. `--lines N` (default 200) |
155
187
  | `cf-orbs logs --provider` | Redacted provider log (last 20 lines) |
@@ -240,11 +272,11 @@ To report a vulnerability, see [SECURITY.md](https://github.com/meirdick/cf-orbs
240
272
 
241
273
  ## Status and known gaps
242
274
 
243
- v0.1. The control plane, dispatch program, provider host and CLI are built and unit tested. On 2026-10-30 a real Amp thread ran end to end on a fresh install: spawn from the image, setup, pause with a project snapshot, wake from the snapshot in 2.3 s, first answer 41 s after the message.
275
+ v0.2.1. The control plane, dispatch program, provider host and CLI are built and unit tested. On 2026-10-30 a real Amp thread ran end to end on a fresh install: spawn from the image, setup, pause with a project snapshot, wake from the snapshot in 2.3 s, first answer 41 s after the message.
244
276
 
245
277
  Known gaps:
246
278
 
247
- - `init` has no orb self-test yet, and no Workers Paid check before the first deploy (a Free account finds out from the deploy error).
279
+ - The plan check before the first deploy is best effort: it asks wrangler to list Containers and stops only on a clear plan or login error. Any other error is a warning, and a Free account may still find out from the deploy error.
248
280
  - Paused orbs restore after an image rebuild. A restored disk keeps its old content; new image content reaches only orbs started from the image.
249
281
  - Orb lost mid-turn: Amp spawns the orb again and the agent runs the failed tool call again. The disk is kept; running processes and shell state are lost.
250
282
  - Not tested yet: the provider's memory on `lite` over 24 h, restore after the source image is deleted from the registry, the Desktop pane, and Docker inside an orb. Snapshot storage has no published price (beta).
@@ -263,6 +295,7 @@ Method, results and costs: [docs/benchmarks.md](https://github.com/meirdick/cf-o
263
295
  | Doc | Covers |
264
296
  | --- | --- |
265
297
  | [AGENTS.md](https://github.com/meirdick/cf-orbs/blob/main/AGENTS.md) | Install guide for an AI agent at a terminal |
298
+ | [Install without Docker](https://github.com/meirdick/cf-orbs/blob/main/docs/install-workers-builds.md) | Preview: Workers Builds builds and deploys from a git repo |
266
299
  | [Provider host](https://github.com/meirdick/cf-orbs/blob/main/provider/README.md) | Cloudflare-hosted vs external provider, token rotation |
267
300
  | [Orb image](https://github.com/meirdick/cf-orbs/blob/main/image/README.md) | The pinned image, toolchains, why snapshots are tied to it |
268
301
  | [Dispatch](https://github.com/meirdick/cf-orbs/blob/main/dispatch/README.md) | The program Amp runs per job, and its protocol |
@@ -12,7 +12,8 @@ import { deployWithRetry, lastErrorLine, makeWrangler } from "../wrangler.js";
12
12
  export async function deployProject(ctx, input) {
13
13
  const { ui, paths } = ctx;
14
14
  const { config, state } = input;
15
- await materialize(paths, { templates: ctx.templates });
15
+ const git = state.mode === "git";
16
+ await materialize(paths, { templates: ctx.templates, git });
16
17
  const missing = missingDockerfiles(paths, config);
17
18
  if (missing.length > 0) {
18
19
  throw new CliError(`image Dockerfile missing: ${missing.join(", ")}`, "create it, or remove its key from images in cf-orbs.config.jsonc");
@@ -38,7 +39,8 @@ export async function deployProject(ctx, input) {
38
39
  ...state,
39
40
  url,
40
41
  workerName: config.name,
41
- mode: config.providerHost,
42
+ // git mode stays git mode: Workers Builds still deploys from the repo
43
+ mode: git ? "git" : config.providerHost,
42
44
  deployedAt: new Date(ctx.now()).toISOString(),
43
45
  cliVersion: await cliVersion(),
44
46
  });
@@ -1,9 +1,11 @@
1
- // cf-orbs doctor: PASS/FAIL per check with a one-line fix, plus INFO hints that never fail. Read-only: never starts a provider or an orb.
1
+ // cf-orbs doctor: PASS/FAIL per check with a one-line fix, plus INFO hints and WARN lines that never fail.
2
+ // Read-only: never starts a provider or an orb.
2
3
  import { existsSync } from "node:fs";
3
4
  import { collectImageKeys, expectOk, providerInfo } from "../api.js";
4
5
  import { CONFIG_FILE, loadConfig, orbImageKeys } from "../config.js";
5
6
  import { connect } from "../connect.js";
6
7
  import { CliError, toFailedLine } from "../errors.js";
8
+ import { fileChecks } from "../file-checks.js";
7
9
  import { fileMode, readState } from "../project.js";
8
10
  import { classifyDeployFailure, lastErrorLine, makeWrangler } from "../wrangler.js";
9
11
  import { AMP_CACHE_HINT } from "./snapshots.js";
@@ -77,6 +79,10 @@ export async function runDoctor(ctx) {
77
79
  }
78
80
  else
79
81
  checks.push(pass("control token", "present, mode 600"));
82
+ checks.push(...(await fileChecks(ctx.run, ctx.paths, config, {
83
+ git: state.mode === "git",
84
+ templates: ctx.templates,
85
+ })));
80
86
  const name = config?.name ?? state.workerName ?? "cf-orbs";
81
87
  const w = makeWrangler(ctx.run, { accountId: state.accountId });
82
88
  const d = await w.run(["deployments", "list", "--json", "--name", name], { timeoutMs: 60_000 });
@@ -106,20 +112,24 @@ export async function runDoctor(ctx) {
106
112
  checks.push(failed("health", e));
107
113
  }
108
114
  let status;
115
+ // git mode: Workers Builds deploys, so the fix is the build, not cf-orbs deploy
116
+ const apiFailed = (n, e) => state.mode === "git" && e instanceof CliError
117
+ ? failed(n, new CliError(e.cause_, `wait for the Workers Builds build to finish (Workers & Pages → ${name} → Deployments), or open its build log`))
118
+ : failed(n, e);
109
119
  if (api) {
110
120
  try {
111
121
  expectOk(await api.get("/v1/health"), "health");
112
122
  checks.push(pass("health", `${api.url}/v1/health ok`));
113
123
  }
114
124
  catch (e) {
115
- checks.push(failed("health", e));
125
+ checks.push(apiFailed("health", e));
116
126
  }
117
127
  try {
118
128
  status = expectOk(await api.get("/v1/status"), "status");
119
129
  checks.push(pass("status", "/v1/status ok (control token accepted)"));
120
130
  }
121
131
  catch (e) {
122
- checks.push(failed("status", e));
132
+ checks.push(apiFailed("status", e));
123
133
  }
124
134
  }
125
135
  // Names only: wrangler secret list never returns values.
@@ -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
  }
@@ -263,9 +349,16 @@ export async function runInit(ctx, o, steps) {
263
349
  }
264
350
  else if (s.source === "prompt") {
265
351
  // 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)
352
+ const keep = Boolean(state.url) && (prevMode === "cloudflare" || prevMode === "git");
353
+ const enter = o.noDeploy
354
+ ? "; Enter skips (set it later)"
355
+ : keep
356
+ ? "; Enter keeps the current one"
357
+ : "";
358
+ const t = await askHidden(`Amp provider token (aop_live_…, not shown)${enter}: `);
359
+ if (t === "" && o.noDeploy)
360
+ ui.step("amp token: not given; set it later (see the steps below)");
361
+ else if (t === "" && keep)
269
362
  ui.step("amp token: keeping the current Worker secret");
270
363
  else
271
364
  ampToken = checkAmpToken(ctx, t);
@@ -320,8 +413,69 @@ export async function runInit(ctx, o, steps) {
320
413
  lastStatus = expectOk(await api.get("/v1/status"), "status check");
321
414
  ui.step("health: /v1/status ok (control token accepted)");
322
415
  if (config)
323
- await startPrewarm(ctx, api, config);
416
+ prewarmStarted = (await startPrewarm(ctx, api, config)).started;
417
+ prewarmAt = ctx.now();
324
418
  break;
419
+ case "selftest": {
420
+ if (o.noDeploy) {
421
+ // Workers Builds deploys later, so this run started no prewarm. Report an earlier run if the
422
+ // Worker has one (a rerun after a build); else skip.
423
+ const last = await lastPrewarm(url, controlToken, ctx);
424
+ if (last && Number(last.pending) === 0 && Number(last.count) > 0) {
425
+ // an old failed run does not fail init: the next build may fix the image
426
+ if (Number(last.ok) > 0)
427
+ reportSelftest(ui, last);
428
+ else
429
+ ui.warn("self-test: the last prewarm run started no instance; check the build, then cf-orbs status");
430
+ }
431
+ else
432
+ ui.step("self-test: skipped (Workers Builds deploys later; after the first build, cf-orbs status shows the prewarm result)");
433
+ break;
434
+ }
435
+ // The prewarm run starts throwaway orbs from the new image. If one becomes ready, the image,
436
+ // the registry and the container runtime work. No Amp thread and no debug route needed.
437
+ if (prewarmStarted === 0) {
438
+ ui.step('self-test: skipped (no prewarm instance was started; set "prewarm" above 0 to enable)');
439
+ break;
440
+ }
441
+ const until = ctx.now() + SELFTEST_WAIT_MS;
442
+ let view = {};
443
+ for (;;) {
444
+ const r = await api.get("/v1/warm");
445
+ const last = warmLast(r.body);
446
+ // Ignore the result of an earlier run.
447
+ if (typeof last.at === "string" && Date.parse(last.at) >= prewarmAt - 10_000) {
448
+ view = last;
449
+ if (Number(last.pending) === 0)
450
+ break;
451
+ }
452
+ if (ctx.now() >= until)
453
+ break;
454
+ await ctx.sleep(3_000);
455
+ }
456
+ if (Number(view.count) > 0 && Number(view.pending) === 0)
457
+ reportSelftest(ui, view);
458
+ else
459
+ ui.warn("self-test: still running after 3 min; the first start on a new host pulls the image. Check cf-orbs status");
460
+ break;
461
+ }
462
+ case "plan-check": {
463
+ // Before the image build: a Free account fails here in seconds, not after a 1.8 GB push.
464
+ // Best effort: only a clear plan or login error stops init; any other error is a warning.
465
+ const r = await makeWrangler(ctx.run, { accountId }).run(["containers", "list", "--json"], {
466
+ timeoutMs: 60_000,
467
+ });
468
+ if (r.code === 0) {
469
+ ui.step("plan: Containers are available on this account");
470
+ break;
471
+ }
472
+ const out = `${r.stdout}\n${r.stderr}`;
473
+ const kind = classifyDeployFailure(out);
474
+ if (kind === "paid" || kind === "auth")
475
+ throw deployFailureError(kind, out);
476
+ ui.warn(`plan check skipped (wrangler containers list failed: ${lastErrorLine(out)})`);
477
+ break;
478
+ }
325
479
  case "start-provider": {
326
480
  const r = await api.post("/v1/provider/start");
327
481
  if (r.status === 404 || r.status === 405) {
@@ -365,6 +519,52 @@ export async function runInit(ctx, o, steps) {
365
519
  }
366
520
  break;
367
521
  }
522
+ case "git-files": {
523
+ if (!config)
524
+ throw new Error("init plan bug: git-files before scaffold");
525
+ const missing = missingDockerfiles(paths, config);
526
+ if (missing.length > 0)
527
+ throw new CliError(`image Dockerfile missing: ${missing.join(", ")}`, "create it, or remove its key from images in cf-orbs.config.jsonc");
528
+ await writeWranglerConfig(paths, config, accountId);
529
+ ui.step("git: wrote .cf-orbs/wrangler.jsonc (Workers Builds deploys with it)");
530
+ if (!existsSync(paths.packageJson)) {
531
+ await writeFile(paths.packageJson, gitPackageJson(config.name, WRANGLER_PIN));
532
+ ui.step(`git: wrote package.json (wrangler ${WRANGLER_PIN} for Workers Builds)`);
533
+ }
534
+ await writeLockfile(ctx);
535
+ repoExisted = await gitCommitNew(ctx, ampToken);
536
+ break;
537
+ }
538
+ case "git-secrets": {
539
+ if (!config || !controlToken)
540
+ throw new Error("init plan bug: git-secrets before scaffold");
541
+ const secrets = { CONTROL_TOKEN: controlToken };
542
+ if (ampToken)
543
+ secrets.AMP_ORB_PROVIDER_TOKEN = ampToken;
544
+ await putSecrets(ctx, config.name, accountId, secrets);
545
+ url = subdomain ? `https://${config.name}.${subdomain}.workers.dev` : state.url;
546
+ if (!url)
547
+ 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");
548
+ state = {
549
+ ...state,
550
+ url,
551
+ accountId,
552
+ mode: "git",
553
+ workerName: config.name,
554
+ cliVersion: await cliVersion(),
555
+ };
556
+ await writeState(paths, state);
557
+ break;
558
+ }
559
+ case "git-next":
560
+ git = gitNext({
561
+ worker: config?.name ?? "cf-orbs",
562
+ repoExisted,
563
+ ampTokenGiven: Boolean(ampToken),
564
+ });
565
+ for (const l of git.lines)
566
+ ui.out(l);
567
+ break;
368
568
  case "print-external-env":
369
569
  ui.out("External provider mode. On an always-on host with the amp CLI, run:");
370
570
  ui.out(" export AMP_ORB_PROVIDER_TOKEN=<your aop_live_ token>");
@@ -379,7 +579,123 @@ export async function runInit(ctx, o, steps) {
379
579
  break;
380
580
  }
381
581
  }
382
- return { url, command, providerName };
582
+ return { url, command, providerName, ...(git ? { git } : {}) };
583
+ }
584
+ /** Workers Builds settings for the dashboard (Settings → Builds → Connect), as tested on 2026-11-02. */
585
+ export const GIT_PATH_FILTERS = ["image/**", ".cf-orbs/**", "cf-orbs.config.jsonc", "package*.json"];
586
+ /** The next steps init --no-deploy prints. Pure. */
587
+ export function gitNext(o) {
588
+ const settings = {
589
+ worker: o.worker,
590
+ branch: "main",
591
+ buildCommand: "",
592
+ deployCommand: GIT_DEPLOY_COMMAND,
593
+ rootDirectory: "/",
594
+ previews: false,
595
+ apiToken: "default build token",
596
+ buildWatchPaths: GIT_PATH_FILTERS,
597
+ };
598
+ const push = o.repoExisted
599
+ ? [
600
+ "1. Commit these files, then push the repo to GitHub or GitLab:",
601
+ " git add -A && git commit -m 'cf-orbs init'",
602
+ " git push",
603
+ ]
604
+ : [
605
+ "1. Make an empty repo on GitHub or GitLab. Push this directory to it:",
606
+ " git remote add origin <repo URL>",
607
+ " git push -u origin main",
608
+ ];
609
+ const lines = [
610
+ "",
611
+ "Next steps. Workers Builds builds the images in your Cloudflare account; this machine needs no Docker.",
612
+ ...push,
613
+ `2. Connect the repo: Cloudflare dashboard → Workers & Pages → ${o.worker} → Settings → Builds → Connect.`,
614
+ " Repository: the repo from step 1",
615
+ ` Branch: ${settings.branch}${o.repoExisted ? " (the branch you push)" : ""}`,
616
+ " Build command: (leave empty)",
617
+ ` Deploy command: ${settings.deployCommand}`,
618
+ ` Root directory: ${settings.rootDirectory}`,
619
+ " Previews (builds for non-production branches): off",
620
+ " API token: the default build token",
621
+ ` Build watch paths: include ${settings.buildWatchPaths.join(", ")}`,
622
+ " If no build starts, push a commit. Measured: first build 10.6 min, plus 2-4 min in the queue.",
623
+ "3. After the first build: cf-orbs status",
624
+ " The 5-min cron starts the provider. The first spawn on each host pulls the image (40-50 s).",
625
+ ...(o.ampTokenGiven
626
+ ? []
627
+ : [
628
+ "4. No Amp token given. If the Worker has none yet, set it with:",
629
+ " cf-orbs init --no-deploy --token-file <file> (no build needed)",
630
+ ]),
631
+ "Upgrades: edit image/Dockerfile or cf-orbs.config.jsonc (or cf-orbs upgrade --digest sha256:<hex> --no-deploy), commit, push.",
632
+ "Docs: https://github.com/meirdick/cf-orbs/blob/main/docs/install-workers-builds.md",
633
+ "Then try it (spends Amp credits):",
634
+ ];
635
+ return { settings, lines };
636
+ }
637
+ /** package-lock.json for npm clean-install in Workers Builds. A failure is one warning. */
638
+ async function writeLockfile(ctx) {
639
+ const { paths, ui } = ctx;
640
+ if (existsSync(join(paths.dir, "package-lock.json")))
641
+ return;
642
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
643
+ const r = await ctx.run(npm, ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], {
644
+ cwd: paths.dir,
645
+ timeoutMs: 180_000,
646
+ });
647
+ if (r.code === 0)
648
+ ui.step("git: wrote package-lock.json");
649
+ else
650
+ 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`);
651
+ }
652
+ /**
653
+ * git init + first commit when dir is in no git work tree. Returns true when a repo was there already.
654
+ * Checks the staged files for tokens before the commit.
655
+ */
656
+ async function gitCommitNew(ctx, ampToken) {
657
+ const { paths, ui } = ctx;
658
+ const g = (args) => ctx.run("git", ["-C", paths.dir, ...args], { timeoutMs: 60_000 });
659
+ if ((await g(["rev-parse", "--is-inside-work-tree"])).code === 0) {
660
+ ui.step("git: this directory is in a git repo already; commit and push the changes yourself");
661
+ return true;
662
+ }
663
+ const init = await g(["init", "-b", "main"]);
664
+ if (init.code !== 0) {
665
+ 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}`);
666
+ return false;
667
+ }
668
+ await g(["add", "-A"]);
669
+ const ct = await readControlToken(paths);
670
+ const staged = (await gitTrackedFiles(ctx.run, paths.dir)) ?? [];
671
+ const hits = await filesWithSecrets(paths.dir, staged, [
672
+ ...(ct ? [ct] : []),
673
+ ...(ampToken ? [ampToken] : []),
674
+ ]);
675
+ if (hits.length > 0) {
676
+ await g(["reset", "-q"]);
677
+ 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");
678
+ }
679
+ const c = await g(["commit", "-q", "-m", "cf-orbs init (Workers Builds)"]);
680
+ if (c.code === 0)
681
+ ui.step(`git: made a repo on branch main with a first commit (${staged.length} files)`);
682
+ else
683
+ 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}`);
684
+ return false;
685
+ }
686
+ /** wrangler secret bulk from a 0600 temp file. Creates the Worker (a placeholder) when it does not exist. */
687
+ async function putSecrets(ctx, name, accountId, secrets) {
688
+ const w = makeWrangler(ctx.run, { configPath: ctx.paths.wrangler, accountId });
689
+ const r = await withSecretsFile(secrets, (file) => w.run(["secret", "bulk", file], { env: { CI: "1" }, timeoutMs: 120_000 }));
690
+ const out = `${r.stdout}\n${r.stderr}`;
691
+ if (r.code !== 0 || /Secrets failed to upload|No content found/i.test(out)) {
692
+ throw classifyDeployFailure(out) === "auth"
693
+ ? new CliError("wrangler is not logged in or the token lacks permissions", "run npx wrangler login, then rerun")
694
+ : new CliError(`wrangler secret bulk failed: ${lastErrorLine(out)}`, "check npx wrangler whoami, then rerun cf-orbs init --no-deploy");
695
+ }
696
+ if (/Creating new Worker/i.test(out))
697
+ ctx.ui.step(`worker: created ${name} (a placeholder until the first build)`);
698
+ ctx.ui.step(`secrets: set ${Object.keys(secrets).join(" and ")} on Worker ${name}`);
383
699
  }
384
700
  /**
385
701
  * Read the provider log tail from /v1/status. Only lines since the last container start count.
@@ -425,16 +741,22 @@ export function parseVcpuFlag(v) {
425
741
  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
742
  return Number(v);
427
743
  }
744
+ const WORKER_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
428
745
  export function initOptions(ctx, existing) {
429
746
  const modeFlag = flagString(ctx.args, "provider");
430
747
  if (modeFlag !== undefined && modeFlag !== "cloudflare" && modeFlag !== "external") {
431
748
  throw new CliError(`--provider must be cloudflare or external, got ${modeFlag}`, "pass --provider cloudflare (default) or --provider external");
432
749
  }
750
+ const name = flagString(ctx.args, "name");
751
+ if (name !== undefined && !WORKER_NAME_RE.test(name))
752
+ 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
753
  const explicitName = flagString(ctx.args, "provider-name") ?? ctx.env.AMP_ORB_PROVIDER_NAME ?? existing?.providerName;
434
754
  return {
435
755
  providerNameExplicit: explicitName !== undefined,
436
756
  subdomain: flagString(ctx.args, "subdomain") ?? ctx.env.CF_ORBS_SUBDOMAIN ?? undefined,
437
757
  vcpu: parseVcpuFlag(flagString(ctx.args, "vcpu")),
758
+ name,
759
+ noDeploy: flagBool(ctx.args, "no-deploy"),
438
760
  mode: modeFlag ?? existing?.providerHost ?? "cloudflare",
439
761
  nonInteractive: flagBool(ctx.args, "non-interactive") || Boolean(ctx.env.CI),
440
762
  tokenFile: flagString(ctx.args, "token-file"),
@@ -455,11 +777,23 @@ export async function initCommand(ctx) {
455
777
  }
456
778
  existing.providerName = (await readState(paths)).providerName;
457
779
  const o = initOptions(ctx, existing);
458
- ui.step(`cf-orbs init in ${paths.dir} (provider host: ${o.mode})`);
459
- const probes = await gatherProbes(ctx);
780
+ ui.step(`cf-orbs init in ${paths.dir} (provider host: ${o.mode}${o.noDeploy ? "; --no-deploy: Workers Builds deploys" : ""})`);
781
+ const probes = await gatherProbes(ctx, o);
460
782
  const steps = plan(o, probes);
461
783
  const r = await runInit(ctx, o, steps);
462
- ui.result({ ok: true, url: r.url, mode: o.mode, providerName: r.providerName, next: r.command });
784
+ if (r.git)
785
+ ui.result({
786
+ ok: true,
787
+ mode: "git",
788
+ providerHost: o.mode,
789
+ url: r.url,
790
+ providerName: r.providerName,
791
+ settings: r.git.settings,
792
+ steps: r.git.lines.filter((l) => l !== ""),
793
+ next: r.command,
794
+ });
795
+ else
796
+ ui.result({ ok: true, url: r.url, mode: o.mode, providerName: r.providerName, next: r.command });
463
797
  if (r.command)
464
798
  ui.out(r.command);
465
799
  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 });
@@ -0,0 +1,194 @@
1
+ // Checks on the project files: image pins, debugExec, secrets in tracked files, git mode files.
2
+ // Used by doctor (all modes) and by init --no-deploy before its first commit. Read-only.
3
+ import { existsSync } from "node:fs";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { CONFIG_FILE } from "./config.js";
7
+ import { CliError, toFailedLine } from "./errors.js";
8
+ import { readControlToken, templatesDir } from "./project.js";
9
+ import { WRANGLER_PIN } from "./wrangler.js";
10
+ const pass = (name, detail) => ({ name, status: "PASS", detail });
11
+ const failed = (name, e) => ({ name, status: "FAIL", detail: toFailedLine(e) });
12
+ const skip = (name, detail) => ({ name, status: "SKIP", detail });
13
+ const DIGEST_REF = /@sha256:[0-9a-f]{64}$/;
14
+ /** Base image refs in a Dockerfile that have no @sha256 digest. Earlier stage names and scratch are fine. */
15
+ export function unpinnedFroms(dockerfile) {
16
+ const stages = new Set(["scratch"]);
17
+ const out = [];
18
+ for (const line of dockerfile.split(/\r?\n/)) {
19
+ const m = /^\s*FROM\s+(.+)$/i.exec(line);
20
+ if (!m)
21
+ continue;
22
+ const words = m[1].trim().split(/\s+/);
23
+ const ref = words.find((w) => !w.startsWith("--"));
24
+ if (!ref)
25
+ continue;
26
+ const known = stages.has(ref.toLowerCase());
27
+ const as = words.findIndex((w) => w.toLowerCase() === "as");
28
+ if (as !== -1 && words[as + 1])
29
+ stages.add(words[as + 1].toLowerCase());
30
+ if (!known && !DIGEST_REF.test(ref))
31
+ out.push(ref);
32
+ }
33
+ return out;
34
+ }
35
+ /** "<repo>@sha256:0123456789ab" for a PASS line. */
36
+ function shortRefs(dockerfile) {
37
+ const refs = [...dockerfile.matchAll(/^\s*FROM\s+(?:--\S+\s+)*(\S+)/gim)].map((m) => m[1].replace(/@sha256:([0-9a-f]{12})[0-9a-f]{52}$/, "@sha256:$1…"));
38
+ return refs.join(", ");
39
+ }
40
+ async function pinCheck(name, file, label, fix) {
41
+ let text;
42
+ try {
43
+ text = await readFile(file, "utf8");
44
+ }
45
+ catch {
46
+ return failed(name, new CliError(`${label} not found`, "run cf-orbs init in this directory"));
47
+ }
48
+ const bad = unpinnedFroms(text);
49
+ if (bad.length > 0)
50
+ return failed(name, new CliError(`${label} FROM ${bad.join(", ")} has no @sha256 digest`, fix));
51
+ return pass(name, `${label} FROM ${shortRefs(text)} (by digest)`);
52
+ }
53
+ /** image/Dockerfile and the Dockerfile of every extra image key: each FROM is pinned by digest. */
54
+ export async function orbImagePinCheck(p, c) {
55
+ const keys = [...new Set(Object.values(c?.images ?? {}))];
56
+ const files = [
57
+ ["image/Dockerfile", p.imageDockerfile],
58
+ ...keys.map((k) => [`image/${k}.Dockerfile`, join(p.imageDir, `${k}.Dockerfile`)]),
59
+ ];
60
+ const fix = "pin the base image as <image>@sha256:<digest> (for the Amp image: cf-orbs upgrade --digest sha256:<hex>)";
61
+ let first;
62
+ let extra = 0;
63
+ for (const [label, file] of files) {
64
+ if (label !== "image/Dockerfile" && !existsSync(file))
65
+ continue; // deploy reports a missing one
66
+ const c1 = await pinCheck("orb image pin", file, label, fix);
67
+ if (c1.status === "FAIL")
68
+ return c1;
69
+ if (first)
70
+ extra++;
71
+ first ??= c1;
72
+ }
73
+ return extra > 0 && first
74
+ ? { ...first, detail: `${first.detail}; ${extra} extra image key(s) pinned too` }
75
+ : first;
76
+ }
77
+ /** The provider image cf-orbs builds: the project copy (.cf-orbs/provider) if there is one, else the template. */
78
+ export async function providerImagePinCheck(p, templates = templatesDir()) {
79
+ const copy = join(p.stateDir, "provider", "Dockerfile");
80
+ const tpl = join(templates, "provider", "Dockerfile");
81
+ // In a source checkout the templates are synced at build time; before that, read the source file.
82
+ const src = join(templates, "..", "..", "provider", "Dockerfile");
83
+ const [file, label] = existsSync(copy)
84
+ ? [copy, ".cf-orbs/provider/Dockerfile"]
85
+ : [existsSync(tpl) || !existsSync(src) ? tpl : src, "provider/Dockerfile (CLI template)"];
86
+ return pinCheck("provider image pin", file, label, "reinstall cf-orbs (the provider image is a CLI template), then cf-orbs deploy");
87
+ }
88
+ /** debugExec lets the control token run commands in orbs. WARN, never FAIL: the substrate bench needs it. */
89
+ export function debugExecCheck(c, git) {
90
+ if (!c)
91
+ return skip("debugExec", "needs a valid config");
92
+ if (!c.debugExec)
93
+ return pass("debugExec", "false (the control token cannot run commands in orbs)");
94
+ const then = git ? "commit and push" : "run cf-orbs deploy";
95
+ return {
96
+ name: "debugExec",
97
+ status: "WARN",
98
+ detail: toFailedLine(new CliError("debugExec is true: whoever has the control token can run any command in any orb", `set "debugExec": false in ${CONFIG_FILE}, then ${then} (keep it on only for the substrate bench)`)),
99
+ };
100
+ }
101
+ const AOP_TOKEN = /aop_live_[A-Za-z0-9_-]{8,}/;
102
+ const MAX_SCAN_BYTES = 10_000_000;
103
+ /**
104
+ * Files (relative to dir) that hold an Amp provider token or one of the given secret values.
105
+ * Never returns the values. Skips files over 10 MB.
106
+ */
107
+ export async function filesWithSecrets(dir, files, values) {
108
+ const hits = [];
109
+ const vals = values.filter((v) => v.length >= 8);
110
+ for (const f of files) {
111
+ const path = join(dir, f);
112
+ try {
113
+ if ((await stat(path)).size > MAX_SCAN_BYTES)
114
+ continue;
115
+ const text = await readFile(path, "latin1");
116
+ if (AOP_TOKEN.test(text) || vals.some((v) => text.includes(v)))
117
+ hits.push(f);
118
+ }
119
+ catch { }
120
+ }
121
+ return hits;
122
+ }
123
+ /** `git ls-files` of dir, or undefined when dir is not in a git work tree (or git is missing). */
124
+ export async function gitTrackedFiles(run, dir) {
125
+ const r = await run("git", ["-C", dir, "ls-files", "-z"], { timeoutMs: 30_000 });
126
+ if (r.code !== 0)
127
+ return undefined;
128
+ return r.stdout.split("\0").filter(Boolean);
129
+ }
130
+ /** No aop_live_ token and no control token value in a file git tracks. SKIP outside a git work tree. */
131
+ export async function trackedSecretsCheck(run, p, extra = []) {
132
+ const name = "secrets in git";
133
+ const files = await gitTrackedFiles(run, p.dir);
134
+ if (!files)
135
+ return skip(name, "not a git repository");
136
+ const ct = await readControlToken(p);
137
+ const hits = await filesWithSecrets(p.dir, files, [...(ct ? [ct] : []), ...extra]);
138
+ if (hits.length === 0)
139
+ return pass(name, `${files.length} tracked file(s), no aop_live_ or control token`);
140
+ return failed(name, new CliError(`a token is in tracked file(s): ${hits.slice(0, 5).join(", ")}${hits.length > 5 ? ", …" : ""}`, "git rm --cached those files (or remove the token), make a new token, and purge it from history if you pushed"));
141
+ }
142
+ /** Git mode: .cf-orbs/control-token and .cf-orbs/state.json are ignored and not tracked. */
143
+ export async function gitignoreCheck(run, p) {
144
+ const name = "gitignore";
145
+ const paths = [".cf-orbs/control-token", ".cf-orbs/state.json"];
146
+ const notIgnored = [];
147
+ for (const f of paths) {
148
+ // exit 0: ignored; 1: not ignored or tracked; 128: no repo. Fall back to the .gitignore lines then.
149
+ const r = await run("git", ["-C", p.dir, "check-ignore", "-q", f], { timeoutMs: 30_000 });
150
+ if (r.code === 0)
151
+ continue;
152
+ if (r.code === 1) {
153
+ notIgnored.push(f);
154
+ continue;
155
+ }
156
+ let lines = [];
157
+ try {
158
+ lines = (await readFile(p.gitignore, "utf8")).split(/\r?\n/).map((l) => l.trim());
159
+ }
160
+ catch { }
161
+ if (!lines.includes(f))
162
+ notIgnored.push(f);
163
+ }
164
+ if (notIgnored.length === 0)
165
+ return pass(name, `${paths.join(" and ")} are ignored`);
166
+ return failed(name, new CliError(`${notIgnored.join(" and ")} not ignored by git (or tracked)`, `add ${notIgnored.join(" and ")} to .gitignore and run git rm --cached on any that git tracks`));
167
+ }
168
+ /** Git mode: package.json pins the wrangler version the CLI uses (Workers Builds installs that one). */
169
+ export async function wranglerPinCheck(p) {
170
+ const name = "wrangler pin";
171
+ let pkg;
172
+ try {
173
+ pkg = JSON.parse(await readFile(p.packageJson, "utf8"));
174
+ }
175
+ catch {
176
+ return failed(name, new CliError("package.json is missing or not valid JSON", "rerun cf-orbs init --no-deploy (it writes one)"));
177
+ }
178
+ const v = pkg.devDependencies?.wrangler ?? pkg.dependencies?.wrangler;
179
+ if (v === WRANGLER_PIN)
180
+ return pass(name, `package.json pins wrangler ${v}, same as cf-orbs`);
181
+ return failed(name, new CliError(`package.json ${v ? `pins wrangler ${v}` : "has no wrangler dependency"}; cf-orbs uses ${WRANGLER_PIN}`, `set devDependencies.wrangler to "${WRANGLER_PIN}", run npm install, commit and push`));
182
+ }
183
+ /** All file checks for doctor, in output order. git: state.mode is "git". */
184
+ export async function fileChecks(run, p, c, o) {
185
+ const out = [
186
+ await orbImagePinCheck(p, c),
187
+ await providerImagePinCheck(p, o.templates),
188
+ debugExecCheck(c, o.git),
189
+ await trackedSecretsCheck(run, p),
190
+ ];
191
+ if (o.git)
192
+ out.push(await gitignoreCheck(run, p), await wranglerPinCheck(p));
193
+ return out;
194
+ }
package/dist/index.js CHANGED
@@ -33,6 +33,9 @@ export const COMMANDS = {
33
33
  "--provider-name <name> Amp provider name, as in Amp Settings → Orbs (default cf-orbs)",
34
34
  "--subdomain <name> workers.dev subdomain to register if the account has none",
35
35
  "--vcpu <1|2|3|4> orb size: N vCPU, N x 3 GiB, 20 GB disk (default 4; 2 is cheaper)",
36
+ "--name <name> Worker name (default cf-orbs; a second install needs another)",
37
+ "--no-deploy preview: no Docker, no deploy. Make a git repo for Workers Builds,",
38
+ " create the Worker and its secrets, print the dashboard steps",
36
39
  ],
37
40
  flags: {
38
41
  provider: { type: "string" },
@@ -41,6 +44,8 @@ export const COMMANDS = {
41
44
  "provider-name": { type: "string" },
42
45
  subdomain: { type: "string" },
43
46
  vcpu: { type: "string" },
47
+ name: { type: "string" },
48
+ "no-deploy": { type: "boolean" },
44
49
  },
45
50
  dirPositional: true,
46
51
  run: initCommand,
package/dist/project.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // cf-orbs.config.jsonc user config (source of truth)
7
7
  // image/Dockerfile orb image, user-editable; upgrade bumps its digest; image/<key>.Dockerfile for extra keys
8
8
  // .gitignore generated; keeps .cf-orbs/ (and its control-token) out of git
9
+ // (git mode: only control-token and state.json; Workers Builds needs the rest)
10
+ // package.json git mode only: pins wrangler for Workers Builds
9
11
  // .cf-orbs/
10
12
  // wrangler.jsonc generated
11
13
  // worker/src/** copied from templates on every deploy
@@ -35,6 +37,7 @@ export function projectPaths(dir) {
35
37
  state: join(s, "state.json"),
36
38
  controlToken: join(s, "control-token"),
37
39
  gitignore: join(d, ".gitignore"),
40
+ packageJson: join(d, "package.json"),
38
41
  };
39
42
  }
40
43
  /** cli/templates, resolved from this file (works from src/ under vitest and from dist/ when installed). */
@@ -105,15 +108,31 @@ export const GITIGNORE_LINES = [
105
108
  ".wrangler/",
106
109
  "node_modules/",
107
110
  ];
111
+ /** Git mode (init --no-deploy): the generated files are committed, so only these stay out of git. */
112
+ export const GIT_MODE_GITIGNORE_LINES = [
113
+ ".cf-orbs/control-token",
114
+ ".cf-orbs/state.json",
115
+ ".wrangler/",
116
+ "node_modules/",
117
+ ];
118
+ /** Lines of the local layout that hide the generated files. Git mode removes them. */
119
+ const LOCAL_ONLY_LINES = new Set([".cf-orbs/*", "!.cf-orbs/state.json"]);
108
120
  /** Add the cf-orbs lines to <dir>/.gitignore, keeping existing lines. */
109
- export async function ensureGitignore(p) {
121
+ export async function ensureGitignore(p, git = false) {
110
122
  let cur = "";
111
123
  try {
112
124
  cur = await readFile(p.gitignore, "utf8");
113
125
  }
114
126
  catch { }
127
+ if (git && cur.split(/\r?\n/).some((l) => LOCAL_ONLY_LINES.has(l.trim()))) {
128
+ cur = cur
129
+ .split(/(?<=\n)/)
130
+ .filter((l) => !LOCAL_ONLY_LINES.has(l.trim()))
131
+ .join("");
132
+ await writeFile(p.gitignore, cur);
133
+ }
115
134
  const have = new Set(cur.split(/\r?\n/).map((l) => l.trim()));
116
- const add = GITIGNORE_LINES.filter((l) => !have.has(l));
135
+ const add = (git ? GIT_MODE_GITIGNORE_LINES : GITIGNORE_LINES).filter((l) => !have.has(l));
117
136
  if (add.length === 0)
118
137
  return;
119
138
  const sep = cur === "" || cur.endsWith("\n") ? "" : "\n";
@@ -155,7 +174,7 @@ export async function materialize(p, opts = {}) {
155
174
  await writeFile(p.config, text);
156
175
  wroteConfig = true;
157
176
  }
158
- await ensureGitignore(p);
177
+ await ensureGitignore(p, opts.git);
159
178
  return { wroteConfig, wroteDockerfile };
160
179
  }
161
180
  /** The files the provider image is built from, relative to .cf-orbs/ (its build context). */
@@ -213,3 +232,15 @@ export async function cliVersion() {
213
232
  return "0.0.0";
214
233
  }
215
234
  }
235
+ /** Git mode deploy command: Workers Builds runs it in the repo root. */
236
+ export const GIT_DEPLOY_COMMAND = "npx wrangler deploy --config .cf-orbs/wrangler.jsonc";
237
+ /** Root package.json for git mode: Workers Builds installs the wrangler version it names. */
238
+ export function gitPackageJson(name, wrangler) {
239
+ const pkg = {
240
+ name,
241
+ private: true,
242
+ scripts: { deploy: "wrangler deploy --config .cf-orbs/wrangler.jsonc" },
243
+ devDependencies: { wrangler },
244
+ };
245
+ return `${JSON.stringify(pkg, null, 2)}\n`;
246
+ }
@@ -40,6 +40,9 @@ export function buildWranglerConfig(c, o = {}) {
40
40
  main: "worker/src/index.ts",
41
41
  compatibility_date: COMPATIBILITY_DATE,
42
42
  compatibility_flags: ["nodejs_compat"],
43
+ // explicit, so wrangler does not warn: the control plane is on workers.dev; preview URLs are not used
44
+ workers_dev: true,
45
+ preview_urls: false,
43
46
  observability: { enabled: true },
44
47
  containers: [
45
48
  orbContainer,
package/dist/wrangler.js CHANGED
@@ -37,6 +37,8 @@ export const runProcess = (cmd, args, o = {}) => new Promise((resolve) => {
37
37
  resolve({ code: code ?? 1, stdout, stderr });
38
38
  });
39
39
  });
40
+ /** The wrangler version the CLI depends on (cli/package.json). Git mode pins the same version in package.json. */
41
+ export const WRANGLER_PIN = "4.156.0";
40
42
  /** [command, ...prefix args] that runs the pinned wrangler. Prefers the CLI's own dependency over npx. */
41
43
  export function wranglerCommand() {
42
44
  try {
@@ -46,7 +48,7 @@ export function wranglerCommand() {
46
48
  return [process.execPath, [bin]];
47
49
  }
48
50
  catch { }
49
- return [process.platform === "win32" ? "npx.cmd" : "npx", ["--yes", "wrangler@4.156.0"]];
51
+ return [process.platform === "win32" ? "npx.cmd" : "npx", ["--yes", `wrangler@${WRANGLER_PIN}`]];
50
52
  }
51
53
  export function makeWrangler(run = runProcess, base = {}) {
52
54
  const [cmd, prefix] = wranglerCommand();
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "cf-orbs",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Run Amp orbs on Cloudflare Containers: self-hosted orb provider, one command to install.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
- "cf-orbs": "./bin/cf-orbs.js"
8
+ "cf-orbs": "bin/cf-orbs.js"
9
9
  },
10
10
  "files": [
11
11
  "bin",