cursedops 0.10.24 → 0.10.26

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
@@ -12,7 +12,7 @@ bun add cursedops
12
12
  | `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin (0.5.0) |
13
13
  | `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`, 0.5.0) |
14
14
  | `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — the one check no app owns (every address of a deployment serving the same built client), and since 0.5.0 its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
15
- | `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — and the readers it needs (0.5.0); `readWranglerFile(root)` since 0.9.0, lifted from four apps' `readWrangler` (task 2153) |
15
+ | `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — and the readers it needs (0.5.0); `readWranglerFile(root)` since 0.9.0, lifted from four apps' `readWrangler` (task 2153); since 0.10.25 every run, red or green, appends a `kind: "deploy"` line with each leg's wall clock (`install`, `stage`, `walk`, `build`, `schema`, `upload`, `secrets`, `smoke`) to the generation's ledger (task 2246) |
16
16
  | `cursedops/worker-deploy` (CPU) | a `wrangler tail` around the traffic a deploy already sends, each request's BILLED CPU charged to its declared route, red on an undeclared, over-budget or uncapped route — and `runWorkerDeploy`'s `stageWalk` + `cpuTail` that wrap the walk and the smoke in it (0.7.0, task 2134, lifted from collections) |
17
17
  | `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back (0.5.0) |
18
18
  | `cursedops/worker-rollback` | a hostname back on its Mac origin: origin first, route second, the route found rather than typed (0.5.0) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.10.24",
3
+ "version": "0.10.26",
4
4
  "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/relayLink.ts CHANGED
@@ -318,7 +318,7 @@ export async function rotateLinkKey(spec: RotateSpec): Promise<{ ok: true; file:
318
318
  const put =
319
319
  spec.putSecret ??
320
320
  ((name: string, value: string) => {
321
- const r = spawnSync("bunx", ["wrangler@4", "secret", "put", name, ...(spec.env ? ["--env", spec.env] : [])], {
321
+ const r = spawnSync("bunx", ["wrangler", "secret", "put", name, ...(spec.env ? ["--env", spec.env] : [])], {
322
322
  cwd: spec.appDir,
323
323
  input: value,
324
324
  env: { ...process.env, ...spec.credential },
package/src/relayStage.ts CHANGED
@@ -54,6 +54,37 @@ export async function until(ms: number, ok: () => Promise<boolean>, clock: Clock
54
54
  return false;
55
55
  }
56
56
 
57
+ /**
58
+ * True once `isLinked` has said yes on every ask for a whole `windowMs` (default 15 s) — false if that
59
+ * never happens within `maxMs` (default 2 min). A throw counts as no, and restarts the window.
60
+ *
61
+ * 🔴 Why not a sleep and then {@link until}: a digest put is a new Worker version that reaches the
62
+ * edge in its own time, and `until` passes the first instant the link is up. Measured 2026-09-28 on
63
+ * station, beside three other deploys: the walk slept its fixed 10 s, the link came up, then flapped
64
+ * (`1002 Expected 101` — an edge still on the old digest refusing the new key) and the first
65
+ * signed-in read got the offline 503. `oldKeyRefused` already records that 15 s was not always
66
+ * enough. So the walk waits for a link that STAYS up, and how long that took is printed.
67
+ */
68
+ export async function linkedSteadily(spec: {
69
+ isLinked: () => Promise<boolean>;
70
+ windowMs?: number;
71
+ maxMs?: number;
72
+ clock?: Clock;
73
+ }): Promise<{ steady: boolean; seconds: number }> {
74
+ const clock = spec.clock ?? realClock;
75
+ const window = spec.windowMs ?? 15_000;
76
+ const started = clock.now();
77
+ let upSince: number | null = null;
78
+ while (clock.now() - started < (spec.maxMs ?? 120_000)) {
79
+ const up = await spec.isLinked().catch(() => false);
80
+ if (!up) upSince = null;
81
+ else if (upSince === null) upSince = clock.now();
82
+ else if (clock.now() - upSince >= window) return { steady: true, seconds: Math.round((clock.now() - started) / 1000) };
83
+ await clock.sleep(1_000);
84
+ }
85
+ return { steady: false, seconds: Math.round((clock.now() - started) / 1000) };
86
+ }
87
+
57
88
  /** The Access service token, as the pair a Mac end's two variables and a roster socket carry. */
58
89
  export function accessPair(headers: Record<string, string>): { id: string; secret: string } {
59
90
  return { id: headers["CF-Access-Client-Id"] ?? "", secret: headers["CF-Access-Client-Secret"] ?? "" };
@@ -142,10 +173,11 @@ export interface DigestPut {
142
173
  /**
143
174
  * Put `key`'s digest on the STAGE Worker (`--env stage`, always — this module has no production
144
175
  * path). A per-run key for the walk, and again for the rotation leg. `ok: false` carries wrangler's
145
- * stderr. A secret put is a new version: the caller gives it a few seconds to reach the edge.
176
+ * stderr. A secret put is a new version that reaches the edge in its own time: the caller waits for
177
+ * a link that stays up ({@link linkedSteadily}), never a fixed sleep.
146
178
  */
147
179
  export async function putStageLinkDigest(put: DigestPut): Promise<{ ok: boolean; stderr: string }> {
148
- const done = (put.run ?? realRun)(["bunx", "wrangler@4", "secret", "put", put.secretName, "--env", "stage"], {
180
+ const done = (put.run ?? realRun)(["bunx", "wrangler", "secret", "put", put.secretName, "--env", "stage"], {
149
181
  cwd: put.cwd,
150
182
  input: await sha256Hex(put.key),
151
183
  env: put.credential,
@@ -53,8 +53,9 @@
53
53
  * refusal without wrangler, git or a network.
54
54
  */
55
55
  import { spawn, spawnSync } from "node:child_process";
56
- import { existsSync, readFileSync } from "node:fs";
57
- import { join } from "node:path";
56
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
57
+ import { dirname, join } from "node:path";
58
+ import { forgeState } from "cursedops/roots";
58
59
 
59
60
  /** The two deployments every Worker app here has. */
60
61
  export type WorkerEnv = "production" | "stage";
@@ -305,6 +306,62 @@ export interface DeployDeps {
305
306
  read?: (path: string) => string;
306
307
  log?: (line: string) => void;
307
308
  error?: (line: string) => void;
309
+ /** The clock the legs are timed on. Default `Date.now`. */
310
+ now?: () => number;
311
+ /** Whether a path in the checkout exists — the local wrangler. Default `existsSync` against the process's cwd. */
312
+ exists?: (path: string) => boolean;
313
+ /** The unpublished library builds in `node_modules` ({@link previewMarks}). Default: read the process's cwd. */
314
+ previews?: () => PreviewMark[];
315
+ /**
316
+ * Where the run's {@link DeployEventLine} goes, on EVERY exit — refused, red or green.
317
+ * {@link workerDeployDeps} appends it to the generation's ledger; absent, nothing is recorded.
318
+ * A throw here is swallowed: a ledger that can fail a deploy is worse than none.
319
+ */
320
+ record?: (line: DeployEventLine) => void;
321
+ }
322
+
323
+ /**
324
+ * One line per deploy into the generation's ledger (`$FORGE_STATE/state/autopilot/events.jsonl`,
325
+ * the file `autopilot timings` reads), with the wall clock of every LEG.
326
+ *
327
+ * 🔴 Why here: until 0.10.25 the whole ledger held 3 `deploy` events, all backfilled by hand, while
328
+ * `autopilot phases` measured deploys at ~110 agent-minutes a day (2026-09-24 → 28) — the slowest
329
+ * thing a task did once gates stopped re-proving unchanged bytes. Every app's `worker:deploy`,
330
+ * stage and production, passes through {@link runWorkerDeploy}, so this is the one seam that sees
331
+ * all of them. A production deploy runs its stage deploy as a child, which records its OWN line
332
+ * (`env: "stage"`) — so the `stage` leg here and that line's legs together say where it went.
333
+ */
334
+ export interface DeployEventLine {
335
+ at: string;
336
+ kind: "deploy";
337
+ what: "cursedops/worker-deploy";
338
+ repo: string;
339
+ env: WorkerEnv;
340
+ ms: number;
341
+ ok: boolean;
342
+ /** The step it stopped at, or `done` / `cpu-unmeasured` — {@link DeployResult.step}. */
343
+ outcome: string;
344
+ commit: string;
345
+ dirty: boolean;
346
+ /**
347
+ * Milliseconds per leg, in the order they ran: `prepare` (git and the refusals), `install`,
348
+ * `stage` (the stage deploy a production deploy runs first), `walk`, `build`, `schema`,
349
+ * `upload` (`wrangler deploy`), `secrets`, `smoke`. A leg that did not run is absent.
350
+ */
351
+ legs: Record<string, number>;
352
+ }
353
+
354
+ /** Append one line to the ledger under a state root. Never throws; `false` when it did not write. */
355
+ export function recordDeployEvent(state: string | null, line: DeployEventLine): boolean {
356
+ if (!state) return false;
357
+ try {
358
+ const file = join(state, "state", "autopilot", "events.jsonl");
359
+ mkdirSync(dirname(file), { recursive: true });
360
+ appendFileSync(file, `${JSON.stringify(line)}\n`);
361
+ return true;
362
+ } catch {
363
+ return false;
364
+ }
308
365
  }
309
366
 
310
367
  export interface DeployResult {
@@ -344,6 +401,9 @@ export function workerDeployDeps(cwd: string): DeployDeps {
344
401
  },
345
402
  git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
346
403
  read: (path) => readFileSync(join(cwd, path), "utf8"),
404
+ record: (line) => {
405
+ recordDeployEvent(forgeState(cwd), line);
406
+ },
347
407
  };
348
408
  }
349
409
 
@@ -492,19 +552,136 @@ export function judgeSchemaApply(code: number, stdout: string): { applied: boole
492
552
  * Run the sequence in the header's order, stopping at the first red with a sentence. Never
493
553
  * exits — the caller does, with {@link DeployResult.code}.
494
554
  */
555
+ /** Where an app's OWN wrangler is, once `bun install` has run. */
556
+ export const LOCAL_WRANGLER = "node_modules/.bin/wrangler";
557
+
558
+ /**
559
+ * Refuse a deploy whose app has no wrangler of its own — `null` when it has one.
560
+ *
561
+ * 🔴 Why (task 2260): with no local install, `bunx wrangler` resolves into ONE directory shared by
562
+ * every process on the Mac (`$TMPDIR/bunx-<uid>-wrangler@latest`), and bunx refreshes it in place.
563
+ * On 2026-09-28 it was rewritten at 12:17 while four deploys ran, and station's stage walk died on
564
+ * `Cannot find module './lib/websocket-server'` — a half-extracted `ws`. A version spec
565
+ * (`wrangler@4`) is worse: bunx then skips a local install even when there is one. So every app
566
+ * declares `wrangler` in its own `devDependencies`, locked by its own `bun.lock`, and nothing here
567
+ * passes bunx a version (`workerDeploy.test.ts` scans `src/` for one).
568
+ */
569
+ export function localWranglerRefusal(exists: (path: string) => boolean): string | null {
570
+ if (exists(LOCAL_WRANGLER)) return null;
571
+ return `this app has no wrangler of its own (${LOCAL_WRANGLER} is missing), so \`bunx wrangler\` would use the one install every deploy on this Mac shares — and concurrent deploys have corrupted it mid-run. Run \`bun add -d wrangler@^4\` in the app and commit package.json and bun.lock. Nothing was deployed.`;
572
+ }
573
+
574
+ /** The file `tools/lib-preview.ts` writes INSIDE a package it overlaid with an unpublished build. */
575
+ export const PREVIEW_MARK = ".lib-preview.json";
576
+
577
+ /** One unpublished library build sitting in `node_modules` — what its {@link PREVIEW_MARK} says. */
578
+ export interface PreviewMark {
579
+ lib: string;
580
+ /** The version in the library's own tree — never the one `bun.lock` names. */
581
+ version: string;
582
+ /** The library tree's HEAD when it was packed, `-dirty` when it had uncommitted changes. */
583
+ commit: string;
584
+ }
585
+
586
+ /**
587
+ * Every package under `nodeModules` (top level and `@scope/name`) carrying a {@link PREVIEW_MARK}.
588
+ *
589
+ * The mark lives inside the package's own directory, never beside it, so it cannot outlive the bytes
590
+ * it describes: an install that replaces the package (a lockfile bump, `--force`) removes it too.
591
+ * `bun install --frozen-lockfile` leaves an overlay whose `package.json` version matches the lock, so
592
+ * the preview writes the locked version there and its real one here (measured 2026-09-28).
593
+ */
594
+ export function previewMarks(nodeModules = "node_modules"): PreviewMark[] {
595
+ const marks: PreviewMark[] = [];
596
+ const read = (dir: string): void => {
597
+ const file = join(dir, PREVIEW_MARK);
598
+ if (!existsSync(file)) return;
599
+ try {
600
+ const mark = JSON.parse(readFileSync(file, "utf8")) as Partial<PreviewMark>;
601
+ marks.push({ lib: mark.lib ?? dir, version: mark.version ?? "?", commit: mark.commit ?? "?" });
602
+ } catch {
603
+ marks.push({ lib: dir, version: "?", commit: "unreadable mark" });
604
+ }
605
+ };
606
+ let names: string[] = [];
607
+ try {
608
+ names = readdirSync(nodeModules);
609
+ } catch {
610
+ return marks;
611
+ }
612
+ for (const name of names) {
613
+ if (name.startsWith("@")) {
614
+ let scoped: string[] = [];
615
+ try {
616
+ scoped = readdirSync(join(nodeModules, name));
617
+ } catch {}
618
+ for (const inner of scoped) read(join(nodeModules, name, inner));
619
+ } else if (!name.startsWith(".")) read(join(nodeModules, name));
620
+ }
621
+ return marks;
622
+ }
623
+
624
+ /** `<lib>@<version> (<commit>)`, joined — how a deploy names what it would ship unpublished. */
625
+ export function describePreviews(marks: readonly PreviewMark[]): string {
626
+ return marks.map((m) => `${m.lib}@${m.version} (${m.commit})`).join(", ");
627
+ }
628
+
629
+ /**
630
+ * Production never ships an unpublished library (task 2252) — `null` when nothing is previewed. A
631
+ * stage deploy ships it, says so, and stamps itself dirty: `/healthz` then says the bytes are not
632
+ * the commit's, which is true.
633
+ */
634
+ export function previewRefusal(env: WorkerEnv, marks: readonly PreviewMark[]): string | null {
635
+ if (env !== "production" || marks.length === 0) return null;
636
+ return `node_modules holds an UNPUBLISHED build of ${describePreviews(marks)} — production ships only published libraries. Publish it (\`tools/publish-libs.sh\`), bump this app to it, or put the locked version back with \`bun $FORGE/tools/lib-preview.ts --restore\`. Nothing was deployed.`;
637
+ }
638
+
495
639
  /** What {@link runWorkerDeploy} runs first: `node_modules` exactly as `bun.lock` says. */
496
640
  export const INSTALL: readonly string[] = ["bun", "install", "--frozen-lockfile"];
497
641
 
498
642
  export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): DeployResult {
499
643
  const log = deps.log ?? ((line: string) => console.log(line));
500
644
  const error = deps.error ?? ((line: string) => console.error(line));
645
+ const now = deps.now ?? Date.now;
646
+ const started = now();
647
+ const legs: Record<string, number> = {};
648
+ let leg = "prepare";
649
+ let legFrom = started;
650
+ /** Close the leg in flight and open `next` — see {@link DeployEventLine.legs}. */
651
+ const enter = (next: string): void => {
652
+ const at = now();
653
+ legs[leg] = (legs[leg] ?? 0) + (at - legFrom);
654
+ leg = next;
655
+ legFrom = at;
656
+ };
501
657
  const tag = `[${spec.app}]`;
502
658
  const commit = deps.git(["rev-parse", "HEAD"]);
503
659
  const porcelain = deps.git(["status", "--porcelain"]);
504
660
  const dirty = porcelain.length > 0;
661
+ const finish = (result: DeployResult): DeployResult => {
662
+ enter("");
663
+ try {
664
+ deps.record?.({
665
+ at: new Date(started).toISOString(),
666
+ kind: "deploy",
667
+ what: "cursedops/worker-deploy",
668
+ repo: spec.app,
669
+ env: spec.env,
670
+ ms: now() - started,
671
+ ok: result.code !== 1,
672
+ outcome: result.step,
673
+ commit: commit.slice(0, 8),
674
+ dirty,
675
+ legs,
676
+ });
677
+ } catch {
678
+ // never the deploy's verdict — see DeployDeps.record
679
+ }
680
+ return result;
681
+ };
505
682
  const stop = (step: string, detail: string): DeployResult => {
506
683
  error(`\n🔴 ${tag} ${detail}`);
507
- return { code: 1, step, detail, commit, dirty };
684
+ return finish({ code: 1, step, detail, commit, dirty });
508
685
  };
509
686
  const step = (message: string): void => log(`\n▸ ${message}`);
510
687
  /** What passed under a CPU tail that never attached — decision 6 of the CPU check. */
@@ -538,17 +715,27 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
538
715
  * no-op when they already agree, and refuses, rather than rewriting the lock, when they cannot.
539
716
  */
540
717
  step("make node_modules match bun.lock");
718
+ enter("install");
541
719
  if (deps.run(INSTALL, {}) !== 0) {
542
720
  return stop("install", `\`${INSTALL.join(" ")}\` failed — node_modules cannot be made to match bun.lock, so nothing was built or deployed.`);
543
721
  }
722
+ const noWrangler = localWranglerRefusal(deps.exists ?? existsSync);
723
+ if (noWrangler) return stop("wrangler", noWrangler);
724
+ const previews = (deps.previews ?? previewMarks)();
725
+ const previewed = previewRefusal(spec.env, previews);
726
+ if (previewed) return stop("preview", previewed);
727
+ if (previews.length > 0) log(`\n🟡 ${tag} this stage deploy ships an UNPUBLISHED ${describePreviews(previews)} — stamped dirty; production will refuse it until it is published`);
728
+ const shipsDirty = dirty || previews.length > 0;
544
729
  if (spec.env === "production") {
545
730
  for (const command of spec.stageFirst ?? []) {
546
731
  step(`the stage first — production waits on it: ${command.join(" ")}`);
732
+ enter("stage");
547
733
  if (deps.run(command, {}) !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
548
734
  }
549
735
  for (const walk of spec.stageWalk ?? []) {
550
736
  const command = spec.cpuTail ? underCpuTail(spec.cpuTail, "stage", walk) : [...walk];
551
737
  step(`walk the stage signed in${spec.cpuTail ? ", under a CPU tail" : ""}: ${command.join(" ")}`);
738
+ enter("walk");
552
739
  const walked = deps.run(command, {});
553
740
  if (spec.cpuTail && walked === TAIL_UNATTACHED_EXIT) unmeasured.push("the stage walk");
554
741
  else if (walked !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
@@ -556,8 +743,10 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
556
743
  }
557
744
  if (spec.build) {
558
745
  step("build");
746
+ enter("build");
559
747
  if (deps.run(spec.build, {}) !== 0) return stop("build", "the build failed — nothing was deployed.");
560
748
  }
749
+ enter("prepare");
561
750
  for (const refusal of spec.beforeShip ?? []) {
562
751
  const why = refusal();
563
752
  if (why) return stop("refusal", `${why} — nothing was deployed.`);
@@ -566,6 +755,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
566
755
  if (spec.databaseName) {
567
756
  const schema = spec.schemaFile ?? "db/schema.sql";
568
757
  step(`apply ${schema} to D1 \`${spec.databaseName}\``);
758
+ enter("schema");
569
759
  let commands: string[];
570
760
  try {
571
761
  commands = schemaCommands((deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema));
@@ -588,19 +778,22 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
588
778
  log(` ${verdict.line}`);
589
779
  }
590
780
  }
591
- step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${dirty ? " (dirty — stage only)" : ""}`);
781
+ step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${shipsDirty ? " (dirty — stage only)" : ""}`);
592
782
  const stamp = spec.stampVars
593
- ? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${dirty ? "true" : "false"}`]
783
+ ? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
594
784
  : [];
785
+ enter("upload");
595
786
  if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
596
787
  if (spec.secrets) {
597
788
  step("attach the secrets — exactly this deployment's set, over a pipe");
789
+ enter("secrets");
598
790
  if (deps.run(spec.secrets, spec.credential) !== 0) {
599
791
  return stop("secrets", "the Worker is deployed and its secrets are NOT attached — it serves nothing until they are.");
600
792
  }
601
793
  }
602
794
  if (spec.smoke) {
603
795
  step(`smoke every address this deployment answers on${spec.cpuTail ? ", under a CPU tail" : ""}`);
796
+ enter("smoke");
604
797
  const smoke = spec.cpuTail ? underCpuTail(spec.cpuTail, spec.env, spec.smoke) : spec.smoke;
605
798
  const smoked = deps.run(smoke, {});
606
799
  if (spec.cpuTail && smoked === TAIL_UNATTACHED_EXIT) unmeasured.push("the smoke");
@@ -617,10 +810,10 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
617
810
  `${detail} Worker CPU UNMEASURED — the tail never attached around ${unmeasured.join(" and ")}, which PASSED. ` +
618
811
  "Nothing is wrong with the deployment and nothing needs rolling back; rerun the smoke under the app's CPU wrapper to measure it.";
619
812
  error(`\n🟡 ${tag} ${why}`);
620
- return { code: TAIL_UNATTACHED_EXIT, step: "cpu-unmeasured", detail: why, commit, dirty };
813
+ return finish({ code: TAIL_UNATTACHED_EXIT, step: "cpu-unmeasured", detail: why, commit, dirty });
621
814
  }
622
815
  log(`\n✅ ${detail}`);
623
- return { code: 0, step: "done", detail, commit, dirty };
816
+ return finish({ code: 0, step: "done", detail, commit, dirty });
624
817
  }
625
818
 
626
819
  /**
@@ -1046,13 +1239,13 @@ export interface WorkerCpuDeps {
1046
1239
  error: (line: string) => void;
1047
1240
  }
1048
1241
 
1049
- /** The real {@link WorkerCpuDeps}: `bunx wrangler@4 tail`, pinned to the major whose JSON shape the parser reads. */
1242
+ /** The real {@link WorkerCpuDeps}: `bunx wrangler tail` — the APP's own wrangler, whose major (4) its `devDependencies` pin: {@link localWranglerRefusal}. */
1050
1243
  export function workerCpuDeps(): WorkerCpuDeps {
1051
1244
  return {
1052
1245
  startTail: (workerName, cwd, env) => {
1053
1246
  let out = "";
1054
1247
  let err = "";
1055
- const child = spawn("bunx", ["wrangler@4", "tail", workerName, "--format", "json"], {
1248
+ const child = spawn("bunx", ["wrangler", "tail", workerName, "--format", "json"], {
1056
1249
  cwd,
1057
1250
  env: { ...process.env, ...env },
1058
1251
  stdio: ["ignore", "pipe", "pipe"],