@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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * augur mark — say what you are about to work on, and read what everyone else is.
4
+ *
5
+ * augur mark what is being worked on right now
6
+ * augur mark <path> [--ttl <s>] leave a mark on it, then start
7
+ * augur mark <path> --clear take yours down early
8
+ * augur mark … --json the same answer, for a tool to read
9
+ *
10
+ * `F-presence-marks`. Nothing anywhere said what was already being worked on. Two agents
11
+ * on two machines, both told to improve the checkout flow, would each open the folder,
12
+ * each edit it, and find out at publish time — where the answer is a fork and a conflict
13
+ * file nobody asked for.
14
+ *
15
+ * ⚠️ THIS IS NOT A LOCK AND IT MUST NEVER BECOME ONE. Marking a path grants nothing and
16
+ * refuses nothing: a marked path can still be edited, published and shipped by anybody, and
17
+ * this command exits 0 whatever it finds. The protocol is social and it is one sentence —
18
+ * READ THE MARKS BEFORE YOU START, LEAVE ONE WHEN YOU DO. What happens when coordination
19
+ * fails anyway is the composed publish's problem, and it settles it on evidence rather than
20
+ * on a claim.
21
+ *
22
+ * ⚠️ AND IT EXPIRES BY ITSELF. A mark carries how long it is good for — ten minutes by
23
+ * default, an hour at the most — and the instance stops reporting it the moment that
24
+ * passes, whether or not anything ever clears it. That is the point rather than a detail:
25
+ * the thing leaving marks is a process that can be killed, and a claim that outlives the
26
+ * claimant is worse than no claim at all. `--clear` is a courtesy, never the guarantee.
27
+ *
28
+ * A path is a URL path (`/checkout/flow/`). A repo folder is accepted and translated, so
29
+ * `checkout/prototypes/flow` — the folder you were just editing — marks the URL it
30
+ * publishes to. The line printed back is always the instance's own spelling.
31
+ */
32
+ import { target, apiClient } from "./lib/store.mjs";
33
+ import { fetchMarks, markPathFor, othersOverlapping, markLine, forAnother } from "./lib/marks.mjs";
34
+
35
+ const C = { dim: "\x1b[2m", ok: "\x1b[32m", warn: "\x1b[33m", off: "\x1b[0m" };
36
+ const log = (m) => console.log(`\x1b[35m[mark]\x1b[0m ${m}`);
37
+ const die = (m) => { console.error(`\x1b[31m[mark] ${m}\x1b[0m`); process.exit(1); };
38
+
39
+ const argv = process.argv.slice(2);
40
+ const flag = (n) => argv.includes(n);
41
+ const opt = (n, d = null) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
42
+ const JSON_OUT = flag("--json");
43
+ const CLEAR = flag("--clear");
44
+ const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1] === "--ttl"));
45
+
46
+ async function main() {
47
+ const { origin, token } = target({ needToken: true });
48
+ const req = apiClient(origin, token);
49
+
50
+ const raw = positional[0] || "";
51
+ const path = raw ? markPathFor(raw) : "";
52
+ if (raw && !path) die(`"${raw}" is not a path anything could be working on.`);
53
+
54
+ // ── list ──────────────────────────────────────────────────────────────────
55
+ if (!path) {
56
+ const marks = await fetchMarks(req);
57
+ if (JSON_OUT) { console.log(JSON.stringify({ origin, marks }, null, 2)); return; }
58
+ if (!marks.length) { log(`${C.dim}nobody is working on anything at ${origin} right now${C.off}`); return; }
59
+ log(`being worked on at ${origin}:`);
60
+ for (const m of marks) console.log(` ${markLine(m)}`);
61
+ console.log(`\n${C.dim}Nothing here stops you. Pick a different path, or go ahead and expect to merge.${C.off}`);
62
+ return;
63
+ }
64
+
65
+ // ── clear ─────────────────────────────────────────────────────────────────
66
+ if (CLEAR) {
67
+ const r = await req("_marks/clear", {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify({ path }),
71
+ });
72
+ const body = await r.json();
73
+ if (JSON_OUT) { console.log(JSON.stringify({ origin, path, ...body }, null, 2)); return; }
74
+ if (body.cleared) log(`${C.ok}${path} released${C.off}`);
75
+ else if (body.reason === "not-yours") log(`${C.dim}${path} is somebody else's mark — left alone. It expires on its own.${C.off}`);
76
+ else log(`${C.dim}no mark of yours on ${path}${C.off}`);
77
+ return;
78
+ }
79
+
80
+ // ── set ───────────────────────────────────────────────────────────────────
81
+ const ttlArg = opt("--ttl");
82
+ const ttl = ttlArg ? Math.round(Number(ttlArg) * 1000) : undefined;
83
+ if (ttlArg && !Number.isFinite(ttl)) die(`--ttl takes seconds, not "${ttlArg}".`);
84
+
85
+ // WHO ELSE IS ALREADY HERE — read BEFORE writing, because that is the whole protocol and
86
+ // an agent that only ever writes is an agent that has learned nothing. Taken before the
87
+ // write and not after: the write replaces the row for this path, so a read afterwards
88
+ // could no longer see the person this is worth telling you about.
89
+ const before = await fetchMarks(req);
90
+
91
+ const r = await req("_marks/set", {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json" },
94
+ body: JSON.stringify({ path, ...(ttl ? { ttl } : {}) }),
95
+ });
96
+ const body = await r.json();
97
+ if (body.error) die(`${origin} refused the mark: ${body.error}`);
98
+
99
+ // Who was already here that is not you. The rule, and the trap inside it, are on
100
+ // `othersOverlapping` — it is a pure function precisely so the trap has a test.
101
+ const overlapping = othersOverlapping(before, body.mark.path, body.mark.personId);
102
+
103
+ if (JSON_OUT) { console.log(JSON.stringify({ origin, overlapping, ...body }, null, 2)); return; }
104
+ log(`${C.ok}${body.mark.path}${C.off} marked ${forAnother(body.mark.expiresIn)}`);
105
+ if (overlapping.length) {
106
+ console.log(`\n ${C.warn}somebody is already working here${C.off}`);
107
+ for (const m of overlapping) console.log(` ${markLine(m)}`);
108
+ console.log(`\n${C.dim}Your mark went down anyway — marks never refuse. Pick a different path, wait it out, or carry on knowing you will be merging.${C.off}`);
109
+ }
110
+ }
111
+
112
+ main().catch((e) => die(e && e.stack ? e.stack : String(e)));
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * migrate-board-keys — give every board document in a KV namespace its workspace segment.
4
+ *
5
+ * CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… AUGUR_KV_NS=… \
6
+ * node scripts/migrate-board-keys.mjs --workspace <id> [--apply] [--json]
7
+ *
8
+ * A ONE-SHOT, and the least important half of the change it belongs to. `board:<path>`
9
+ * becomes `board:<workspace>:<path>` on the deploy that moves an instance's canvas rooms
10
+ * into its own worker; the worker READS THROUGH on a miss and writes the document back
11
+ * scoped, so every board migrates itself the first time anybody opens it and nothing is
12
+ * ever unreachable. What this script buys is that the migration finishes for the boards
13
+ * nobody opens — a board read once a quarter would otherwise keep a legacy key alive
14
+ * indefinitely, and the fallback can only be retired when nothing depends on it.
15
+ *
16
+ * ⚠️ DRY RUN IS THE DEFAULT. Writing needs `--apply`, spelled out, because the destination
17
+ * is a live namespace and the failure mode is silent: a wrong `--workspace` writes every
18
+ * board under a name nothing resolves to, and the site keeps working (the read-through
19
+ * still finds the legacy key) while a second, stale copy of every board accumulates.
20
+ *
21
+ * IT COPIES AND DOES NOT DELETE, on purpose. The legacy key is left exactly where it is:
22
+ * rolling the deploy back has to leave the boards reachable, and a migration that has
23
+ * removed the thing it migrated is not reversible. Sweeping the legacy keys is a separate
24
+ * decision for a separate day, once the read-through has been off for longer than anyone's
25
+ * memory of a board.
26
+ *
27
+ * WHAT IT REPORTS, and why the numbers are the point:
28
+ * scanned legacy `board:` keys found (a scoped key is not one)
29
+ * identical already present at the scoped key with the SAME bytes — nothing to do
30
+ * differing present at the scoped key with DIFFERENT bytes — SKIPPED and named. The
31
+ * scoped copy is the newer one by construction (only the current code writes
32
+ * it), so overwriting it with the legacy document would revert a live board.
33
+ * copied written (or, on a dry run, would be)
34
+ *
35
+ * A dry run that reports `differing: 0` is the acceptance test: it says the batch is a pure
36
+ * addition, that no board is about to be reverted, and that the read-through and the direct
37
+ * path resolve the same bytes for every board in the namespace.
38
+ *
39
+ * Values are copied VERBATIM as bytes — never parsed, never re-serialised. A board document
40
+ * is JSON today and a migration that parses is a migration that fails on the one document
41
+ * that is not what it expected.
42
+ */
43
+ import { BOARD_PREFIX, boardKvKey } from "../src/board-key.mjs";
44
+
45
+ const args = process.argv.slice(2);
46
+ const flag = (f) => args.includes(f);
47
+ const opt = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
48
+
49
+ const WORKSPACE = (opt("--workspace") || process.env.AUGUR_WORKSPACE || "").trim();
50
+ const APPLY = flag("--apply");
51
+ const JSON_OUT = flag("--json");
52
+
53
+ const ACC = process.env.CLOUDFLARE_ACCOUNT_ID;
54
+ const TOK = process.env.CLOUDFLARE_API_TOKEN;
55
+ const NS = process.env.AUGUR_KV_NS || process.env.KV_NAMESPACE_ID || process.env.GV_KV_NS;
56
+
57
+ const log = (m) => console.error(`\x1b[36m[board-keys]\x1b[0m ${m}`);
58
+ const die = (m) => { log(`\x1b[31m${m}\x1b[0m`); process.exit(1); };
59
+
60
+ // Overridable so the whole run can be driven against a local stand-in: that a value comes
61
+ // off the wire and back onto it unchanged is exactly what cannot be proven by reading the
62
+ // code, and proving it must not need an account or a network.
63
+ const API_ROOT = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4";
64
+
65
+ /**
66
+ * The migration itself, over an interface of four calls, so the same code runs against the
67
+ * REST API and against an in-memory namespace in the suite.
68
+ *
69
+ * `store` is `{ list(prefix) -> [names], get(name) -> ArrayBuffer|null, put(name, body) }`.
70
+ */
71
+ export async function migrateBoardKeys(store, { workspace, apply = false, onProgress } = {}) {
72
+ if (!workspace) throw new Error("a workspace id is required — the segment is what this writes");
73
+ if (workspace.includes(":")) throw new Error("a workspace id may not contain ':' — it is the segment separator");
74
+ const scopedPrefix = boardKvKey(workspace, "");
75
+ const names = await store.list(BOARD_PREFIX);
76
+ // A scoped key matches the legacy prefix too, so the legacy set is what is left after
77
+ // this workspace's own scoped keys are taken out. Another workspace's scoped keys are
78
+ // NOT excluded here and must not be: a namespace holding two workspaces' boards has
79
+ // never had an unscoped key to migrate, so it will not reach this line with any.
80
+ const legacy = names.filter((n) => n.startsWith(BOARD_PREFIX) && !n.startsWith(scopedPrefix));
81
+ const out = { workspace, apply, scanned: legacy.length, identical: 0, differing: [], copied: [], vanished: [] };
82
+ for (const name of legacy) {
83
+ const path = name.slice(BOARD_PREFIX.length);
84
+ const target = boardKvKey(workspace, path);
85
+ const src = await store.get(name);
86
+ if (src === null || src === undefined) { out.vanished.push(name); continue; }
87
+ const dst = await store.get(target);
88
+ if (dst !== null && dst !== undefined) {
89
+ if (sameBytes(src, dst)) out.identical++;
90
+ else out.differing.push(path);
91
+ continue;
92
+ }
93
+ if (apply) await store.put(target, src);
94
+ out.copied.push(path);
95
+ if (onProgress) onProgress(path);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function sameBytes(a, b) {
101
+ const x = new Uint8Array(a), y = new Uint8Array(b);
102
+ if (x.length !== y.length) return false;
103
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
104
+ return true;
105
+ }
106
+
107
+ /** The REST-API namespace. Reads bytes, writes bytes. */
108
+ export function apiStore({ root = API_ROOT, account, namespace, token }) {
109
+ const base = `${root}/accounts/${account}/storage/kv/namespaces/${namespace}`;
110
+ const H = { authorization: `Bearer ${token}` };
111
+ return {
112
+ async list(prefix) {
113
+ const names = [];
114
+ let cursor = "";
115
+ for (;;) {
116
+ const url = `${base}/keys?limit=1000&prefix=${encodeURIComponent(prefix)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
117
+ const res = await fetch(url, { headers: H });
118
+ const j = await res.json().catch(() => ({}));
119
+ if (!res.ok || !j.success) throw new Error(`list → ${res.status} ${JSON.stringify(j.errors || j).slice(0, 300)}`);
120
+ for (const k of j.result || []) names.push(k.name);
121
+ cursor = (j.result_info && j.result_info.cursor) || "";
122
+ if (!cursor) break;
123
+ }
124
+ return names;
125
+ },
126
+ async get(name) {
127
+ const res = await fetch(`${base}/values/${encodeURIComponent(name)}`, { headers: H });
128
+ if (res.status === 404) return null;
129
+ if (!res.ok) throw new Error(`get ${name} → ${res.status}`);
130
+ return await res.arrayBuffer();
131
+ },
132
+ async put(name, body) {
133
+ const res = await fetch(`${base}/values/${encodeURIComponent(name)}`, {
134
+ method: "PUT", headers: H, body,
135
+ });
136
+ if (!res.ok) throw new Error(`put ${name} → ${res.status}`);
137
+ },
138
+ };
139
+ }
140
+
141
+ // ---- CLI --------------------------------------------------------------------
142
+ const invokedDirectly = process.argv[1] && process.argv[1].endsWith("migrate-board-keys.mjs");
143
+ if (invokedDirectly) {
144
+ if (!ACC || !TOK) die("need CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID");
145
+ if (!NS) die("need AUGUR_KV_NS — the namespace id this instance's worker is bound to");
146
+ if (!WORKSPACE) die("need --workspace <id> — the segment to write. It is instance.json's tenantId, and getting it wrong writes a second copy of every board under a name nothing resolves to.");
147
+
148
+ const store = apiStore({ account: ACC, namespace: NS, token: TOK });
149
+ const res = await migrateBoardKeys(store, { workspace: WORKSPACE, apply: APPLY });
150
+
151
+ if (JSON_OUT) console.log(JSON.stringify({ ...res, copied: res.copied.length, differing: res.differing }, null, 2));
152
+ else {
153
+ log(`${APPLY ? "APPLY" : "DRY RUN"} · workspace "${WORKSPACE}"`);
154
+ log(`scanned ${res.scanned} legacy board keys`);
155
+ log(`identical ${res.identical} · ${APPLY ? "copied" : "would copy"} ${res.copied.length} · vanished ${res.vanished.length}`);
156
+ if (res.differing.length) {
157
+ log(`\x1b[31mdiffering ${res.differing.length} — SKIPPED, the scoped copy is newer:\x1b[0m`);
158
+ for (const p of res.differing.slice(0, 20)) log(` ${p}`);
159
+ } else log("differing 0 — every legacy board matches its scoped copy or has none");
160
+ }
161
+ // A run that skipped something is not a success: the operator has to look before the
162
+ // read-through can be considered finished with.
163
+ if (res.differing.length) process.exit(2);
164
+ }
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * augur migrate — move a workspace from one instance to another, and prove it arrived.
4
+ *
5
+ * augur migrate --from https://old --to https://new [--freeze] [--out <dir>] [--dry-run]
6
+ *
7
+ * `MIG-do-rekey-run`. Five steps, and each one is a command that already exists and is
8
+ * already tested:
9
+ *
10
+ * 1. FREEZE the source (optional but you almost always want it). Writes stop; reads and
11
+ * sign-in do not. See docs/migration-freeze.md for why that is a flag rather than a
12
+ * DNS pull.
13
+ * 2. EXPORT everything — published content AND the roster, invites, publish tokens,
14
+ * statuses, card names, comment threads, boards, pins, canvas images.
15
+ * 3. RESTORE it into the target, content and state.
16
+ * 4. VERIFY by reading the target back through its OWN export and diffing family by
17
+ * family. This is the step that makes the run worth anything: a migration that
18
+ * reports success without reading the far side back has only reported that it sent
19
+ * some requests.
20
+ * 5. Leave the source frozen (deliberately — see below), and print the numbers.
21
+ *
22
+ * WHY IT SHELLS OUT rather than reimplementing any of it: a second copy of the export
23
+ * logic is a second thing that has to stay right, and the one that gets used less is the
24
+ * one that rots. Each step here is the command an operator would run by hand, in order,
25
+ * with its output on screen.
26
+ *
27
+ * RE-RUNNING IS SAFE, and that is the design rather than a happy accident. The export is
28
+ * content-addressed and skips blobs it already has; the restore replaces each family
29
+ * whole; the workspace object's import is one transaction. So a run that dies anywhere —
30
+ * mid-family, mid-blob, mid-anything — is fixed by running it again, and the end state is
31
+ * identical to an uninterrupted run. There is nothing to clean up first and nothing that
32
+ * double-writes.
33
+ *
34
+ * ⚠️ IT DOES NOT THAW THE SOURCE, and it does not touch DNS. Both are deliberate. The
35
+ * source stays read-only until a person has looked at the target and decided; thawing
36
+ * automatically would reopen writes to an instance that is about to stop being the one
37
+ * people reach. Cutting the hostname over is the step that needs a human and an account
38
+ * credential this script does not hold.
39
+ */
40
+ import { spawn } from "node:child_process";
41
+ import path from "node:path";
42
+ import os from "node:os";
43
+ import { mkdtempSync } from "node:fs";
44
+ import { fileURLToPath } from "node:url";
45
+ import { resolveToken, apiClient } from "./lib/store.mjs";
46
+ import { compareFamily } from "./lib/state-compare.mjs";
47
+
48
+ const SCRIPTS = path.dirname(fileURLToPath(import.meta.url));
49
+ const log = (m) => console.error(`\x1b[35m[migrate]\x1b[0m ${m}`);
50
+ const die = (m) => { log(`\x1b[31m${m}\x1b[0m`); process.exit(1); };
51
+
52
+ const args = process.argv.slice(2);
53
+ const flag = (f) => args.includes(f);
54
+ const opt = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
55
+
56
+ const FROM = (opt("--from") || "").replace(/\/+$/, "");
57
+ const TO = (opt("--to") || "").replace(/\/+$/, "");
58
+ const DRY = flag("--dry-run");
59
+ const FREEZE = flag("--freeze");
60
+ if (!FROM || !TO) die("name both ends: --from https://old --to https://new");
61
+ if (FROM === TO) die("--from and --to are the same instance.");
62
+
63
+ // Tokens come from the same file `augur login` writes, one per host, so a migration needs
64
+ // no new credential and no new place to keep one.
65
+ const FROM_TOKEN = process.env.AUGUR_FROM_TOKEN || resolveToken(FROM);
66
+ const TO_TOKEN = process.env.AUGUR_TO_TOKEN || resolveToken(TO);
67
+ if (!FROM_TOKEN) die(`no token for ${FROM} — run \`augur login --origin ${FROM}\`. It needs STAR scope.`);
68
+ if (!TO_TOKEN) die(`no token for ${TO} — run \`augur login --origin ${TO}\`. It needs STAR scope.`);
69
+
70
+ const OUT = opt("--out") || mkdtempSync(path.join(os.tmpdir(), "augur-migrate-"));
71
+
72
+ /** Run one of the existing commands, with its output on screen, and stop on failure. */
73
+ function step(name, script, argv, env) {
74
+ return new Promise((resolve) => {
75
+ log(`\x1b[1m${name}\x1b[0m`);
76
+ const child = spawn(process.execPath, [path.join(SCRIPTS, script), ...argv], {
77
+ stdio: "inherit",
78
+ env: { ...process.env, ...env },
79
+ });
80
+ child.on("close", (code) => {
81
+ if (code !== 0) die(`${name} failed (exit ${code}). Nothing to clean up — fix it and run this again.`);
82
+ resolve();
83
+ });
84
+ });
85
+ }
86
+
87
+ const started = Date.now();
88
+ log(`${FROM} → ${TO}${DRY ? " [dry run]" : ""}`);
89
+ log(`working directory: ${OUT}`);
90
+
91
+ // 1. Freeze. Before the export, never after: a copy taken while writes are still landing
92
+ // is a copy that is already behind by the time it finishes.
93
+ //
94
+ // ⚠️ AND THEN WAIT, because `freeze` returning is not the same as writes having stopped.
95
+ // The worker reads the freeze flag through a per-isolate cache with a 10-second life
96
+ // (`FREEZE_TTL_MS` in src/_worker.js), so an isolate that last looked 9 seconds ago goes
97
+ // on accepting writes for another second, and one that has not looked at all since the
98
+ // flag was set can accept them for the full ten. Those writes land in the workspace
99
+ // being copied AFTER the copy starts reading it — which is exactly the loss the freeze
100
+ // exists to prevent, arriving through the freeze itself.
101
+ //
102
+ // Waiting the cache out is the whole fix and it costs eleven seconds of a migration
103
+ // somebody scheduled. Shortening the TTL instead would put a KV read in front of every
104
+ // write on every instance forever, to save eleven seconds on an operation run by hand.
105
+ //
106
+ // ⚠️ AND A FREEZE DOES NOT STOP CANVAS EDITING. `isFrozenWrite` exempts GET, and a
107
+ // WebSocket upgrade is a GET, so `/__rt` stays open for the whole window: somebody with
108
+ // a board open can go on moving nodes, pasting images and renaming it while this runs,
109
+ // and every one of those edits lands in a room on the instance being retired. The `/__board`
110
+ // KV rail IS frozen, so what they cannot do is make the mirror this export reads catch
111
+ // up. That is why the board step below reads the ROOM and why it is allowed to refuse:
112
+ // an `unstable` read means somebody is editing right now, and the answer is to find them,
113
+ // not to force it.
114
+ const FREEZE_SETTLE_MS = 11_000;
115
+ if (FREEZE && !DRY) {
116
+ await step("freeze the source", "freeze.mjs",
117
+ ["--reason", `being moved to ${TO}`],
118
+ { AUGUR_ORIGIN: FROM, AUGUR_TOKEN: FROM_TOKEN });
119
+ log(`waiting ${FREEZE_SETTLE_MS / 1000}s for every isolate's freeze cache to expire — a write accepted now would be copied late or not at all`);
120
+ await new Promise((r) => setTimeout(r, FREEZE_SETTLE_MS));
121
+ }
122
+
123
+ // 2 + 3.
124
+ await step("export everything", "export.mjs",
125
+ ["--out", OUT, "--full", "--history"],
126
+ { AUGUR_ORIGIN: FROM, AUGUR_TOKEN: FROM_TOKEN });
127
+
128
+ await step("restore into the target", "restore.mjs",
129
+ [OUT, "--state", ...(DRY ? ["--dry-run"] : [])],
130
+ { AUGUR_ORIGIN: TO, AUGUR_TOKEN: TO_TOKEN });
131
+
132
+ if (DRY) {
133
+ log("dry run — nothing was written to the target, and the source was not frozen");
134
+ process.exit(0);
135
+ }
136
+
137
+ // 4. THE STEP THAT MAKES THIS WORTH RUNNING. Read the target back through its own export
138
+ // and compare, family by family. A migration that reports success without reading the
139
+ // far side has reported that it sent some requests.
140
+ log("\x1b[1mverify the target against the source\x1b[0m");
141
+ const fromApi = apiClient(FROM, FROM_TOKEN);
142
+ const toApi = apiClient(TO, TO_TOKEN);
143
+ const [before, after] = await Promise.all([
144
+ (await fromApi("_state/export")).json(),
145
+ (await toApi("_state/export")).json(),
146
+ ]);
147
+
148
+ // The judgement lives in lib/state-compare.mjs, where it can be tested without two
149
+ // instances on the far side of a spawned runner. It is STRUCTURAL: the two ends answer
150
+ // the same documents in different key orders (KV insertion order against the workspace
151
+ // object's sorted read), so a bytewise comparison failed correct copies.
152
+
153
+ const diffs = [];
154
+ const blind = [];
155
+ const ids = [...new Set([...Object.keys(before.families), ...Object.keys(after.families)])];
156
+ for (const id of ids) {
157
+ const verdict = compareFamily(id, before.families[id], after.families[id]);
158
+ if (verdict === "differ") diffs.push(id);
159
+ if (verdict === "blind") blind.push(id);
160
+ }
161
+ const missingAssets = (before.assets || []).filter((h) => !(after.assets || []).includes(h));
162
+
163
+ for (const id of ids) {
164
+ const n = Object.keys(before.families[id] || {}).length;
165
+ const bad = diffs.includes(id) || blind.includes(id);
166
+ const note = blind.includes(id)
167
+ ? " \x1b[33mreported absent by one side — a set of documents, so an empty one is {}\x1b[0m"
168
+ : "";
169
+ console.log(` ${bad ? "\x1b[31m✗\x1b[0m" : "\x1b[32m✓\x1b[0m"} ${id.padEnd(22)} ${n} key(s)${note}`);
170
+ }
171
+ console.log(` ${missingAssets.length ? "\x1b[31m✗\x1b[0m" : "\x1b[32m✓\x1b[0m"} ${"canvas images".padEnd(22)} ${(before.assets || []).length}`);
172
+
173
+ if (blind.length) {
174
+ die(`neither end can be judged on ${blind.join(", ")}: one side reported the family ABSENT rather than empty.\n`
175
+ + ` A family that is a set of documents reports an empty set as {}, so absent there means that\n`
176
+ + ` export could not enumerate it — not that it holds nothing. The copy may well be correct and\n`
177
+ + ` this run cannot say so. The source is untouched and still frozen.`);
178
+ }
179
+ if (diffs.length || missingAssets.length) {
180
+ die(`the target does not match the source: ${[...diffs, ...missingAssets.map((h) => `image ${h.slice(0, 8)}`)].join(", ")}.\n`
181
+ + ` The source is untouched and still frozen. Run this again — it is safe to repeat.`);
182
+ }
183
+
184
+ // 4b. THE ONE FAMILY THE COMPARISON ABOVE CANNOT JUDGE, and the reason it gets its own step.
185
+ //
186
+ // ⚠️ `board:` IS A MIRROR ON BOTH SIDES. The export reads the KV document a room writes on
187
+ // a dirty alarm; the target's export reads the copy this migration just wrote there. So the
188
+ // two agree the instant the restore lands, and they agree just as readily when the document
189
+ // is nodes behind the room that owns it — which is the normal state of a live board, not an
190
+ // edge case. Measured on a real instance with nobody editing: the mirror held 21 nodes and
191
+ // the room held 24. A verification that says "every family matches" over that is telling the
192
+ // truth about the copy and the wrong thing about the board, and it is the only step of this
193
+ // command that can be green while data is being dropped.
194
+ //
195
+ // So the boards move over a SOCKET, from the room that owns them into the room that will.
196
+ // `board-snapshot move` is that operation, it reads the truth and proves the seed landed,
197
+ // and it never deletes the source. One process per board so a failure names its board.
198
+ //
199
+ // It can legitimately refuse: a board somebody is editing right now reads as `unstable`,
200
+ // and the freeze does NOT stop that — a WebSocket upgrade is a GET, so `/__rt` is open for
201
+ // the whole window. That refusal is the correct answer and it is why this is a step rather
202
+ // than a footnote.
203
+ //
204
+ // AND IT IS SKIPPED, CORRECTLY, ON A DEPLOYMENT WITH NO ROOMS AT ALL. There, no room ever
205
+ // wrote the mirror, so the KV document IS the board and the copy already carried the whole
206
+ // of it. `/__rt` says which: 501 `realtime-not-configured` from a deployment that binds no
207
+ // rooms and names no realtime origin, 426 `expected-websocket` from one that does. A plain
208
+ // GET is enough to ask, which keeps this off the socket path entirely.
209
+ const hasRooms = async (origin) => {
210
+ try {
211
+ const r = await fetch(`${origin}/__rt?path=/`, { headers: { Accept: "application/json" } });
212
+ return r.status !== 501;
213
+ } catch (e) { return true; } // cannot tell ⇒ do not skip the step that protects the data
214
+ };
215
+
216
+ const boardPaths = Object.keys((before.families || {})["board:"] || {});
217
+ if (boardPaths.length && !(await hasRooms(FROM))) {
218
+ log(`${boardPaths.length} canvas board(s): ${FROM} serves no rooms, so the KV document is the `
219
+ + `whole board and the copy carried it. Nothing to move over a socket.`);
220
+ } else if (boardPaths.length && !(await hasRooms(TO))) {
221
+ // The one case that is neither a pass nor a retry: the source's boards live in rooms and
222
+ // the target has nowhere to put them. Restoring the mirror leaves boards that are as
223
+ // stale as the mirror was and that no room will ever correct.
224
+ die(`${boardPaths.length} board(s) live in rooms on ${FROM}, and ${TO} has no realtime configured.\n`
225
+ + ` What landed there is the KV MIRROR, which is not the board. Configure realtime on the\n`
226
+ + ` target (a ROOMS binding, or rtOrigin in its deploy config) and run this again.`);
227
+ } else if (boardPaths.length) {
228
+ log(`\x1b[1mmove ${boardPaths.length} canvas board(s) from the room, not the mirror\x1b[0m`);
229
+ const failedBoards = [];
230
+ for (const p of boardPaths) {
231
+ const code = await new Promise((resolve) => {
232
+ spawn(process.execPath, [path.join(SCRIPTS, "board-snapshot.mjs"), "move",
233
+ "--from", FROM, "--to", TO, "--path", p], { stdio: "inherit", env: process.env })
234
+ .on("close", resolve);
235
+ });
236
+ if (code !== 0) failedBoards.push(p);
237
+ }
238
+ if (failedBoards.length) {
239
+ die(`${failedBoards.length} board(s) did not move: ${failedBoards.join(", ")}.\n`
240
+ + ` The content and the state DID land — only these boards are still on the source, and\n`
241
+ + ` what ${TO} holds for them is the stale KV mirror, which is not the board. Fix and re-run;\n`
242
+ + ` a board move is safe to repeat and never removes the source.`);
243
+ }
244
+ console.log(` \x1b[32m✓\x1b[0m ${"canvas boards".padEnd(22)} ${boardPaths.length} moved from the room`);
245
+ }
246
+
247
+ const mins = ((Date.now() - started) / 60000).toFixed(1);
248
+ log(`\x1b[32mevery family matches\x1b[0m — ${Object.keys(before.families).length} famil(y/ies), ${(before.assets || []).length} image(s)`);
249
+ console.log("");
250
+ console.log(` ${TO} now holds what ${FROM} holds.`);
251
+ console.log(` Copy took ${mins} minutes. The source is ${FREEZE ? "still FROZEN" : "still accepting writes"}.`);
252
+ console.log("");
253
+ // THE ONE THING THAT DID NOT COME, stated in the success report rather than in a doc.
254
+ // A restore does not replay publish history, so the target starts at v1 per space; saying
255
+ // it here is the difference between a known trade and a discovery mid-incident.
256
+ console.log("\x1b[33m Publish history did not come with it: every space on the target is at");
257
+ console.log(` version 1, so \`augur rollback\` there reaches nothing. The archive is on disk`);
258
+ console.log(` under ${OUT}/versions/ and ${FROM} still holds the live history until it is retired.\x1b[0m`);
259
+ console.log("");
260
+ console.log(" Next, in this order, by hand:");
261
+ console.log(` 1. look at ${TO} and decide`);
262
+ console.log(" 2. point the hostname at it");
263
+ console.log(` 3. \x1b[1maugur thaw\x1b[0m against whichever instance people now reach`);
264
+ console.log("");
265
+ console.log("\x1b[2m Nothing here touches DNS, and nothing thaws the source: both need a person.\x1b[0m");