@augurworks/augur 0.15.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/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// shoot.mjs — capture optimized WebP poster screenshots for prototype/page/component
|
|
3
|
+
// cards. Renders each entry index.html headlessly and writes preview.webp into its
|
|
4
|
+
// folder (OVERWRITING any old one), optimized via cwebp. build.js then renders an
|
|
5
|
+
// <img> poster instead of a live <iframe> preview — far cheaper — and falls back to
|
|
6
|
+
// a live iframe for anything without a poster yet.
|
|
7
|
+
//
|
|
8
|
+
// Rendered from source over file:// so the injected companion/review overlay are
|
|
9
|
+
// absent (their absolute /piti.js, /__review paths simply don't resolve) and the
|
|
10
|
+
// poster is a clean shot of the prototype itself.
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// npm run shoot # (re)shoot every card target
|
|
14
|
+
// npm run shoot -- --stale # only targets whose source changed (or no poster)
|
|
15
|
+
// npm run shoot -- <path/to/folder> # just one folder (must hold an entry .html)
|
|
16
|
+
//
|
|
17
|
+
// `--stale` is what `npm run posters` uses: it compares each folder's newest source
|
|
18
|
+
// file against its preview.webp and reshoots only what actually changed. Posters are
|
|
19
|
+
// COMMITTED IN THE SPACE REPOS — run this from a workspace of sibling clones (so targets
|
|
20
|
+
// resolve to the editable sibling clones), then commit/push the space repo.
|
|
21
|
+
//
|
|
22
|
+
// Requires: playwright (devDep) + cwebp on PATH (`brew install webp`).
|
|
23
|
+
|
|
24
|
+
import { chromium } from "playwright";
|
|
25
|
+
import { promises as fs } from "node:fs";
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { promisify } from "node:util";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
const execFileP = promisify(execFile);
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
const VIEWPORT = { width: 1280, height: 800 }; // 16:10 — matches the card aspect ratio
|
|
35
|
+
const WEBP_W = 768; // shown at ≤260px; 768px covers 2–3× DPR crisply
|
|
36
|
+
const WEBP_Q = 72;
|
|
37
|
+
|
|
38
|
+
async function exists(p) { try { await fs.access(p); return true; } catch { return false; } }
|
|
39
|
+
async function isDir(p) { try { return (await fs.stat(p)).isDirectory(); } catch { return false; } }
|
|
40
|
+
|
|
41
|
+
// Entry HTML for a folder: index.html, else the first .html.
|
|
42
|
+
async function entry(dir) {
|
|
43
|
+
if (await exists(path.join(dir, "index.html"))) return path.join(dir, "index.html");
|
|
44
|
+
const es = await fs.readdir(dir, { withFileTypes: true });
|
|
45
|
+
const h = es.find((e) => e.isFile() && e.name.endsWith(".html"));
|
|
46
|
+
return h ? path.join(dir, h.name) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A canvas board can't be shot from source: its entry html is a thin shell that
|
|
50
|
+
// loads /__canvas/canvas.js by absolute path (dead over file://) and the board
|
|
51
|
+
// content lives in the instance's KV (GET /__board — a public route). Canvas
|
|
52
|
+
// folders are shot from the LIVE page instead — strictly read-only: the room
|
|
53
|
+
// socket is stubbed at the constructor (upgrades bypass route interception), in
|
|
54
|
+
// EVERY frame, because a tile can embed another canvas which would join ITS room
|
|
55
|
+
// and haunt presence; POST /__board is answered locally; the overlay/companion
|
|
56
|
+
// scripts are blocked for the same clean-shot reason source shots use file://.
|
|
57
|
+
// No siteOrigin, offline, or a gate answering the URL → skip, keeping whatever
|
|
58
|
+
// poster is committed.
|
|
59
|
+
async function isCanvasEntry(file) {
|
|
60
|
+
try { return /\/__canvas\/canvas\.js/.test(await fs.readFile(file, "utf8")); } catch { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A card folder's live URL: walk up to its space root (space.json), mount the
|
|
64
|
+
// default space at "/" and others at "/<id>/" (build.js's rule), and drop the
|
|
65
|
+
// on-disk "prototypes/" segment (<opp>/prototypes/<name> ships at /<opp>/<name>/).
|
|
66
|
+
async function liveUrl(dir) {
|
|
67
|
+
let root = dir;
|
|
68
|
+
while (root !== path.dirname(root) && !(await exists(path.join(root, "space.json")))) root = path.dirname(root);
|
|
69
|
+
let meta;
|
|
70
|
+
try { meta = JSON.parse(await fs.readFile(path.join(root, "space.json"), "utf8")); } catch { return null; }
|
|
71
|
+
const origin = typeof meta.siteOrigin === "string" ? meta.siteOrigin.replace(/\/+$/, "") : "";
|
|
72
|
+
if (!origin) return null;
|
|
73
|
+
const parts = path.relative(root, dir).split(path.sep);
|
|
74
|
+
if (parts[1] === "prototypes") parts.splice(1, 1);
|
|
75
|
+
const base = meta.default ? "" : `/${meta.id}`;
|
|
76
|
+
return `${origin}${base}/${parts.map(encodeURIComponent).join("/")}/`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function shootLiveCanvas(browser, dir, rel) {
|
|
80
|
+
const live = await liveUrl(dir);
|
|
81
|
+
if (!live) { console.log("· skip (canvas, no siteOrigin):", rel); return false; }
|
|
82
|
+
// Reachability first: the gate answers unknown or locked paths with its own
|
|
83
|
+
// login HTML, which must never become a poster.
|
|
84
|
+
let shell = "";
|
|
85
|
+
try { shell = await (await fetch(live)).text(); } catch {}
|
|
86
|
+
if (!/\/__canvas\/canvas\.js/.test(shell)) {
|
|
87
|
+
console.log("· skip (canvas gated/unpublished/offline):", rel, "→", live);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const tmp = path.join(os.tmpdir(), "shoot-" + rel.replace(/[^a-z0-9]+/gi, "-") + ".png");
|
|
91
|
+
const outWebp = path.join(dir, "preview.webp");
|
|
92
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
93
|
+
const page = await browser.newPage({ viewport: VIEWPORT });
|
|
94
|
+
try {
|
|
95
|
+
await page.addInitScript(() => {
|
|
96
|
+
window.WebSocket = class {
|
|
97
|
+
constructor() {
|
|
98
|
+
this.readyState = 3;
|
|
99
|
+
setTimeout(() => { if (this.onerror) this.onerror(new Event("error")); if (this.onclose) this.onclose(new Event("close")); }, 0);
|
|
100
|
+
}
|
|
101
|
+
addEventListener(type, fn) { if (type === "error" || type === "close") setTimeout(() => fn(new Event(type)), 0); }
|
|
102
|
+
removeEventListener() {} send() {} close() {}
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
await page.route("**/__board*", (r) =>
|
|
106
|
+
r.request().method() === "POST" ? r.fulfill({ status: 204, body: "" }) : r.continue());
|
|
107
|
+
await page.route("**/piti*", (r) => r.abort());
|
|
108
|
+
await page.route("**/__review/**", (r) => r.abort());
|
|
109
|
+
await page.goto(live, { waitUntil: "load", timeout: 30000 });
|
|
110
|
+
// Board fetch + node paint; an empty board is legitimate, so a miss just shoots.
|
|
111
|
+
await page.waitForFunction(() => window.GVCanvas && window.GVCanvas.nodes && window.GVCanvas.nodes().length > 0, { timeout: 15000 }).catch(() => {});
|
|
112
|
+
await page.waitForTimeout(2500); // tiles/images/fonts settle
|
|
113
|
+
// Editing chrome (toolbar/topbar/zoom — all inside #gvc-ui) is not board
|
|
114
|
+
// content; frame the whole board rather than whatever camera was saved last.
|
|
115
|
+
await page.addStyleTag({ content: "#gvc-ui { display: none !important; }" });
|
|
116
|
+
await page.evaluate(() => { try { window.GVCanvas.fit({ cover: true }); } catch {} });
|
|
117
|
+
await page.waitForTimeout(700); // fit() animates ~320ms; let tiles re-rasterize
|
|
118
|
+
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, ...VIEWPORT } });
|
|
119
|
+
const png = await fs.stat(tmp).catch(() => null);
|
|
120
|
+
if (!png || png.size === 0) throw new Error("empty screenshot");
|
|
121
|
+
await execFileP("cwebp", ["-quiet", "-q", String(WEBP_Q), "-resize", String(WEBP_W), "0", tmp, "-o", outWebp]);
|
|
122
|
+
const kb = Math.round((await fs.stat(outWebp)).size / 1024);
|
|
123
|
+
console.log("✓", rel + "/preview.webp", kb + "KB (live canvas)");
|
|
124
|
+
return true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
const msg = e.message.split("\n")[0];
|
|
127
|
+
if (attempt < 2) { console.log("· retry", rel, "—", msg); continue; }
|
|
128
|
+
console.log("✗ FAIL", rel, "—", msg);
|
|
129
|
+
return false;
|
|
130
|
+
} finally {
|
|
131
|
+
await page.close().catch(() => {});
|
|
132
|
+
await fs.unlink(tmp).catch(() => {});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Folders inside a space root that never hold card targets.
|
|
139
|
+
// Extend via GV_SCAN_IGNORE (comma-separated dir names) for space-specific bulk dirs.
|
|
140
|
+
const IGNORE = new Set(["node_modules", "skills", "scripts", "registry", ".git", ".github", ".claude",
|
|
141
|
+
...(process.env.GV_SCAN_IGNORE || "").split(",").filter(Boolean)]);
|
|
142
|
+
|
|
143
|
+
// Space roots to scan. One repo per space: card targets live inside each space repo.
|
|
144
|
+
// Prefer the EDITABLE sibling clones (../<x> with a space.json at its root — a shot
|
|
145
|
+
// poster can be committed + pushed from there); fall back to the pinned spaces/<id>
|
|
146
|
+
// mirrors (posters shot there can't be pushed — run from the sibling-clone workspace).
|
|
147
|
+
async function spaceRoots() {
|
|
148
|
+
const roots = [];
|
|
149
|
+
for (const base of [path.join(ROOT, ".."), path.join(ROOT, "spaces")]) {
|
|
150
|
+
try {
|
|
151
|
+
for (const e of await fs.readdir(base, { withFileTypes: true })) {
|
|
152
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
153
|
+
if (await exists(path.join(base, e.name, "space.json"))) roots.push(path.join(base, e.name));
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
156
|
+
if (roots.length) break; // siblings found → don't also scan the pinned mirrors
|
|
157
|
+
}
|
|
158
|
+
return roots;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Every folder that appears as a card on a shell page: each opportunity's prototypes,
|
|
162
|
+
// plus the pages/, components/, base/, patterns/, playground/ groups — per space.
|
|
163
|
+
async function targets() {
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const root of await spaceRoots()) {
|
|
166
|
+
for (const t of await fs.readdir(root, { withFileTypes: true })) {
|
|
167
|
+
if (!t.isDirectory() || IGNORE.has(t.name) || t.name.startsWith(".")) continue;
|
|
168
|
+
const pp = path.join(root, t.name, "prototypes");
|
|
169
|
+
if (await isDir(pp)) {
|
|
170
|
+
for (const p of await fs.readdir(pp, { withFileTypes: true })) {
|
|
171
|
+
if (p.isDirectory()) out.push(path.join(pp, p.name));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const group of ["pages", "components", "base", "patterns", "playground"]) {
|
|
176
|
+
const g = path.join(root, group);
|
|
177
|
+
if (await isDir(g)) {
|
|
178
|
+
for (const e of await fs.readdir(g, { withFileTypes: true })) {
|
|
179
|
+
if (e.isDirectory() && !e.name.startsWith(".")) out.push(path.join(g, e.name));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Newest mtime (ms) of any file in a folder tree, ignoring the poster itself.
|
|
188
|
+
async function newestMtime(dir, ignore) {
|
|
189
|
+
let latest = 0;
|
|
190
|
+
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
191
|
+
if (e.name === ignore) continue;
|
|
192
|
+
const p = path.join(dir, e.name);
|
|
193
|
+
if (e.isDirectory()) latest = Math.max(latest, await newestMtime(p, ignore));
|
|
194
|
+
else if (e.isFile()) latest = Math.max(latest, (await fs.stat(p)).mtimeMs);
|
|
195
|
+
}
|
|
196
|
+
return latest;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A folder needs reshooting when it has no poster, or its source is newer than one.
|
|
200
|
+
async function needsShoot(dir) {
|
|
201
|
+
const poster = path.join(dir, "preview.webp");
|
|
202
|
+
if (!(await exists(poster))) return true;
|
|
203
|
+
return (await newestMtime(dir, "preview.webp")) > (await fs.stat(poster)).mtimeMs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function shoot(browser, dir) {
|
|
207
|
+
const file = await entry(dir);
|
|
208
|
+
const rel = path.relative(ROOT, dir);
|
|
209
|
+
if (!file) { console.log("· skip (no html):", rel); return false; }
|
|
210
|
+
if (await isCanvasEntry(file)) return shootLiveCanvas(browser, dir, rel);
|
|
211
|
+
const tmp = path.join(os.tmpdir(), "shoot-" + rel.replace(/[^a-z0-9]+/gi, "-") + ".png");
|
|
212
|
+
const outWebp = path.join(dir, "preview.webp");
|
|
213
|
+
// Up to 2 attempts. Headless captures occasionally produce an empty/corrupt PNG
|
|
214
|
+
// that cwebp then can't read; previously the unguarded cwebp rejection crashed the
|
|
215
|
+
// whole run (aborting `npm run deploy`). Now any single-poster failure is caught and
|
|
216
|
+
// logged — the run keeps going and the existing committed poster is left in place —
|
|
217
|
+
// and a retry clears the common transient case.
|
|
218
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
219
|
+
const page = await browser.newPage({ viewport: VIEWPORT });
|
|
220
|
+
try {
|
|
221
|
+
await page.goto("file://" + file, { waitUntil: "load", timeout: 20000 });
|
|
222
|
+
await page.waitForTimeout(900); // let fonts/layout settle
|
|
223
|
+
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, ...VIEWPORT } });
|
|
224
|
+
const png = await fs.stat(tmp).catch(() => null);
|
|
225
|
+
if (!png || png.size === 0) throw new Error("empty screenshot");
|
|
226
|
+
await execFileP("cwebp", ["-quiet", "-q", String(WEBP_Q), "-resize", String(WEBP_W), "0", tmp, "-o", outWebp]);
|
|
227
|
+
const kb = Math.round((await fs.stat(outWebp)).size / 1024);
|
|
228
|
+
console.log("✓", rel + "/preview.webp", kb + "KB");
|
|
229
|
+
return true;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
const msg = e.message.split("\n")[0];
|
|
232
|
+
if (attempt < 2) { console.log("· retry", rel, "—", msg); continue; }
|
|
233
|
+
console.log("✗ FAIL", rel, "—", msg);
|
|
234
|
+
return false;
|
|
235
|
+
} finally {
|
|
236
|
+
await page.close().catch(() => {});
|
|
237
|
+
await fs.unlink(tmp).catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const argv = process.argv.slice(2);
|
|
244
|
+
const staleOnly = argv.includes("--stale");
|
|
245
|
+
const pathArg = argv.find((a) => !a.startsWith("--"));
|
|
246
|
+
let list = pathArg ? [path.resolve(pathArg)] : await targets();
|
|
247
|
+
|
|
248
|
+
if (staleOnly) {
|
|
249
|
+
const fresh = [];
|
|
250
|
+
for (const d of list) if (await needsShoot(d)) fresh.push(d);
|
|
251
|
+
const skipped = list.length - fresh.length;
|
|
252
|
+
if (skipped) console.log(`stale: ${skipped} poster(s) up to date · ${fresh.length} to (re)shoot`);
|
|
253
|
+
list = fresh;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!list.length) {
|
|
257
|
+
console.log("nothing to shoot — all posters current");
|
|
258
|
+
} else {
|
|
259
|
+
const browser = await chromium.launch();
|
|
260
|
+
let ok = 0;
|
|
261
|
+
for (const d of list) if (await shoot(browser, d)) ok++;
|
|
262
|
+
await browser.close();
|
|
263
|
+
console.log(`\nshot ${ok}/${list.length} posters`);
|
|
264
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* smoke-invite.mjs — the whole invite lifecycle in one pass, against an in-memory KV.
|
|
4
|
+
* The unit tests check each piece; this checks the SEQUENCE, which is what an operator
|
|
5
|
+
* actually walks through: pending → invite → accepted → login → reset → locked out.
|
|
6
|
+
*
|
|
7
|
+
* Usage: npm run smoke
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { __testables as W } from "../src/_worker.js";
|
|
11
|
+
|
|
12
|
+
function memKV() {
|
|
13
|
+
const store = new Map();
|
|
14
|
+
return {
|
|
15
|
+
async get(k) { return store.has(k) ? store.get(k) : null; },
|
|
16
|
+
async put(k, v) { store.set(k, v); },
|
|
17
|
+
async delete(k) { store.delete(k); },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const kv = memKV();
|
|
22
|
+
const env = { COMMENTS: kv, SESSION_SECRET: "smoke-secret" };
|
|
23
|
+
const ADMIN = { email: "admin@smoke.test", name: "Admin", role: "admin" };
|
|
24
|
+
// pass: "leaked-seed-2026" stands in for the legacy roster password migration is
|
|
25
|
+
// revoking. Without it, effectiveSecret has nothing to fall back to, so a tombstone
|
|
26
|
+
// (ov[email] = null) and an outright `delete ov[email]` both collapse to "" — steps
|
|
27
|
+
// 6 and 7 would pass identically either way, and the reset-regresses-to-a-leak bug
|
|
28
|
+
// this script exists to catch would sail straight through. Do not remove this field.
|
|
29
|
+
const USER = { email: "user@smoke.test", name: "User", pass: "leaked-seed-2026" };
|
|
30
|
+
// A fresh entry with no legacy password and no passHash — represents a genuinely new,
|
|
31
|
+
// never-invited person. Asserts that the pending state is actually derivable.
|
|
32
|
+
const FRESH = { email: "fresh@smoke.test", name: "Fresh" };
|
|
33
|
+
const ROSTER = [ADMIN, USER, FRESH];
|
|
34
|
+
const ORIGIN = "https://smoke.test";
|
|
35
|
+
|
|
36
|
+
const post = (body) => new Request(`${ORIGIN}/__admin/users`, {
|
|
37
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
const redeem = (token, password) => new Request(`${ORIGIN}/__invite`, {
|
|
40
|
+
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
41
|
+
body: new URLSearchParams({ token, password }).toString(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let step = 0;
|
|
45
|
+
const ok = (m) => console.log(` ${++step}. ${m}`);
|
|
46
|
+
|
|
47
|
+
console.log("invite lifecycle:");
|
|
48
|
+
|
|
49
|
+
// 1. A roster entry with a legacy password reads as accepted — that's the leaked
|
|
50
|
+
// password this whole migration exists to revoke. "reset" (step 2) is what moves
|
|
51
|
+
// them to pending. A fresh entry with no password reads as pending — that state
|
|
52
|
+
// derivation must not be broken.
|
|
53
|
+
let res = await W.adminUsersApi(new Request(`${ORIGIN}/__admin/users`), new URL(`${ORIGIN}/__admin/users`), env, ADMIN, ROSTER);
|
|
54
|
+
let body = await res.json();
|
|
55
|
+
assert.equal(body.users.find((u) => u.email === USER.email).state, "accepted");
|
|
56
|
+
assert.equal(body.users.find((u) => u.email === FRESH.email).state, "pending");
|
|
57
|
+
ok("legacy roster entry reads as accepted, fresh never-invited entry reads as pending");
|
|
58
|
+
|
|
59
|
+
// 2. Reset mints a link.
|
|
60
|
+
res = await W.adminUsersApi(post({ op: "reset", email: USER.email }), new URL(`${ORIGIN}/__admin/users`), env, ADMIN, ROSTER);
|
|
61
|
+
body = await res.json();
|
|
62
|
+
assert.equal(body.ok, true);
|
|
63
|
+
const token = new URL(body.url).searchParams.get("t");
|
|
64
|
+
ok("reset returns a single-use invite link");
|
|
65
|
+
|
|
66
|
+
// 3. Redeeming sets a hash and signs in.
|
|
67
|
+
res = await W.invitePost(redeem(token, "a properly long password"), new URL(`${ORIGIN}/__invite`), env, ROSTER);
|
|
68
|
+
assert.equal(res.status, 303);
|
|
69
|
+
assert.match(res.headers.get("Set-Cookie") || "", /^__Host-augur_user=/);
|
|
70
|
+
const stored = JSON.parse(await kv.get("users:secrets"))[USER.email];
|
|
71
|
+
assert.ok(W.isPassHash(stored), "stored as a hash");
|
|
72
|
+
ok("redemption stores a hash and issues a session");
|
|
73
|
+
|
|
74
|
+
// 4. The link is dead, and the reuse attempt didn't touch the stored secret. A status-only
|
|
75
|
+
// check would miss a future reordering that starts mutating state before the token check.
|
|
76
|
+
res = await W.invitePost(redeem(token, "another long password"), new URL(`${ORIGIN}/__invite`), env, ROSTER);
|
|
77
|
+
assert.equal(res.status, 400);
|
|
78
|
+
assert.equal(JSON.parse(await kv.get("users:secrets"))[USER.email], stored);
|
|
79
|
+
ok("the link cannot be reused, and the stored secret is untouched by the attempt");
|
|
80
|
+
|
|
81
|
+
// 5. The user now reads as accepted.
|
|
82
|
+
res = await W.adminUsersApi(new Request(`${ORIGIN}/__admin/users`), new URL(`${ORIGIN}/__admin/users`), env, ADMIN, ROSTER);
|
|
83
|
+
body = await res.json();
|
|
84
|
+
assert.equal(body.users.find((u) => u.email === USER.email).state, "accepted");
|
|
85
|
+
assert.ok(!JSON.stringify(body).includes("pbkdf2$"), "no secret leaks through the admin API");
|
|
86
|
+
ok("state flips to accepted, no secret in the API response");
|
|
87
|
+
|
|
88
|
+
// 6. The chosen password verifies; the session cookie identifies.
|
|
89
|
+
assert.equal(await W.verifyPassword("a properly long password", stored), true);
|
|
90
|
+
const sessionToken = await W.userToken(env, USER);
|
|
91
|
+
const identified = await W.identify(
|
|
92
|
+
new Request(ORIGIN, { headers: { Cookie: `__Host-augur_user=${USER.email}.${sessionToken}` } }), env, ROSTER);
|
|
93
|
+
assert.equal(identified && identified.email, USER.email);
|
|
94
|
+
ok("password verifies and the session identifies the user");
|
|
95
|
+
|
|
96
|
+
// 7. Reset again: this must write a TOMBSTONE, not a delete. Both make the session stop
|
|
97
|
+
// verifying, so that check alone can't tell them apart — a regression from
|
|
98
|
+
// `ov[email] = null` to `delete ov[email]` would sail through it, because effectiveSecret
|
|
99
|
+
// only falls back to USER.pass ("leaked-seed-2026") when the key is ABSENT, and a fallback
|
|
100
|
+
// to that leaked roster password is exactly the bug this reset exists to prevent. Assert
|
|
101
|
+
// the raw stored state directly, not just the session outcome.
|
|
102
|
+
await W.adminUsersApi(post({ op: "reset", email: USER.email }), new URL(`${ORIGIN}/__admin/users`), env, ADMIN, ROSTER);
|
|
103
|
+
const secretsMap = JSON.parse(await kv.get("users:secrets"));
|
|
104
|
+
assert.ok(Object.prototype.hasOwnProperty.call(secretsMap, USER.email), "key still present: a tombstone, not a deletion");
|
|
105
|
+
assert.ok(!secretsMap[USER.email], "tombstoned value is falsy");
|
|
106
|
+
const revokedSecret = await W.effectiveSecret(env, USER);
|
|
107
|
+
assert.equal(revokedSecret, "");
|
|
108
|
+
assert.notEqual(revokedSecret, "leaked-seed-2026", "must not fall back to the leaked roster password");
|
|
109
|
+
assert.equal(await W.identify(
|
|
110
|
+
new Request(ORIGIN, { headers: { Cookie: `__Host-augur_user=${USER.email}.${sessionToken}` } }), env, ROSTER), null);
|
|
111
|
+
ok("reset writes a tombstone (not a deletion), revokes the password with no fallback, and kills the live session");
|
|
112
|
+
|
|
113
|
+
console.log("\nall good.");
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// state-inventory — the inventory stays true, or the build stops.
|
|
3
|
+
//
|
|
4
|
+
// WHY. `src/state-inventory.mjs` is the authoritative map from every stored key to
|
|
5
|
+
// where it goes in the migration off shared KV. A list like that rots by default: five
|
|
6
|
+
// families appeared between its first draft and its second reading, and nothing caught
|
|
7
|
+
// them. A family nobody remembers is a family nobody exports, and the first anyone hears
|
|
8
|
+
// of it is a workspace arriving on its new home missing something.
|
|
9
|
+
//
|
|
10
|
+
// TWO MODES, because the two questions are different and only one of them can run in CI.
|
|
11
|
+
//
|
|
12
|
+
// SOURCE (default, and what `check` runs). Every key-shaped constant and every literal
|
|
13
|
+
// handed to a store call in the engine must be accounted for, and every inventory entry
|
|
14
|
+
// must still appear in the source. No credentials, so it runs anywhere — and it catches
|
|
15
|
+
// the rot that actually happens, which is somebody adding a family in code.
|
|
16
|
+
//
|
|
17
|
+
// LIVE (`--live <file>`). Every key on a real instance must be accounted for. This is
|
|
18
|
+
// the one the plan item asks for and it needs a KV listing, which needs an account
|
|
19
|
+
// credential CI does not have:
|
|
20
|
+
//
|
|
21
|
+
// npx wrangler kv key list --namespace-id <id> --remote > /tmp/keys.json
|
|
22
|
+
// node scripts/state-inventory.mjs --live /tmp/keys.json
|
|
23
|
+
//
|
|
24
|
+
// Run it before a migration, not on a schedule. What it finds that the source scan
|
|
25
|
+
// cannot is a key written by something that is no longer in the engine at all.
|
|
26
|
+
//
|
|
27
|
+
// WHAT THIS IS NOT: a parser. It reads literals, so a key assembled at runtime from pieces
|
|
28
|
+
// it cannot see is a key it cannot see either — the same floor `wrangler-preflight.mjs`
|
|
29
|
+
// stands on, and stated for the same reason. The LIVE mode is what closes that gap.
|
|
30
|
+
//
|
|
31
|
+
// Usage: node scripts/state-inventory.mjs [--live <file>] [--print] [--quiet]
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { STATE_INVENTORY, accountsFor, inventoryEntry } from "../src/state-inventory.mjs";
|
|
36
|
+
import { IDENTITY_KV_FAMILIES, UNMAPPED_WORKSPACE_FAMILIES } from "../src/kv-identity.mjs";
|
|
37
|
+
|
|
38
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
39
|
+
const SOURCES = ["src/_worker.js", "src/mail.mjs"];
|
|
40
|
+
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
const quiet = argv.includes("--quiet");
|
|
43
|
+
const liveArg = argv[argv.indexOf("--live") + 1];
|
|
44
|
+
const live = argv.includes("--live") ? liveArg : null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every key-ish literal the engine names, as a set.
|
|
48
|
+
*
|
|
49
|
+
* Four shapes, and only four, because each is a place a key can be WRITTEN DOWN:
|
|
50
|
+
* · a module-scope `const SOMETHING_KEY = "…"` or `_PREFIX`
|
|
51
|
+
* · a `doc: "…"` inside the overlay's family table
|
|
52
|
+
* · a string handed straight to a store call
|
|
53
|
+
* · a template handed straight to a store call, with its holes blanked
|
|
54
|
+
* Anything else is a key built out of pieces, which the live mode is for.
|
|
55
|
+
*/
|
|
56
|
+
export function keysInSource(text) {
|
|
57
|
+
const found = new Map(); // literal -> the line it was found on
|
|
58
|
+
const add = (lit, line) => { if (lit && !found.has(lit)) found.set(lit, line); };
|
|
59
|
+
const lineOf = (idx) => text.slice(0, idx).split("\n").length;
|
|
60
|
+
|
|
61
|
+
for (const m of text.matchAll(/^const\s+([A-Z0-9_]*_(?:KEY|PREFIX))\s*=\s*"([^"]+)"/gm)) {
|
|
62
|
+
add(m[2], lineOf(m.index));
|
|
63
|
+
}
|
|
64
|
+
for (const m of text.matchAll(/^export const\s+([A-Z0-9_]*_(?:KEY|PREFIX))\s*=\s*"([^"]+)"/gm)) {
|
|
65
|
+
add(m[2], lineOf(m.index));
|
|
66
|
+
}
|
|
67
|
+
for (const m of text.matchAll(/\bdoc:\s*"([^"]+)"/g)) {
|
|
68
|
+
// The overlay family table stores the document NAME; the keys it composes are
|
|
69
|
+
// `<doc>:<something>`, so record the prefix form the inventory carries.
|
|
70
|
+
add(m[1] + ":", lineOf(m.index));
|
|
71
|
+
add(m[1], lineOf(m.index));
|
|
72
|
+
}
|
|
73
|
+
// The receiver has to be a STORE, or this reads every header name and query parameter in
|
|
74
|
+
// the file: `headers.get("Cookie")` is the same call shape as `kv.get("statuses")`.
|
|
75
|
+
// These four names are what the engine calls its stores, and a fifth would be caught by
|
|
76
|
+
// the live mode rather than silently skipped.
|
|
77
|
+
const RECEIVER = String.raw`(?:kv|r2|bundles|env\.BUNDLES|env\.COMMENTS)`;
|
|
78
|
+
for (const m of text.matchAll(new RegExp(RECEIVER + String.raw`\.(?:get|put|delete|head|getWithMetadata)\(\s*"([^"]+)"`, "g"))) {
|
|
79
|
+
add(m[1], lineOf(m.index));
|
|
80
|
+
}
|
|
81
|
+
for (const m of text.matchAll(new RegExp(RECEIVER + String.raw`\.(?:get|put|delete|head|getWithMetadata)\(\s*` + "`([^`]+)`", "g"))) {
|
|
82
|
+
add(m[1].replace(/\$\{[^}]*\}/g, "*"), lineOf(m.index));
|
|
83
|
+
}
|
|
84
|
+
for (const m of text.matchAll(new RegExp(RECEIVER + String.raw`\.list\(\{\s*prefix:\s*` + "[`\"]([^`\"]+)[`\"]", "g"))) {
|
|
85
|
+
add(m[1].replace(/\$\{[^}]*\}/g, "*"), lineOf(m.index));
|
|
86
|
+
}
|
|
87
|
+
for (const m of text.matchAll(/const\s+\w+\s*=\s*`([a-z][a-z0-9:_-]*:)\$\{/g)) {
|
|
88
|
+
// `const sentKey = \`rebake:sent:${id}\`` — a prefix written as a template.
|
|
89
|
+
add(m[1], lineOf(m.index));
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The inventory entries a literal accounts for — plural, because one literal can.
|
|
96
|
+
*
|
|
97
|
+
* A trailing colon is not a distinction: `statuses` and `statuses:` are the same family
|
|
98
|
+
* written two ways, and the overlay's family table writes the second. A DOCUMENT NAME
|
|
99
|
+
* covers every key under it, which is how `pt` accounts for both `pt:view` and
|
|
100
|
+
* `pt:remarks`. And a composed key (`spaces/*/manifest.json`) belongs to its prefix.
|
|
101
|
+
*/
|
|
102
|
+
function accountedBy(lit) {
|
|
103
|
+
const star = lit.indexOf("*");
|
|
104
|
+
const head = star === -1 ? lit : lit.slice(0, star);
|
|
105
|
+
const bare = head.replace(/:$/, "");
|
|
106
|
+
const out = new Set();
|
|
107
|
+
for (const f of [lit, head, lit.replace(/:$/, ""), bare, bare + ":"]) {
|
|
108
|
+
const direct = inventoryEntry(f);
|
|
109
|
+
if (direct) out.add(direct.id);
|
|
110
|
+
}
|
|
111
|
+
// A document name ALSO stands for every key beneath it, and it can do both at once:
|
|
112
|
+
// `pins` is a document AND a prefix, because a signed-out visitor's pins live under the
|
|
113
|
+
// bare name while everybody else's live under `pins:<address>`. Collecting both is what
|
|
114
|
+
// stops the second one being reported as an entry nothing names.
|
|
115
|
+
for (const e of STATE_INVENTORY) if (e.id.startsWith(bare + ":")) out.add(e.id);
|
|
116
|
+
if (out.size) return [...out];
|
|
117
|
+
const covering = accountsFor(head);
|
|
118
|
+
return covering ? [covering.id] : [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const problems = [];
|
|
122
|
+
|
|
123
|
+
if (live) {
|
|
124
|
+
const raw = fs.readFileSync(live === "-" ? 0 : live, "utf8");
|
|
125
|
+
let keys;
|
|
126
|
+
try {
|
|
127
|
+
const doc = JSON.parse(raw);
|
|
128
|
+
keys = (Array.isArray(doc) ? doc : doc.result || []).map((k) => (typeof k === "string" ? k : k.name));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
keys = raw.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
if (!keys.length) {
|
|
133
|
+
console.error("state-inventory: that listing is empty — a vacuous pass is not a pass");
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
const unaccounted = new Map();
|
|
137
|
+
for (const k of keys) {
|
|
138
|
+
if (accountsFor(k)) continue;
|
|
139
|
+
// Report the SHAPE, not every key: a thousand orphans under one prefix is one problem.
|
|
140
|
+
const shape = k.replace(/[0-9a-f]{8,}/gi, "<hash>").replace(/[^:/]+@[^:/]+/g, "<address>");
|
|
141
|
+
unaccounted.set(shape, (unaccounted.get(shape) || 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
for (const [shape, n] of unaccounted) {
|
|
144
|
+
problems.push(`live key not in the inventory: ${shape}${n > 1 ? ` (${n} of them)` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
if (!problems.length && !quiet) {
|
|
147
|
+
console.log(`state-inventory: OK — all ${keys.length} live key(s) are accounted for`);
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
for (const rel of SOURCES) {
|
|
152
|
+
const abs = path.join(ROOT, rel);
|
|
153
|
+
if (!fs.existsSync(abs)) continue;
|
|
154
|
+
for (const [lit, line] of keysInSource(fs.readFileSync(abs, "utf8"))) {
|
|
155
|
+
const ids = accountedBy(lit);
|
|
156
|
+
if (!ids.length) {
|
|
157
|
+
problems.push(`${rel}:${line}: "${lit}" is not in the inventory. Add it to src/state-inventory.mjs with a destination and a reason — including "not a store key", if that is what it is.`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const id of ids) seen.add(id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// And the other direction: an entry nothing names any more is an entry to delete, or a
|
|
164
|
+
// family that quietly stopped being written and is about to be forgotten.
|
|
165
|
+
for (const e of STATE_INVENTORY) {
|
|
166
|
+
if (!seen.has(e.id)) {
|
|
167
|
+
problems.push(`the inventory lists "${e.id}" but nothing in the engine names it. Delete the entry, or find out what stopped writing it.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── A THIRD DIRECTION: does what the inventory PROMISES actually have somewhere to go? ──
|
|
172
|
+
//
|
|
173
|
+
// The two checks above keep the list and the code agreeing about what EXISTS. Neither
|
|
174
|
+
// asks whether a family the inventory sends to the workspace object can actually be
|
|
175
|
+
// stored there. `mail:suppressed` was `to: "workspace"` for weeks with no table to land
|
|
176
|
+
// in, and its own entry says dropping it "breaks that promise silently" — a copy would
|
|
177
|
+
// have run, reported success, and left an instance ready to resume mailing addresses a
|
|
178
|
+
// provider had already told it to stop mailing.
|
|
179
|
+
//
|
|
180
|
+
// A family is landable when the overlay accessor knows it (content), when the identity
|
|
181
|
+
// translation reads it, or when it is declared unmapped WITH A REASON. The third is not
|
|
182
|
+
// a loophole: it is the difference between a decision somebody made and a gap nobody saw.
|
|
183
|
+
const overlayDocs = new Set();
|
|
184
|
+
{
|
|
185
|
+
const src = fs.readFileSync(path.join(ROOT, "src/_worker.js"), "utf8");
|
|
186
|
+
const block = /const OVERLAY_KV_KEYS = Object\.freeze\(\{([\s\S]*?)\n\}\);/.exec(src);
|
|
187
|
+
if (!block) {
|
|
188
|
+
problems.push("could not find OVERLAY_KV_KEYS in src/_worker.js — this check reads it to know which families the overlay accessor handles, so its shape moving is a problem in itself.");
|
|
189
|
+
} else {
|
|
190
|
+
for (const m of block[1].matchAll(/doc:\s*"([^"]+)"/g)) overlayDocs.add(m[1]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const landsInOverlay = (id) =>
|
|
194
|
+
[...overlayDocs].some((d) => d === id || `${d}:` === id || id.startsWith(`${d}:`));
|
|
195
|
+
|
|
196
|
+
for (const e of STATE_INVENTORY) {
|
|
197
|
+
if (e.to !== "workspace") continue;
|
|
198
|
+
if (landsInOverlay(e.id)) continue;
|
|
199
|
+
if (IDENTITY_KV_FAMILIES.includes(e.id)) continue;
|
|
200
|
+
if (Object.prototype.hasOwnProperty.call(UNMAPPED_WORKSPACE_FAMILIES, e.id)) continue;
|
|
201
|
+
problems.push(
|
|
202
|
+
`"${e.id}" is to: "workspace" and has nowhere to land. It is not an overlay family, ` +
|
|
203
|
+
`src/kv-identity.mjs does not read it, and it is not declared in UNMAPPED_WORKSPACE_FAMILIES. ` +
|
|
204
|
+
`A copy would run, report success, and silently omit it. Give it a home or declare it unmapped with a reason.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (!problems.length && !quiet) {
|
|
208
|
+
console.log(`state-inventory: OK — ${STATE_INVENTORY.length} entries, every one named by the engine and every named key accounted for`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (argv.includes("--print")) {
|
|
213
|
+
const w = Math.max(...STATE_INVENTORY.map((e) => e.id.length));
|
|
214
|
+
for (const e of STATE_INVENTORY) {
|
|
215
|
+
console.log(`${e.id.padEnd(w)} ${e.store.padEnd(4)} ${e.kind.padEnd(6)} → ${e.to}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (problems.length) {
|
|
220
|
+
for (const p of problems) console.log(` ${p}`);
|
|
221
|
+
console.log(`\n${problems.length} problem(s). The inventory is the migration's only complete list of what exists; a family missing from it is a family nobody exports.`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|