cursedops 0.7.1 → 0.8.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 +8 -2
- package/src/edgeFetch.ts +43 -1
- package/src/smoke.ts +133 -0
- package/src/workerDeploy.ts +33 -0
- package/src/workerSafety.ts +89 -0
- package/src/workerSecrets.ts +122 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
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 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.",
|
|
3
|
+
"version": "0.8.1",
|
|
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": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
"source": "./src/serve.ts",
|
|
38
38
|
"import": "./src/serve.ts"
|
|
39
39
|
},
|
|
40
|
+
"./worker-safety": {
|
|
41
|
+
"types": "./src/workerSafety.ts",
|
|
42
|
+
"bun": "./src/workerSafety.ts",
|
|
43
|
+
"source": "./src/workerSafety.ts",
|
|
44
|
+
"import": "./src/workerSafety.ts"
|
|
45
|
+
},
|
|
40
46
|
"./smoke": {
|
|
41
47
|
"types": "./src/smoke.ts",
|
|
42
48
|
"bun": "./src/smoke.ts",
|
package/src/edgeFetch.ts
CHANGED
|
@@ -29,9 +29,11 @@
|
|
|
29
29
|
* the response body after the LAST header block is bytes — an interim `1xx` block precedes it.
|
|
30
30
|
*/
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
32
|
-
import { rmSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
33
33
|
import { tmpdir } from "node:os";
|
|
34
34
|
import { join } from "node:path";
|
|
35
|
+
// The package's own subpath, never `./roots.ts` — see `publishShape.test.ts`.
|
|
36
|
+
import { forgeState } from "cursedops/roots";
|
|
35
37
|
|
|
36
38
|
export interface CurlRun {
|
|
37
39
|
status: number | null;
|
|
@@ -150,3 +152,43 @@ export function cloudflareAccessHeaders(envText: string): Record<string, string>
|
|
|
150
152
|
if (missing.length > 0) throw new Error(`the Access service token is incomplete: no ${missing.join(" and no ")}`);
|
|
151
153
|
return { "CF-Access-Client-Id": id, "CF-Access-Client-Secret": secret };
|
|
152
154
|
}
|
|
155
|
+
|
|
156
|
+
/** Is `host` a stage — i.e. behind the fleet's Access application for every `*-stage` host? */
|
|
157
|
+
export const behindAccess = (host: string): boolean => /-stage\./.test(host);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The fleet's edge fetch, configured the one way every Worker app configured it (task 2145, out
|
|
161
|
+
* of `apps/family` and `apps/music`): on a `*-stage` host, the Access service token read from
|
|
162
|
+
* `accessFile` (`$FORGE_STATE/secrets/cloudflare-access.env`); elsewhere, nothing.
|
|
163
|
+
*
|
|
164
|
+
* `accessHeadersFor` is returned too, because a smoke asks it FIRST — an incomplete token is the
|
|
165
|
+
* smoke's own environment, refused as exit 2, never charged to the app as a wall of 403s.
|
|
166
|
+
*/
|
|
167
|
+
export function createStageEdgeFetch(
|
|
168
|
+
accessFile: string,
|
|
169
|
+
options: Omit<EdgeFetchOptions, "headersFor"> & { read?: (path: string) => string } = {},
|
|
170
|
+
): { edgeFetch: (url: string, init?: RequestInit) => Promise<Response>; accessHeadersFor: (host: string) => Record<string, string> } {
|
|
171
|
+
const read = options.read ?? ((path: string) => readFileSync(path, "utf8"));
|
|
172
|
+
const accessHeadersFor = (host: string): Record<string, string> =>
|
|
173
|
+
behindAccess(host) ? cloudflareAccessHeaders(read(accessFile)) : {};
|
|
174
|
+
const { read: _read, ...rest } = options;
|
|
175
|
+
return { edgeFetch: createEdgeFetch({ ...rest, headersFor: accessHeadersFor }), accessHeadersFor };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* THE fleet's edge fetch — {@link createStageEdgeFetch} over `$FORGE_STATE/secrets/cloudflare-access.env`,
|
|
180
|
+
* the one file every Worker app's smoke, stage walk and CPU tail read the Access token from. Two apps
|
|
181
|
+
* carried this as a one-line edge-fetch module of their own until task 2145.
|
|
182
|
+
*
|
|
183
|
+
* `$FORGE_STATE` comes from the environment or the `forge.env` above `from` (default: wherever this
|
|
184
|
+
* package is installed, which is inside the generation's checkout). THROWS when neither answers —
|
|
185
|
+
* a smoke with no idea where its token lives must not guess one.
|
|
186
|
+
*/
|
|
187
|
+
export function fleetEdgeFetch(
|
|
188
|
+
from: string = import.meta.dir,
|
|
189
|
+
options: Omit<EdgeFetchOptions, "headersFor"> & { read?: (path: string) => string } = {},
|
|
190
|
+
): ReturnType<typeof createStageEdgeFetch> {
|
|
191
|
+
const state = forgeState(from);
|
|
192
|
+
if (!state) throw new Error(`no generation state root above ${from}: set $FORGE_STATE or run inside a checkout with forge.env`);
|
|
193
|
+
return createStageEdgeFetch(join(state, "secrets", "cloudflare-access.env"), options);
|
|
194
|
+
}
|
package/src/smoke.ts
CHANGED
|
@@ -893,3 +893,136 @@ export async function steadyHealth<T = Record<string, unknown>>(
|
|
|
893
893
|
await sleep(steady > 0 ? (options.pauseMs ?? 2_000) : (options.retryMs ?? 5_000));
|
|
894
894
|
}
|
|
895
895
|
}
|
|
896
|
+
|
|
897
|
+
/** One census entry — a private route and the method it is asked with. */
|
|
898
|
+
export interface CensusRoute {
|
|
899
|
+
method: string;
|
|
900
|
+
path: string;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** What {@link runWorkerSmoke} needs to know about one deployment — data, and one hook. */
|
|
904
|
+
export interface WorkerSmokeSpec {
|
|
905
|
+
deployment: { env: string; workerName: string; publicUrl: string; previewUrl: string | null };
|
|
906
|
+
/** Addresses given on the command line. Empty: the preview (no cache in front) and the public URL. */
|
|
907
|
+
urls?: readonly string[];
|
|
908
|
+
/** The checkout's HEAD, as `/healthz` reports a commit (`git rev-parse --short=8 HEAD`). */
|
|
909
|
+
head: string;
|
|
910
|
+
/** Every private route; each must refuse an anonymous caller with 401. Empty is a FAILURE. */
|
|
911
|
+
census: readonly CensusRoute[];
|
|
912
|
+
/** Client routes that must get the HTML shell THROUGH the Worker. */
|
|
913
|
+
shellPaths: readonly string[];
|
|
914
|
+
/** The edge fetch (`createStageEdgeFetch`) and its Access half, asked first. */
|
|
915
|
+
fetch: (url: string, init?: RequestInit) => Promise<Response>;
|
|
916
|
+
accessHeadersFor: (host: string) => Record<string, string>;
|
|
917
|
+
/** Where to fix the Access token, for the environment refusal. */
|
|
918
|
+
accessFileHint: string;
|
|
919
|
+
/** The app's own checks, per address, after the census — `family`'s `/ws/tree`, say. */
|
|
920
|
+
extraChecks?: (context: {
|
|
921
|
+
tag: string;
|
|
922
|
+
ask: (path: string, init?: RequestInit) => Promise<Response>;
|
|
923
|
+
health: Record<string, unknown>;
|
|
924
|
+
record: Smoke["record"];
|
|
925
|
+
}) => Promise<void>;
|
|
926
|
+
/** Test seams. */
|
|
927
|
+
smokeOptions?: Partial<SmokeOptions>;
|
|
928
|
+
steady?: SteadyOptions;
|
|
929
|
+
log?: (line: string) => void;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* The whole anonymous `worker:smoke` of a deployed Worker, lifted out of `apps/family` and
|
|
934
|
+
* `apps/music` (task 2145). Per address: `/healthz` settles on THIS commit with the door mounted
|
|
935
|
+
* (`steadyHealth`), the credential goes back to THIS deployment's own origin, every census route
|
|
936
|
+
* answers 401, the app's `extraChecks`, the shell through the Worker, and a missing hashed bundle
|
|
937
|
+
* is a 404 rather than HTML. The ledger and what a red MEANS are {@link createSmoke}'s: an
|
|
938
|
+
* incomplete Access token is the smoke's own environment (exit 2), never charged to the app.
|
|
939
|
+
*
|
|
940
|
+
* The signed-in half is the stage walk's — `collections` went live with this half green and its
|
|
941
|
+
* owner locked out. Answers the exit code; `process.exit(await runWorkerSmoke(...))`.
|
|
942
|
+
*/
|
|
943
|
+
export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
944
|
+
const log = spec.log ?? ((line: string) => console.log(line));
|
|
945
|
+
const { deployment } = spec;
|
|
946
|
+
const urls =
|
|
947
|
+
spec.urls && spec.urls.length > 0
|
|
948
|
+
? [...spec.urls]
|
|
949
|
+
: [...new Set([deployment.previewUrl, deployment.publicUrl].filter((u): u is string => Boolean(u)))];
|
|
950
|
+
const smoke = createSmoke({ base: deployment.publicUrl || (urls[0] as string), ...spec.smokeOptions });
|
|
951
|
+
|
|
952
|
+
for (const base of urls) {
|
|
953
|
+
const host = new URL(base).hostname;
|
|
954
|
+
try {
|
|
955
|
+
spec.accessHeadersFor(host);
|
|
956
|
+
} catch (error) {
|
|
957
|
+
smoke.refuseEnvironment(
|
|
958
|
+
`the Access service token for ${host}`,
|
|
959
|
+
`${(error as Error).message} — fix ${spec.accessFileHint}; nothing about the app was measured`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (smoke.refusals.length > 0) return smoke.report();
|
|
964
|
+
|
|
965
|
+
for (const base of urls) {
|
|
966
|
+
const tag = new URL(base).hostname;
|
|
967
|
+
log(`\n▸ ${base} (the ${deployment.env} deployment: ${deployment.workerName})`);
|
|
968
|
+
const ask = (path: string, init: RequestInit = {}) => spec.fetch(new URL(path, base).toString(), init);
|
|
969
|
+
|
|
970
|
+
let health: Record<string, unknown>;
|
|
971
|
+
let status: number;
|
|
972
|
+
try {
|
|
973
|
+
const read = await steadyHealth<Record<string, unknown>>(
|
|
974
|
+
() => ask("/healthz"),
|
|
975
|
+
(body) => body.commit === spec.head && body.apiServed === true,
|
|
976
|
+
spec.steady,
|
|
977
|
+
);
|
|
978
|
+
health = read.body;
|
|
979
|
+
status = read.response.status;
|
|
980
|
+
} catch (error) {
|
|
981
|
+
smoke.markEdgeFault();
|
|
982
|
+
smoke.record(`${tag} healthz`, false, `${(error as Error).message}${smoke.dnsHint(error as Error)}`);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
smoke.record(`${tag} healthz`, status === 200 && health.runtime === "worker", `${status}, runtime ${JSON.stringify(health.runtime)}`);
|
|
986
|
+
smoke.record(
|
|
987
|
+
`${tag} door`,
|
|
988
|
+
health.apiServed === true,
|
|
989
|
+
`apiServed ${JSON.stringify(health.apiServed)}${health.apiRefusal ? ` (${health.apiRefusal})` : ""}`,
|
|
990
|
+
);
|
|
991
|
+
smoke.record(`${tag} commit`, health.commit === spec.head, `serving ${JSON.stringify(health.commit)}, HEAD ${spec.head}`);
|
|
992
|
+
smoke.record(
|
|
993
|
+
`${tag} publicUrl`,
|
|
994
|
+
health.publicUrl === deployment.publicUrl,
|
|
995
|
+
`credentials go back to ${JSON.stringify(health.publicUrl)} — this deployment's ${deployment.publicUrl}`,
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
const open: string[] = [];
|
|
999
|
+
for (const route of spec.census) {
|
|
1000
|
+
const answered = (await ask(route.path, { method: route.method })).status;
|
|
1001
|
+
if (answered !== 401) open.push(`${route.method} ${route.path} → ${answered}`);
|
|
1002
|
+
}
|
|
1003
|
+
smoke.record(
|
|
1004
|
+
`${tag} origin-gate`,
|
|
1005
|
+
spec.census.length > 0 && open.length === 0,
|
|
1006
|
+
spec.census.length === 0
|
|
1007
|
+
? "🔴 the route census is EMPTY, so this proves nothing"
|
|
1008
|
+
: open.length === 0
|
|
1009
|
+
? `all ${spec.census.length} private routes refuse an anonymous caller`
|
|
1010
|
+
: `🔴 ${open.join(", ")}`,
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
await spec.extraChecks?.({ tag, ask, health, record: (check, ok, detail) => smoke.record(check, ok, detail) });
|
|
1014
|
+
|
|
1015
|
+
for (const path of spec.shellPaths) {
|
|
1016
|
+
const res = await ask(path, { headers: { accept: "text/html" } });
|
|
1017
|
+
const type = res.headers.get("content-type") ?? "";
|
|
1018
|
+
smoke.record(`${tag} shell ${path}`, res.status === 200 && type.includes("text/html"), `${res.status} ${type}`);
|
|
1019
|
+
}
|
|
1020
|
+
const miss = await ask("/assets/index-no-such-bundle.js");
|
|
1021
|
+
smoke.record(
|
|
1022
|
+
`${tag} missing-bundle`,
|
|
1023
|
+
miss.status === 404 && !(miss.headers.get("content-type") ?? "").includes("text/html"),
|
|
1024
|
+
`a missing bundle is a miss — ${miss.status}`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
return await smoke.settle();
|
|
1028
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -136,6 +136,39 @@ function readIfThere(path: string): string | null {
|
|
|
136
136
|
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* {@link cloudflareCredential} as the account a REST call needs — both halves, or a throw naming
|
|
141
|
+
* the file. `createHttpD1` and the secrets API take this shape (task 2145 — `family` and `music`
|
|
142
|
+
* each carried a copy of this in their Cloudflare credential module).
|
|
143
|
+
*/
|
|
144
|
+
export function cloudflareAccount(
|
|
145
|
+
file: string,
|
|
146
|
+
read: (path: string) => string | null = readIfThere,
|
|
147
|
+
): { accountId: string; apiToken: string } {
|
|
148
|
+
const found = cloudflareCredential(file, read);
|
|
149
|
+
const accountId = found.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
150
|
+
if (!accountId) throw new Error(`${file} has no CLOUDFLARE_ACCOUNT_ID`);
|
|
151
|
+
return { accountId, apiToken: found.CLOUDFLARE_API_TOKEN as string };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The refusal every app with a Mac host carries into its Worker deploy (task 2145): a PRODUCTION
|
|
156
|
+
* deploy of an app whose config routes the household's hostname to the Worker, while the Mac's
|
|
157
|
+
* host job `label` is still loaded — two hosts writing two databases. The cutover stops the Mac
|
|
158
|
+
* first. Stage deploys and a not-yet-routed production are never refused by it.
|
|
159
|
+
*/
|
|
160
|
+
export function macHostStillLoaded(
|
|
161
|
+
spec: { env: WorkerEnv; cutOver: boolean; label: string },
|
|
162
|
+
loaded: (label: string) => boolean = (label) =>
|
|
163
|
+
spawnSync("launchctl", ["print", `gui/${process.getuid?.() ?? 501}/${label}`], { stdio: "ignore" }).status === 0,
|
|
164
|
+
): Refusal {
|
|
165
|
+
return () =>
|
|
166
|
+
spec.env === "production" && spec.cutOver && loaded(spec.label)
|
|
167
|
+
? `${spec.label} is loaded on this Mac, and this deploy claims the household's hostname for the Worker —\n` +
|
|
168
|
+
" two writers, two databases. The cutover stops the Mac and imports its rows FIRST."
|
|
169
|
+
: null;
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
/**
|
|
140
173
|
* `command` under the app's Worker CPU wrapper, in the argv {@link parseWorkerCpuArgv} reads.
|
|
141
174
|
*
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two static checks every Worker app's suite runs over its own source — lifted out of
|
|
3
|
+
* `apps/family` and `apps/music`, which carried them verbatim (task 2145).
|
|
4
|
+
*
|
|
5
|
+
* · {@link valueImports} and {@link isBunOnlySubpath} — the import-graph walk a
|
|
6
|
+
* `workerSafe.test.ts` does from `worker/index.ts`, the edges esbuild walks. A value import
|
|
7
|
+
* of `bun:sqlite`, or of a library subpath whose export map sends every non-Bun runtime to a
|
|
8
|
+
* throwing stub, is a deploy that goes green and a Worker that never starts.
|
|
9
|
+
* · {@link asyncShapeHits} and {@link asyncExpectHits} — the await port's silent shapes. A
|
|
10
|
+
* sync-to-async port leaves code that typechecks and does nothing: `rows.filter(async …)`
|
|
11
|
+
* keeps every row (a Promise is truthy), `flatMap(async …)` flattens Promises, `forEach(async …)`
|
|
12
|
+
* starts every write and waits for none, and `expect(async () => …).not.toThrow()` asserts
|
|
13
|
+
* nothing for ever.
|
|
14
|
+
*
|
|
15
|
+
* Pure over strings, so each rule's failure path is driven against a fixture in this package's
|
|
16
|
+
* own suite; the apps keep only the walking (which files, which island is allowed).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every module specifier `code` imports or re-exports as a VALUE. Type-only edges are erased by
|
|
21
|
+
* esbuild and cannot pull anything into a bundle, so they are not returned. Pass code with its
|
|
22
|
+
* comments blanked, so prose that NAMES a module is not an import of it.
|
|
23
|
+
*/
|
|
24
|
+
export function valueImports(code: string): string[] {
|
|
25
|
+
const out: string[] = [];
|
|
26
|
+
const pattern =
|
|
27
|
+
/(?:^|[;\n])\s*(import|export)\s+(type\s+)?(?:[\s\S]*?\sfrom\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
|
|
28
|
+
for (const m of code.matchAll(pattern)) {
|
|
29
|
+
if (m[2]) continue;
|
|
30
|
+
const spec = m[3] ?? m[4];
|
|
31
|
+
if (spec) out.push(spec);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is `spec` a package subpath whose export map sends every non-Bun runtime to a stub that
|
|
38
|
+
* throws? `cursedbelt-server` routes its Bun-only entries to `_bunOnly.js` under `import`:
|
|
39
|
+
* wrangler bundles it without complaint and the isolate throws at module evaluation. Read off
|
|
40
|
+
* the INSTALLED package's own `exports` (`readManifest`), so a subpath the library later makes
|
|
41
|
+
* Bun-only is caught with no edit in the app.
|
|
42
|
+
*/
|
|
43
|
+
export function isBunOnlySubpath(spec: string, readManifest: (pkg: string) => unknown): boolean {
|
|
44
|
+
if (spec.startsWith(".") || spec.startsWith("node:") || spec.startsWith("bun:")) return false;
|
|
45
|
+
const parts = spec.split("/");
|
|
46
|
+
const pkg = spec.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] as string);
|
|
47
|
+
const subpath = `.${spec.slice(pkg.length)}` || ".";
|
|
48
|
+
const manifest = readManifest(pkg) as { exports?: Record<string, unknown> } | null;
|
|
49
|
+
const entry = manifest?.exports?.[subpath === "." ? "." : subpath];
|
|
50
|
+
if (!entry || typeof entry !== "object") return false;
|
|
51
|
+
const conditions = entry as Record<string, unknown>;
|
|
52
|
+
const nonBun = conditions.workerd ?? conditions.worker ?? conditions.import ?? conditions.default;
|
|
53
|
+
return typeof nonBun === "string" && /_bunOnly\b/.test(nonBun);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The await port's silent shapes in `code`, one hit per offending line. `.map(async …)` is a
|
|
58
|
+
* hit only when no `Promise.all(`/`Promise.allSettled(` opens within the three lines before it,
|
|
59
|
+
* since the idiomatic spelling breaks across lines. A line carrying `async-shape:ignore` is
|
|
60
|
+
* excused — on the LINE, never by deleting the rule.
|
|
61
|
+
*/
|
|
62
|
+
export function asyncShapeHits(code: string): { rule: string; line: number }[] {
|
|
63
|
+
const hits: { rule: string; line: number }[] = [];
|
|
64
|
+
const lines = code.split("\n");
|
|
65
|
+
lines.forEach((line, index) => {
|
|
66
|
+
if (line.includes("async-shape:ignore")) return;
|
|
67
|
+
const predicate = /\.(filter|some|every|find|findIndex|sort|forEach)\(\s*async\b/.exec(line);
|
|
68
|
+
if (predicate) hits.push({ rule: `${predicate[1]}-async-predicate`, line: index + 1 });
|
|
69
|
+
if (/\.flatMap\(\s*async\b/.test(line)) hits.push({ rule: "flatMap-async", line: index + 1 });
|
|
70
|
+
if (/\.map\(\s*async\b/.test(line)) {
|
|
71
|
+
const window = lines.slice(Math.max(0, index - 3), index + 1).join(" ");
|
|
72
|
+
if (!/Promise\.(all|allSettled)\s*\(/.test(window)) hits.push({ rule: "map-async-unawaited", line: index + 1 });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return hits;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lines holding `expect(async () => …)` or `expect(() => await …)` — an async function never
|
|
80
|
+
* throws when CALLED, so `.not.toThrow()` over one passes for ever. The correct spelling is
|
|
81
|
+
* `await expect(p).rejects…`, or `await p;` for "does not throw".
|
|
82
|
+
*/
|
|
83
|
+
export function asyncExpectHits(code: string): number[] {
|
|
84
|
+
return code
|
|
85
|
+
.split("\n")
|
|
86
|
+
.flatMap((line, index) =>
|
|
87
|
+
/expect\(\s*(async\s*\(|\(\)\s*=>\s*await\b)/.test(line) && !line.includes("async-shape:ignore") ? [index + 1] : [],
|
|
88
|
+
);
|
|
89
|
+
}
|
package/src/workerSecrets.ts
CHANGED
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
* ENROLL a new password (found 2026-09-22 by the stage walk, one step past sign-in).
|
|
36
36
|
*/
|
|
37
37
|
import { spawnSync } from "node:child_process";
|
|
38
|
-
import { chmodSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
// The package's own subpath, never `./workerDeploy.ts`: shipped source may not import a sibling
|
|
40
|
+
// by relative path (`publishShape.test.ts` says why), and a self-reference resolves through the
|
|
41
|
+
// same `exports` map every consumer's does.
|
|
42
|
+
import { readEnvFile, type WorkerEnv, wranglerEnvArgs } from "cursedops/worker-deploy";
|
|
39
43
|
|
|
40
44
|
/** Worker secret name → the key it is read from in the secrets file. A list means "same name". */
|
|
41
45
|
export type SecretMap = Readonly<Record<string, string>> | readonly string[];
|
|
@@ -193,3 +197,120 @@ export function writeSecretsFile(path: string, header: readonly string[], values
|
|
|
193
197
|
export function throwawaySecret(): string {
|
|
194
198
|
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
|
|
195
199
|
}
|
|
200
|
+
|
|
201
|
+
/** A deployment before its cutover, and every stage: a throwaway session key and the app's signing key. */
|
|
202
|
+
export const THROWAWAY_SECRETS = {
|
|
203
|
+
SESSION_SECRET: "SESSION_SECRET",
|
|
204
|
+
FILE_TOKEN_PRIVATE_KEY: "FILE_TOKEN_PRIVATE_KEY",
|
|
205
|
+
} as const;
|
|
206
|
+
|
|
207
|
+
/** What {@link runWorkerSecrets} needs to know about ONE app's ONE deployment — data, no logic. */
|
|
208
|
+
export interface WorkerSecretsSpec {
|
|
209
|
+
/** `family`, `music` — prefixes every message and names the Mac's `<app>.env`. */
|
|
210
|
+
app: string;
|
|
211
|
+
env: WorkerEnv;
|
|
212
|
+
/** Where `wrangler secret put` runs — the app's checkout. */
|
|
213
|
+
root: string;
|
|
214
|
+
workerName: string;
|
|
215
|
+
/** Worker secret name → the key it is read from in {@link secretsFile}. */
|
|
216
|
+
secrets: SecretMap;
|
|
217
|
+
/** The file this deployment's values come from. */
|
|
218
|
+
secretsFile: string;
|
|
219
|
+
/** Routed to the household's hostname — production uploads the Mac's own file, never a mint. */
|
|
220
|
+
cutOver: boolean;
|
|
221
|
+
/** The Mac's `<app>.env` — where a mint reads the app's signing key from. */
|
|
222
|
+
macSecretsFile: string;
|
|
223
|
+
/** What a deployment without a signing key cannot do — "signs no portrait URL". */
|
|
224
|
+
withoutSigningKey: string;
|
|
225
|
+
/** The app's own sentence about its signing key, for the minted file's header. */
|
|
226
|
+
signingKeyNote: string;
|
|
227
|
+
/** The Cloudflare credential (`cloudflare.env`, read by the app). */
|
|
228
|
+
cloudflare: Record<string, string>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The whole `worker:secrets` main, lifted out of `apps/family` and `apps/music` (task 2145):
|
|
233
|
+
*
|
|
234
|
+
* --mint write this deployment's THROWAWAY file (refused after the cutover)
|
|
235
|
+
* --check say what the account holds against the set, change nothing
|
|
236
|
+
* (none) upload EXACTLY the set over a pipe, delete every other name, read it back
|
|
237
|
+
*
|
|
238
|
+
* Answers the exit code: 0 done, 1 the account does not hold exactly the set, 2 this machine's
|
|
239
|
+
* own files are wrong (nothing about the Worker was measured).
|
|
240
|
+
*/
|
|
241
|
+
export async function runWorkerSecrets(
|
|
242
|
+
spec: WorkerSecretsSpec,
|
|
243
|
+
argv: readonly string[],
|
|
244
|
+
deps: { ops?: SecretOps; read?: (path: string) => string | null; write?: typeof writeSecretsFile } = {},
|
|
245
|
+
): Promise<number> {
|
|
246
|
+
const tag = `[${spec.app}]`;
|
|
247
|
+
const read = deps.read ?? ((path: string) => (existsSync(path) ? readFileSync(path, "utf8") : null));
|
|
248
|
+
|
|
249
|
+
if (argv.includes("--mint")) {
|
|
250
|
+
if (spec.cutOver) {
|
|
251
|
+
console.error(`${tag} --mint makes a THROWAWAY set. Production after the cutover uploads the Mac's own ${spec.app}.env.`);
|
|
252
|
+
return 2;
|
|
253
|
+
}
|
|
254
|
+
const signing = readEnvFile(read(spec.macSecretsFile) ?? "").FILE_TOKEN_PRIVATE_KEY;
|
|
255
|
+
if (!signing) {
|
|
256
|
+
console.error(`${tag} ${spec.app}.env carries no FILE_TOKEN_PRIVATE_KEY — without it this deployment ${spec.withoutSigningKey}.`);
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
const who = spec.env === "stage" ? "the STAGE" : "the PREVIEW";
|
|
260
|
+
(deps.write ?? writeSecretsFile)(
|
|
261
|
+
spec.secretsFile,
|
|
262
|
+
[
|
|
263
|
+
`${spec.workerName} — ${who}'s Worker secrets. 0600, never committed.`,
|
|
264
|
+
`Minted by \`bun run worker:secrets -- ${spec.env === "stage" ? "--env stage " : ""}--mint\`. The session key is a`,
|
|
265
|
+
"throwaway and opens nothing the owner owns.",
|
|
266
|
+
spec.signingKeyNote,
|
|
267
|
+
],
|
|
268
|
+
{ SESSION_SECRET: throwawaySecret(), FILE_TOKEN_PRIVATE_KEY: signing },
|
|
269
|
+
);
|
|
270
|
+
console.log(`✅ minted ${who}'s secrets into ${spec.secretsFile} (0600)`);
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const account = spec.cloudflare.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
275
|
+
if (!deps.ops && !account) {
|
|
276
|
+
console.error(`${tag} cloudflare.env has no CLOUDFLARE_ACCOUNT_ID`);
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
const ops =
|
|
280
|
+
deps.ops ??
|
|
281
|
+
restSecretOps({
|
|
282
|
+
api: cloudflareApi(spec.cloudflare.CLOUDFLARE_API_TOKEN as string),
|
|
283
|
+
account,
|
|
284
|
+
script: spec.workerName,
|
|
285
|
+
put: (name, value) =>
|
|
286
|
+
wranglerSecretPut(name, value, { cwd: spec.root, envArgs: wranglerEnvArgs(spec.env), env: spec.cloudflare }),
|
|
287
|
+
});
|
|
288
|
+
const before = await ops.held();
|
|
289
|
+
console.log(`${spec.workerName} holds: ${before.join(", ") || "(nothing)"}`);
|
|
290
|
+
|
|
291
|
+
if (argv.includes("--check")) {
|
|
292
|
+
const plan = planSecrets(spec.secrets, before, {});
|
|
293
|
+
const missing = plan.wanted.filter((name) => !before.includes(name));
|
|
294
|
+
if (missing.length > 0) console.error(`🔴 missing: ${missing.join(", ")}`);
|
|
295
|
+
if (plan.remove.length > 0) console.error(`🔴 not in the ${spec.env} set: ${plan.remove.join(", ")}`);
|
|
296
|
+
return missing.length + plan.remove.length > 0 ? 1 : 0;
|
|
297
|
+
}
|
|
298
|
+
const text = read(spec.secretsFile);
|
|
299
|
+
if (text === null) {
|
|
300
|
+
console.error(`${tag} no secrets file at ${spec.secretsFile}${spec.cutOver ? "" : " — make it: bun run worker:secrets -- --mint"}`);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
const plan = planSecrets(spec.secrets, before, readEnvFile(text));
|
|
304
|
+
if (plan.absent.length > 0) {
|
|
305
|
+
console.error(`${tag} ${spec.secretsFile} has no ${plan.absent.join(" and no ")}`);
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
const synced = await syncSecrets(plan, ops);
|
|
309
|
+
for (const line of synced.lines) console.log(`▸ ${line}`);
|
|
310
|
+
console.log(
|
|
311
|
+
synced.ok
|
|
312
|
+
? `✅ ${spec.workerName} holds exactly its ${spec.env} set`
|
|
313
|
+
: `🔴 ${spec.workerName} does not hold exactly its ${spec.env} set`,
|
|
314
|
+
);
|
|
315
|
+
return synced.ok ? 0 : 1;
|
|
316
|
+
}
|