cursedops 0.16.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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": {
@@ -54,7 +54,8 @@
54
54
  * refusal without wrangler, git or a network.
55
55
  */
56
56
  import { spawn, spawnSync } from "node:child_process";
57
- import { existsSync, readdirSync, readFileSync } from "node:fs";
57
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
58
+ import { tmpdir } from "node:os";
58
59
  import { join } from "node:path";
59
60
  import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables } from "cursedops/d1-columns";
60
61
  import { wranglerRows } from "cursedops/d1-import";
@@ -345,6 +346,19 @@ export interface DeployDeps {
345
346
  * generation's lease; absent (or `null` with no state root), nothing is leased.
346
347
  */
347
348
  lease?: (app: string, what: string) => StageLeaseResult | null;
349
+ /**
350
+ * A fresh file for wrangler's ND-JSON output (`WRANGLER_OUTPUT_FILE_PATH`), which is where a failed
351
+ * `wrangler deploy` says WHY in a form a program can read ({@link transientDeployFailure}).
352
+ * {@link workerDeployDeps} makes one under the OS temp dir; absent, a failed deploy is not retried.
353
+ */
354
+ wranglerOutput?: () => WranglerOutputFile;
355
+ }
356
+
357
+ /** One `wrangler` run's output file: the env that points wrangler at it, its text, and its removal. */
358
+ export interface WranglerOutputFile {
359
+ env: Record<string, string>;
360
+ read: () => string;
361
+ dispose: () => void;
348
362
  }
349
363
 
350
364
  /**
@@ -420,6 +434,15 @@ export function workerDeployDeps(cwd: string): DeployDeps {
420
434
  },
421
435
  git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
422
436
  read: (path) => readFileSync(join(cwd, path), "utf8"),
437
+ wranglerOutput: () => {
438
+ const dir = mkdtempSync(join(tmpdir(), "cursedops-wrangler-"));
439
+ const path = join(dir, "output.ndjson");
440
+ return {
441
+ env: { WRANGLER_OUTPUT_FILE_PATH: path },
442
+ read: () => (existsSync(path) ? readFileSync(path, "utf8") : ""),
443
+ dispose: () => rmSync(dir, { recursive: true, force: true }),
444
+ };
445
+ },
423
446
  record: (line) => {
424
447
  recordDeployEvent(forgeState(cwd), line);
425
448
  },
@@ -572,6 +595,38 @@ export function wranglerError(stdout: string): { reason: string; api: boolean }
572
595
  return { reason, api: name === "APIError" || (kind === "error" && /cloudflare api/i.test(reason)) };
573
596
  }
574
597
 
598
+ /**
599
+ * Why a failed `wrangler deploy` is worth ONE more try — or `null` when it would fail the same way.
600
+ *
601
+ * Measured 2026-10-11 (forge task 3053): roms' production deploy uploaded the Worker and its routes,
602
+ * then Cloudflare answered the cron-schedules PUT with `Internal authentication error: internal
603
+ * server error [code: 10500]`. wrangler exited 1, the ship's roms lane went red, and the ship batch
604
+ * re-ran a 50-minute `ship run` to redo a step that a second request would have done. A deploy is
605
+ * idempotent — the same script, the same triggers — so a Cloudflare-side failure is retried once.
606
+ * A bundling error, a refused binding or a script that throws at startup (10021) is not: wrangler's
607
+ * own `command-failed` line ({@link WranglerOutputFile}) must name the server's fault or a retry-after.
608
+ */
609
+ export function transientDeployFailure(ndjson: string): string | null {
610
+ type Failed = { code?: unknown; message?: unknown; retry_after_ms?: unknown };
611
+ let failed: Failed | null = null;
612
+ for (const line of ndjson.split("\n")) {
613
+ if (!line.trim()) continue;
614
+ try {
615
+ const entry = JSON.parse(line) as (Failed & { type?: unknown }) | null;
616
+ if (entry?.type === "command-failed") failed = entry;
617
+ } catch {
618
+ // a torn line from a killed run — the ones around it still count
619
+ }
620
+ }
621
+ if (!failed) return null;
622
+ const message = typeof failed.message === "string" ? failed.message : "";
623
+ const first = message.split("\n").find((l) => l.trim()) ?? "";
624
+ const reason = (detail: string) => `${first.trim() || "wrangler deploy failed"} — ${detail}`;
625
+ if (typeof failed.retry_after_ms === "number") return reason(`Cloudflare asked for a retry after ${failed.retry_after_ms} ms`);
626
+ const server = message.match(/internal server error|\[code: (?:10500|10013|7403)\]|\b50[234]\b|ETIMEDOUT|ECONNRESET|socket hang up|fetch failed/i);
627
+ return server ? reason(`"${server[0]}" is Cloudflare's side`) : null;
628
+ }
629
+
575
630
  /**
576
631
  * Did one schema chunk APPLY? Its exit code alone does not say.
577
632
  *
@@ -895,7 +950,22 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
895
950
  ? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
896
951
  : [];
897
952
  enter("upload");
898
- if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
953
+ const deployArgv = ["bunx", "wrangler", "deploy", ...envArgs, ...stamp];
954
+ const deployOnce = (): { code: number; transient: string | null } => {
955
+ const output = deps.wranglerOutput?.();
956
+ try {
957
+ const code = deps.run(deployArgv, { ...spec.credential, ...output?.env });
958
+ return { code, transient: code !== 0 && output ? transientDeployFailure(output.read()) : null };
959
+ } finally {
960
+ output?.dispose();
961
+ }
962
+ };
963
+ let deployed = deployOnce();
964
+ if (deployed.code !== 0 && deployed.transient) {
965
+ error(` 🟡 ${deployed.transient} — retrying the deploy once`);
966
+ deployed = deployOnce();
967
+ }
968
+ if (deployed.code !== 0) return stop("deploy", "wrangler deploy failed.");
899
969
  if (spec.secrets) {
900
970
  step("attach the secrets — exactly this deployment's set, over a pipe");
901
971
  enter("secrets");