cursedops 0.8.1 → 0.9.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 +3 -2
- package/package.json +9 -2
- package/src/backups.ts +9 -0
- package/src/d1Import.ts +41 -0
- package/src/d1Schema.ts +80 -0
- package/src/workerDeploy.ts +11 -0
package/README.md
CHANGED
|
@@ -12,12 +12,13 @@ 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) |
|
|
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) |
|
|
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) |
|
|
19
19
|
| `cursedops/edge-fetch` | a request to a deployed Worker as a script must make it — curl pinned past the Mac's negative DNS cache (0.5.0) |
|
|
20
|
-
| `cursedops/d1-import` | a cutover's data proof — SQL literals and the row-for-row comparison, never a count (0.5.0) |
|
|
20
|
+
| `cursedops/d1-import` | a cutover's data proof — SQL literals and the row-for-row comparison, never a count (0.5.0); and `d1SchemaText`, the generated `db/schema.sql` that refuses a statement spanning lines, because D1's exec splits on newlines (0.9.0, lifted from five apps, task 2153) — and its `forge-d1-schema` bin (0.9.1), so no app carries its own schema script at all |
|
|
21
|
+
| `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots — six apps each exported `KEEP = 14` (0.9.0, task 2153) |
|
|
21
22
|
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
22
23
|
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser and the default 404 body. No `node:` import, so it mounts inside a Worker |
|
|
23
24
|
| `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty — read once at load, and a `null` retried in the background rather than cached for the life of the process. `node:child_process`, so never in a Worker |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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 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": {
|
|
@@ -110,6 +110,12 @@
|
|
|
110
110
|
"source": "./src/stagedClient.ts",
|
|
111
111
|
"import": "./src/stagedClient.ts"
|
|
112
112
|
},
|
|
113
|
+
"./backups": {
|
|
114
|
+
"types": "./src/backups.ts",
|
|
115
|
+
"bun": "./src/backups.ts",
|
|
116
|
+
"source": "./src/backups.ts",
|
|
117
|
+
"import": "./src/backups.ts"
|
|
118
|
+
},
|
|
113
119
|
"./deploy-tree": {
|
|
114
120
|
"types": "./src/deployTree.ts",
|
|
115
121
|
"bun": "./src/deployTree.ts",
|
|
@@ -120,7 +126,8 @@
|
|
|
120
126
|
"bin": {
|
|
121
127
|
"public-surface": "./src/publicSurface.ts",
|
|
122
128
|
"forge-paths": "./src/paths.ts",
|
|
123
|
-
"forge-client": "./src/stagedClient.ts"
|
|
129
|
+
"forge-client": "./src/stagedClient.ts",
|
|
130
|
+
"forge-d1-schema": "./src/d1Schema.ts"
|
|
124
131
|
},
|
|
125
132
|
"files": [
|
|
126
133
|
"src",
|
package/src/backups.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet's backup retention: how many dated snapshots each app's backup script keeps.
|
|
3
|
+
*
|
|
4
|
+
* Six apps (auth, collections, family, music, roms, station) each exported `KEEP = 14` until
|
|
5
|
+
* task 2153 (2026-09-24). A retention is a POLICY, and a policy held as six constants is six
|
|
6
|
+
* places a change has to reach — the one that is missed keeps a fortnight while the rest keep
|
|
7
|
+
* whatever was decided. So it is one number here, and each app's prune takes it.
|
|
8
|
+
*/
|
|
9
|
+
export const BACKUP_KEEP = 14;
|
package/src/d1Import.ts
CHANGED
|
@@ -122,3 +122,44 @@ export function wranglerRows(stdout: string): Array<Record<string, unknown>> {
|
|
|
122
122
|
const parsed = JSON.parse(stdout.slice(start)) as Array<{ results?: Array<Record<string, unknown>> }>;
|
|
123
123
|
return parsed.flatMap((result) => result.results ?? []);
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `db/schema.sql`'s text: the header every cut-over app wrote by hand, then one statement per line.
|
|
128
|
+
*
|
|
129
|
+
* 🔴 **Every statement is ONE LINE, and that is not formatting.** D1's `exec` splits its input on
|
|
130
|
+
* newlines and treats each line as a statement, so a pretty-printed `CREATE TABLE` fails there with
|
|
131
|
+
* `D1_EXEC_ERROR: … incomplete input` — and is green on `bun:sqlite` for ever, because that parses
|
|
132
|
+
* the whole string. So a statement carrying a newline THROWS here rather than being written into a
|
|
133
|
+
* file that only fails once a person applies it to production. Five apps (auth, collections,
|
|
134
|
+
* family, music, vault) carried this script as a copy until task 2153.
|
|
135
|
+
*/
|
|
136
|
+
export function d1SchemaText(options: {
|
|
137
|
+
/** The D1 database's name, as `wrangler d1 execute` takes it. */
|
|
138
|
+
database: string;
|
|
139
|
+
/** What the statements were generated from, e.g. `src/server/localDb.ts's migrate()`. */
|
|
140
|
+
source: string;
|
|
141
|
+
statements: readonly string[];
|
|
142
|
+
/** App-specific lines for the header, each written as a `-- ` comment. */
|
|
143
|
+
notes?: readonly string[];
|
|
144
|
+
}): string {
|
|
145
|
+
const multiline = options.statements.filter((statement) => /[\r\n]/.test(statement));
|
|
146
|
+
if (multiline.length > 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${multiline.length} statement(s) span more than one line, and D1's exec splits on newlines: ${multiline[0]?.slice(0, 120)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const header = [
|
|
152
|
+
`-- GENERATED by \`bun run schema\` from ${options.source}. Do not edit.`,
|
|
153
|
+
"--",
|
|
154
|
+
"-- 🔴 Applied to D1 ONCE, by hand, because a Worker has no boot:",
|
|
155
|
+
`-- bunx wrangler d1 execute ${options.database} --remote --file db/schema.sql`,
|
|
156
|
+
"--",
|
|
157
|
+
"-- Every statement is on ONE LINE: D1's `exec` splits on newlines and treats each line as",
|
|
158
|
+
"-- a statement, so a formatted CREATE TABLE fails with `incomplete input` there and is",
|
|
159
|
+
"-- green on bun:sqlite for ever. src/server/schemaMatches.test.ts fails if this file and",
|
|
160
|
+
"-- migrate() ever disagree.",
|
|
161
|
+
...(options.notes ?? []).map((line) => `-- ${line}`),
|
|
162
|
+
"",
|
|
163
|
+
];
|
|
164
|
+
return `${header.join("\n")}${options.statements.join("\n")}\n`;
|
|
165
|
+
}
|
package/src/d1Schema.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `forge-d1-schema --database <name> --from <module> [--note "<line>"]…` — write `db/schema.sql`,
|
|
4
|
+
* the shape a Worker app's D1 is given, from the module's `schemaStatements()`.
|
|
5
|
+
*
|
|
6
|
+
* ```jsonc
|
|
7
|
+
* // an app's package.json — the script line IS the configuration
|
|
8
|
+
* "schema": "forge-d1-schema --database music --from src/server/localDb.ts"
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* ## 🔴 Why the file is GENERATED and never hand-written
|
|
12
|
+
*
|
|
13
|
+
* A Worker has no boot. An app's `migrate()` issues dozens of statements and inspects
|
|
14
|
+
* `PRAGMA table_info` as it goes; running that on every invocation would spend a D1 invocation's
|
|
15
|
+
* query budget before the request started, and D1 has no `PRAGMA table_info` to inspect with. So
|
|
16
|
+
* D1's schema is applied ONCE, by hand — `bunx wrangler d1 execute <name> --remote --file
|
|
17
|
+
* db/schema.sql` — and there are two descriptions of the app's tables. Each app's own
|
|
18
|
+
* schema-matches test fails its gate when they disagree, so the file is a build product with a
|
|
19
|
+
* checker rather than a document with a convention. {@link d1SchemaText} writes it, and refuses a
|
|
20
|
+
* statement that spans lines, because D1's `exec` splits on newlines.
|
|
21
|
+
*
|
|
22
|
+
* ## Why this is a bin and not a function each app calls
|
|
23
|
+
*
|
|
24
|
+
* Five apps (auth, collections, family, music, vault) each carried its own schema script until task
|
|
25
|
+
* 2153 (2026-09-24). A thinner wrapper around {@link d1SchemaText} was still five files that agree
|
|
26
|
+
* line for line on everything but two strings, and `tools/check-copies.ts` rightly priced them as
|
|
27
|
+
* one file forked five ways. The only thing that differs per app is data — the database name, the
|
|
28
|
+
* module, a note — and data fits in the one line of `package.json` that already names the command.
|
|
29
|
+
*/
|
|
30
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
32
|
+
import { d1SchemaText } from "cursedops/d1-import";
|
|
33
|
+
|
|
34
|
+
export type D1SchemaArgs = { database: string; from: string; exportName: string; notes: string[]; out: string };
|
|
35
|
+
|
|
36
|
+
/** The flags, or the sentence saying what is missing. */
|
|
37
|
+
export function parseD1SchemaArgs(argv: readonly string[]): D1SchemaArgs | string {
|
|
38
|
+
const args: D1SchemaArgs = { database: "", from: "", exportName: "schemaStatements", notes: [], out: join("db", "schema.sql") };
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const flag = argv[i];
|
|
41
|
+
const value = argv[i + 1];
|
|
42
|
+
if (value === undefined) return `${flag} needs a value`;
|
|
43
|
+
if (flag === "--database") args.database = value;
|
|
44
|
+
else if (flag === "--from") args.from = value;
|
|
45
|
+
else if (flag === "--export") args.exportName = value;
|
|
46
|
+
else if (flag === "--note") args.notes.push(value);
|
|
47
|
+
else if (flag === "--out") args.out = value;
|
|
48
|
+
else return `unknown flag ${flag}`;
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
if (!args.database) return "--database <name> is required — the D1 database, as `wrangler d1 execute` takes it";
|
|
52
|
+
if (!args.from) return "--from <module> is required — the module whose schemaStatements() is the schema";
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write the file under `cwd` (the app's root, where `bun run` starts a script). Exit code. */
|
|
57
|
+
export async function runD1SchemaCli(argv: readonly string[], cwd: string = process.cwd()): Promise<number> {
|
|
58
|
+
const args = parseD1SchemaArgs(argv);
|
|
59
|
+
if (typeof args === "string") {
|
|
60
|
+
console.error(`✗ forge-d1-schema: ${args}\nusage: forge-d1-schema --database <name> --from <module> [--export <fn>] [--note "<line>"]… [--out <file>]`);
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
const module = (await import(resolve(cwd, args.from))) as Record<string, unknown>;
|
|
64
|
+
const read = module[args.exportName];
|
|
65
|
+
if (typeof read !== "function") {
|
|
66
|
+
console.error(`✗ forge-d1-schema: ${args.from} exports no function ${args.exportName}()`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
const statements = (await read()) as string[];
|
|
70
|
+
const out = isAbsolute(args.out) ? args.out : join(cwd, args.out);
|
|
71
|
+
const text = d1SchemaText({ database: args.database, source: `${args.from}'s ${args.exportName}()`, statements, notes: args.notes });
|
|
72
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
73
|
+
writeFileSync(out, text);
|
|
74
|
+
console.log(`[${args.database}] wrote ${statements.length} statement(s) to ${args.out}`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (import.meta.main) {
|
|
79
|
+
process.exit(await runD1SchemaCli(process.argv.slice(2)));
|
|
80
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
*/
|
|
52
52
|
import { spawn, spawnSync } from "node:child_process";
|
|
53
53
|
import { existsSync, readFileSync } from "node:fs";
|
|
54
|
+
import { join } from "node:path";
|
|
54
55
|
|
|
55
56
|
/** The two deployments every Worker app here has. */
|
|
56
57
|
export type WorkerEnv = "production" | "stage";
|
|
@@ -89,6 +90,16 @@ export function readWranglerJsonc(text: string): Record<string, unknown> {
|
|
|
89
90
|
return JSON.parse(stripped) as Record<string, unknown>;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* `<root>/wrangler.jsonc`, read and parsed. Four apps (auth, family, music, vault) each carried
|
|
95
|
+
* this as its own `readWrangler` until task 2153. It is what tells every CLI
|
|
96
|
+
* script whether production is the Worker or the Mac (`servedByWorker`), so a copy that drifts
|
|
97
|
+
* sends a script's writes to a frozen registry.
|
|
98
|
+
*/
|
|
99
|
+
export function readWranglerFile(root: string): Record<string, unknown> {
|
|
100
|
+
return readWranglerJsonc(readFileSync(join(root, "wrangler.jsonc"), "utf8"));
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
/**
|
|
93
104
|
* `KEY=value` lines — `export ` tolerated, one layer of surrounding quotes removed (and a POSIX
|
|
94
105
|
* `'\''` inside single quotes read back as `'`), `#` lines and blanks skipped. The reader every
|