cursedops 0.10.23 → 0.10.25

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.23",
3
+ "version": "0.10.25",
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": {
@@ -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, 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,58 @@ 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
+ /**
312
+ * Where the run's {@link DeployEventLine} goes, on EVERY exit — refused, red or green.
313
+ * {@link workerDeployDeps} appends it to the generation's ledger; absent, nothing is recorded.
314
+ * A throw here is swallowed: a ledger that can fail a deploy is worse than none.
315
+ */
316
+ record?: (line: DeployEventLine) => void;
317
+ }
318
+
319
+ /**
320
+ * One line per deploy into the generation's ledger (`$FORGE_STATE/state/autopilot/events.jsonl`,
321
+ * the file `autopilot timings` reads), with the wall clock of every LEG.
322
+ *
323
+ * 🔴 Why here: until 0.10.25 the whole ledger held 3 `deploy` events, all backfilled by hand, while
324
+ * `autopilot phases` measured deploys at ~110 agent-minutes a day (2026-09-24 → 28) — the slowest
325
+ * thing a task did once gates stopped re-proving unchanged bytes. Every app's `worker:deploy`,
326
+ * stage and production, passes through {@link runWorkerDeploy}, so this is the one seam that sees
327
+ * all of them. A production deploy runs its stage deploy as a child, which records its OWN line
328
+ * (`env: "stage"`) — so the `stage` leg here and that line's legs together say where it went.
329
+ */
330
+ export interface DeployEventLine {
331
+ at: string;
332
+ kind: "deploy";
333
+ what: "cursedops/worker-deploy";
334
+ repo: string;
335
+ env: WorkerEnv;
336
+ ms: number;
337
+ ok: boolean;
338
+ /** The step it stopped at, or `done` / `cpu-unmeasured` — {@link DeployResult.step}. */
339
+ outcome: string;
340
+ commit: string;
341
+ dirty: boolean;
342
+ /**
343
+ * Milliseconds per leg, in the order they ran: `prepare` (git and the refusals), `install`,
344
+ * `stage` (the stage deploy a production deploy runs first), `walk`, `build`, `schema`,
345
+ * `upload` (`wrangler deploy`), `secrets`, `smoke`. A leg that did not run is absent.
346
+ */
347
+ legs: Record<string, number>;
348
+ }
349
+
350
+ /** Append one line to the ledger under a state root. Never throws; `false` when it did not write. */
351
+ export function recordDeployEvent(state: string | null, line: DeployEventLine): boolean {
352
+ if (!state) return false;
353
+ try {
354
+ const file = join(state, "state", "autopilot", "events.jsonl");
355
+ mkdirSync(dirname(file), { recursive: true });
356
+ appendFileSync(file, `${JSON.stringify(line)}\n`);
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
308
361
  }
309
362
 
310
363
  export interface DeployResult {
@@ -344,6 +397,9 @@ export function workerDeployDeps(cwd: string): DeployDeps {
344
397
  },
345
398
  git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
346
399
  read: (path) => readFileSync(join(cwd, path), "utf8"),
400
+ record: (line) => {
401
+ recordDeployEvent(forgeState(cwd), line);
402
+ },
347
403
  };
348
404
  }
349
405
 
@@ -498,13 +554,46 @@ export const INSTALL: readonly string[] = ["bun", "install", "--frozen-lockfile"
498
554
  export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): DeployResult {
499
555
  const log = deps.log ?? ((line: string) => console.log(line));
500
556
  const error = deps.error ?? ((line: string) => console.error(line));
557
+ const now = deps.now ?? Date.now;
558
+ const started = now();
559
+ const legs: Record<string, number> = {};
560
+ let leg = "prepare";
561
+ let legFrom = started;
562
+ /** Close the leg in flight and open `next` — see {@link DeployEventLine.legs}. */
563
+ const enter = (next: string): void => {
564
+ const at = now();
565
+ legs[leg] = (legs[leg] ?? 0) + (at - legFrom);
566
+ leg = next;
567
+ legFrom = at;
568
+ };
501
569
  const tag = `[${spec.app}]`;
502
570
  const commit = deps.git(["rev-parse", "HEAD"]);
503
571
  const porcelain = deps.git(["status", "--porcelain"]);
504
572
  const dirty = porcelain.length > 0;
573
+ const finish = (result: DeployResult): DeployResult => {
574
+ enter("");
575
+ try {
576
+ deps.record?.({
577
+ at: new Date(started).toISOString(),
578
+ kind: "deploy",
579
+ what: "cursedops/worker-deploy",
580
+ repo: spec.app,
581
+ env: spec.env,
582
+ ms: now() - started,
583
+ ok: result.code !== 1,
584
+ outcome: result.step,
585
+ commit: commit.slice(0, 8),
586
+ dirty,
587
+ legs,
588
+ });
589
+ } catch {
590
+ // never the deploy's verdict — see DeployDeps.record
591
+ }
592
+ return result;
593
+ };
505
594
  const stop = (step: string, detail: string): DeployResult => {
506
595
  error(`\n🔴 ${tag} ${detail}`);
507
- return { code: 1, step, detail, commit, dirty };
596
+ return finish({ code: 1, step, detail, commit, dirty });
508
597
  };
509
598
  const step = (message: string): void => log(`\n▸ ${message}`);
510
599
  /** What passed under a CPU tail that never attached — decision 6 of the CPU check. */
@@ -538,17 +627,20 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
538
627
  * no-op when they already agree, and refuses, rather than rewriting the lock, when they cannot.
539
628
  */
540
629
  step("make node_modules match bun.lock");
630
+ enter("install");
541
631
  if (deps.run(INSTALL, {}) !== 0) {
542
632
  return stop("install", `\`${INSTALL.join(" ")}\` failed — node_modules cannot be made to match bun.lock, so nothing was built or deployed.`);
543
633
  }
544
634
  if (spec.env === "production") {
545
635
  for (const command of spec.stageFirst ?? []) {
546
636
  step(`the stage first — production waits on it: ${command.join(" ")}`);
637
+ enter("stage");
547
638
  if (deps.run(command, {}) !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
548
639
  }
549
640
  for (const walk of spec.stageWalk ?? []) {
550
641
  const command = spec.cpuTail ? underCpuTail(spec.cpuTail, "stage", walk) : [...walk];
551
642
  step(`walk the stage signed in${spec.cpuTail ? ", under a CPU tail" : ""}: ${command.join(" ")}`);
643
+ enter("walk");
552
644
  const walked = deps.run(command, {});
553
645
  if (spec.cpuTail && walked === TAIL_UNATTACHED_EXIT) unmeasured.push("the stage walk");
554
646
  else if (walked !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
@@ -556,8 +648,10 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
556
648
  }
557
649
  if (spec.build) {
558
650
  step("build");
651
+ enter("build");
559
652
  if (deps.run(spec.build, {}) !== 0) return stop("build", "the build failed — nothing was deployed.");
560
653
  }
654
+ enter("prepare");
561
655
  for (const refusal of spec.beforeShip ?? []) {
562
656
  const why = refusal();
563
657
  if (why) return stop("refusal", `${why} — nothing was deployed.`);
@@ -566,6 +660,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
566
660
  if (spec.databaseName) {
567
661
  const schema = spec.schemaFile ?? "db/schema.sql";
568
662
  step(`apply ${schema} to D1 \`${spec.databaseName}\``);
663
+ enter("schema");
569
664
  let commands: string[];
570
665
  try {
571
666
  commands = schemaCommands((deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema));
@@ -592,15 +687,18 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
592
687
  const stamp = spec.stampVars
593
688
  ? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${dirty ? "true" : "false"}`]
594
689
  : [];
690
+ enter("upload");
595
691
  if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
596
692
  if (spec.secrets) {
597
693
  step("attach the secrets — exactly this deployment's set, over a pipe");
694
+ enter("secrets");
598
695
  if (deps.run(spec.secrets, spec.credential) !== 0) {
599
696
  return stop("secrets", "the Worker is deployed and its secrets are NOT attached — it serves nothing until they are.");
600
697
  }
601
698
  }
602
699
  if (spec.smoke) {
603
700
  step(`smoke every address this deployment answers on${spec.cpuTail ? ", under a CPU tail" : ""}`);
701
+ enter("smoke");
604
702
  const smoke = spec.cpuTail ? underCpuTail(spec.cpuTail, spec.env, spec.smoke) : spec.smoke;
605
703
  const smoked = deps.run(smoke, {});
606
704
  if (spec.cpuTail && smoked === TAIL_UNATTACHED_EXIT) unmeasured.push("the smoke");
@@ -617,10 +715,10 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
617
715
  `${detail} Worker CPU UNMEASURED — the tail never attached around ${unmeasured.join(" and ")}, which PASSED. ` +
618
716
  "Nothing is wrong with the deployment and nothing needs rolling back; rerun the smoke under the app's CPU wrapper to measure it.";
619
717
  error(`\n🟡 ${tag} ${why}`);
620
- return { code: TAIL_UNATTACHED_EXIT, step: "cpu-unmeasured", detail: why, commit, dirty };
718
+ return finish({ code: TAIL_UNATTACHED_EXIT, step: "cpu-unmeasured", detail: why, commit, dirty });
621
719
  }
622
720
  log(`\n✅ ${detail}`);
623
- return { code: 0, step: "done", detail, commit, dirty };
721
+ return finish({ code: 0, step: "done", detail, commit, dirty });
624
722
  }
625
723
 
626
724
  /**
@@ -89,19 +89,40 @@ export interface SecretSync {
89
89
  after: string[];
90
90
  }
91
91
 
92
+ /** Tries per put. A put is idempotent, so a retry can only turn a network blip into a success. */
93
+ export const SECRET_PUT_ATTEMPTS = 3;
94
+
92
95
  /**
93
96
  * Apply `plan`: put every value, delete every extra, then READ THE ACCOUNT BACK and fail unless it
94
97
  * holds exactly the set. Refuses without touching anything when the plan has `absent` keys.
98
+ *
99
+ * 🔴 **A failed put is tried again, up to {@link SECRET_PUT_ATTEMPTS} times.** On 2026-09-28 one
100
+ * `wrangler secret put` of three hit `fetch failed` and the whole deploy stopped with
101
+ * collections-stage holding a Worker and no secrets — serving nothing — for a blip the next
102
+ * attempt would have cleared. The read-back is still the verdict.
95
103
  */
96
- export async function syncSecrets(plan: SecretPlan, ops: SecretOps): Promise<SecretSync> {
104
+ export async function syncSecrets(
105
+ plan: SecretPlan,
106
+ ops: SecretOps,
107
+ options: { attempts?: number; pause?: (ms: number) => Promise<void> } = {},
108
+ ): Promise<SecretSync> {
109
+ const attempts = Math.max(1, options.attempts ?? SECRET_PUT_ATTEMPTS);
110
+ const pause = options.pause ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
97
111
  const lines: string[] = [];
98
112
  if (plan.absent.length > 0) {
99
113
  return { ok: false, lines: [`the secrets file has no ${plan.absent.join(" and no ")} — nothing was changed`], after: await ops.held() };
100
114
  }
101
115
  let bad = 0;
102
116
  for (const { name, from, value } of plan.put) {
103
- const ok = await ops.put(name, value);
104
- lines.push(`put ${name}${from === name ? "" : ` (from ${from})`} (${value.length} bytes) — ${ok ? "ok" : "FAILED"}`);
117
+ let ok = false;
118
+ let tries = 0;
119
+ while (!ok && tries < attempts) {
120
+ if (tries > 0) await pause(2_000 * tries);
121
+ ok = await ops.put(name, value);
122
+ tries++;
123
+ }
124
+ const retried = tries > 1 ? ` after ${tries} attempts` : "";
125
+ lines.push(`put ${name}${from === name ? "" : ` (from ${from})`} (${value.length} bytes) — ${ok ? "ok" : "FAILED"}${retried}`);
105
126
  if (!ok) bad++;
106
127
  }
107
128
  for (const name of plan.remove) {