@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,1041 @@
1
+ // publish.mjs — incremental direct publish: build a space, upload only the blobs
2
+ // the store doesn't hold, commit its manifest (an atomic pointer flip).
3
+ //
4
+ // node scripts/publish.mjs --space <id> publish one space
5
+ // node scripts/publish.mjs --all publish every space + engine chrome
6
+ // + push the instance config
7
+ // … --dry-run diff against the store, ship nothing
8
+ // … --allow-unpublish permit taking live public pages down
9
+ // … --no-config skip pushing the instance config (chrome-only refresh)
10
+ //
11
+ // Run from a space repo (a cwd with space.json) the --space flag is inferred.
12
+ // Contract (agents rely on this): synchronous, zero prompts, exit code = truth,
13
+ // "<live url> v<version>" is the LAST line on stdout; progress goes to stderr.
14
+ //
15
+ // Auth: AUGUR_TOKEN env (a publish token minted in the Admin panel, or the
16
+ // instance's .env.deploy). Target: AUGUR_ORIGIN env, else the instance's
17
+ // deploy.config.json siteOrigin. Provenance: the space repo's git sha + a dirty
18
+ // flag ride in the manifest (a working-tree publish is visible, never hidden).
19
+
20
+ import { spawn, spawnSync, execFileSync } from "node:child_process";
21
+ import { sanitizeActor } from "../src/provenance.mjs";
22
+ import { existsSync, readFileSync, readdirSync, realpathSync, mkdirSync, writeFileSync } from "node:fs";
23
+ import os from "node:os";
24
+ import { readFile } from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { findShellDir, deployConfig, originHost } from "./lib/instance.mjs";
28
+ import { composePublish, filterLitter, unitPaths } from "./lib/publish-compose.mjs";
29
+ import { collectEvidence } from "./lib/publish-evidence.mjs";
30
+ import { stripVolatileHead } from "./lib/publish-conflict.mjs";
31
+ import { CLIENT_PROTOCOL, buildStamp } from "./lib/store.mjs";
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+ const log = (msg) => console.error(`\x1b[32m[publish]\x1b[0m ${msg}`);
35
+ const die = (msg) => { log(msg); process.exit(1); };
36
+ // Appended to every "publish is not happening this run" failure: the sanctioned
37
+ // meanwhile is the real local shell (chrome, login, canvas — a faithful preview),
38
+ // never a bare file:// path opened directly, which has none of that and is only
39
+ // ever a personal sanity check, not something to hand to anyone else.
40
+ const MEANWHILE = "Meanwhile: `node scripts/dev.mjs` runs a full local preview " +
41
+ "(chrome, login, canvas) — always local-only, not shipped, nobody else can see it. " +
42
+ "Never hand over a file:// path.";
43
+
44
+ const args = process.argv.slice(2);
45
+ const flag = (f) => args.includes(f);
46
+ const opt = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
47
+ const DRY = flag("--dry-run");
48
+ const ALL = flag("--all");
49
+ // --allow-unpublish: this publish MAY remove public pages that are live now.
50
+ // Off by default, because a publish ships one tree as the whole space — so a
51
+ // checkout missing a folder silently takes everyone's shareable links down, and
52
+ // the publisher cannot see it happen (their preview is right, and the gate shows
53
+ // a login page where the page used to be, which reads as locked, not gone).
54
+ // Deleting a prototype on purpose is the case this flag exists for.
55
+ const ALLOW_UNPUBLISH = flag("--allow-unpublish");
56
+ // ⚠️ ASK THE STORE TO RESOLVE A CONCURRENT EDIT, instead of recomposing here and retrying.
57
+ // `C-repo-less-ship` sets it and nothing else does: a folder with no `.git` has no evidence
58
+ // of what its owner edited, so it cannot compose — and without this a concurrent edit is a
59
+ // `stale-base` 409 with no next move. With a repo the decision stays HERE, where the
60
+ // evidence is. See src/publish-compose.mjs and the resolver in src/_worker.js.
61
+ const FORK_ON_CONFLICT = flag("--fork-on-conflict");
62
+ // --takeover: ship this WHOLE tree as the space, skipping per-unit composition —
63
+ // the old (pre-protocol-5) semantics, for repo surgery, heals and migrations.
64
+ // Composition normally makes a stale checkout harmless; takeover says "this tree
65
+ // IS the space now". The unpublish guard and the stale-base CAS still apply, and
66
+ // the `-conflict-` litter filter is never skipped.
67
+ const TAKEOVER = flag("--takeover");
68
+ // --engine: shared chrome + instance config ONLY, never space content. The CI
69
+ // path uses this — its checkout may lag direct publishes, so it must never be
70
+ // able to overwrite newer space content with a stale tree.
71
+ //
72
+ // "Never" is enforced twice over. This flag only ever commits the _engine
73
+ // manifest, AND it runs the build with GV_ENGINE_ONLY=1, which skips space
74
+ // discovery entirely — so a space sitting in the workspace is not merely ignored,
75
+ // it is never read, and the build asserts it emitted nothing but chrome. Until
76
+ // that assertion existed, four files that LOOKED like chrome (the composition
77
+ // graph, the space icon, and the two canvas aggregates) were in fact derived from
78
+ // space content, and every CI run quietly republished them from its pinned tree.
79
+ const ENGINE_ONLY = flag("--engine");
80
+ // --no-config: do NOT push dist/__config/instance.json. For a chrome-only refresh on a
81
+ // hosted deployment, where the credential (a `chrome`-capability token) is refused on the
82
+ // config route anyway — this skips the job so the one command exits clean instead of failing
83
+ // it. There is no single "the instance" whose roster an engine publish speaks for on a
84
+ // multi-tenant worker; see augur-deploy-hosted/CHROME-PUBLISH-GAP.md.
85
+ const NO_CONFIG = flag("--no-config");
86
+ // --no-self-update: keep a stale engine rather than fast-forwarding it. Off by default
87
+ // (see selfUpdate below) because the alternative is telling a person to run git.
88
+ const NO_SELF_UPDATE = flag("--no-self-update") || process.env.AUGUR_NO_SELF_UPDATE === "1";
89
+ // Declared here, not beside selfUpdate: `function` declarations hoist but `let` does
90
+ // not, and maybeRefreshEngine calls selfUpdate from earlier in the file. Leaving it
91
+ // below the callers is a ReferenceError that only fires on a clone that is behind —
92
+ // i.e. exactly the case the whole mechanism exists for.
93
+ let selfUpdateTried = false;
94
+
95
+ function readEnvFile(p) {
96
+ const out = {};
97
+ try {
98
+ for (const line of readFileSync(p, "utf8").split("\n")) {
99
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
100
+ if (m) out[m[1]] = m[2].trim();
101
+ }
102
+ } catch (e) {}
103
+ return out;
104
+ }
105
+ const DEPLOY_ENV = readEnvFile(path.join(ROOT, ".env.deploy"));
106
+
107
+ // Target origin: env, instance file, deploy shell — and for a bare space clone
108
+ // (the collaborator layout, no shell anywhere) the space's own space.json
109
+ // `siteOrigin`: the one public fact a space repo knows about its instance.
110
+ const cwdSpaceOrigin = (() => {
111
+ try { return JSON.parse(readFileSync(path.join(process.cwd(), "space.json"), "utf8")).siteOrigin || ""; }
112
+ catch (e) { return ""; }
113
+ })();
114
+ let ORIGIN = (process.env.AUGUR_ORIGIN || DEPLOY_ENV.AUGUR_ORIGIN ||
115
+ deployConfig(ROOT, originHost(cwdSpaceOrigin)).siteOrigin || cwdSpaceOrigin || "")
116
+ .replace(/\/+$/, "");
117
+ if (!ORIGIN) die("no target origin — set AUGUR_ORIGIN, or add \"siteOrigin\" to space.json.");
118
+ // A WORKSPACE THAT MOVED answers its old address with a redirect. A checkout that still
119
+ // names the old address (space.json not yet pulled, a stale AUGUR_ORIGIN) would otherwise
120
+ // POST through that redirect and read the answer as a bad token. One GET with redirects
121
+ // off says where the workspace lives now; from here on that is the origin.
122
+ async function followMovedOrigin(origin) {
123
+ let r;
124
+ try { r = await fetch(`${origin}/_build.json`, { redirect: "manual", signal: AbortSignal.timeout(6000) }); }
125
+ catch (e) { return origin; }
126
+ if (r.status < 300 || r.status >= 400) return origin;
127
+ let to;
128
+ try { to = new URL(r.headers.get("location") || "", origin); } catch (e) { return origin; }
129
+ if (!to.host || to.host === new URL(origin).host || !/^https?:$/.test(to.protocol)) return origin;
130
+ log(`${new URL(origin).host} now redirects to ${to.host} — publishing there.`);
131
+ return `${to.protocol}//${to.host}`;
132
+ }
133
+ ORIGIN = await followMovedOrigin(ORIGIN);
134
+ // Keep the engine current BEFORE anything below can refuse. Every refusal this file prints
135
+ // is a place a stale clone would stop and never reach the update at all — so a fix to the
136
+ // refusal itself (what to say, what to do instead) would never arrive on the one machine
137
+ // that needs it. Both may re-exec and never return; both are throttled and never fatal.
138
+ await maybeRefreshEngine(); // periodic sweep
139
+ await refreshIfInstanceIsAhead(ORIGIN); // and the exact case: the instance is newer
140
+ // Token: env/instance file, else the saved `augur login` credential for this origin.
141
+ // An AUGUR_TOKEN set to the empty string is a statement, not an absence: "the saved
142
+ // login, not this machine's .env.deploy" — which is how a person's publish is kept apart
143
+ // from a shell's on the same laptop, and how a test isolates itself from the checkout.
144
+ let TOKEN = "AUGUR_TOKEN" in process.env ? process.env.AUGUR_TOKEN : (DEPLOY_ENV.AUGUR_TOKEN || "");
145
+ // A token handed in by the environment belongs to a machine (CI, a shell's .env.deploy).
146
+ // It is never paired for below: a pairing prints a code and waits for a person.
147
+ const TOKEN_FROM_ENV = !!TOKEN;
148
+ const HOST = new URL(ORIGIN).host;
149
+ const TOKENS_FILE = path.join(os.homedir(), ".config", "augur", "tokens.json");
150
+ const readSavedTokens = () => { try { return JSON.parse(readFileSync(TOKENS_FILE, "utf8")); } catch (e) { return {}; } };
151
+ const saveTokens = (all) => {
152
+ try { mkdirSync(path.dirname(TOKENS_FILE), { recursive: true }); writeFileSync(TOKENS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 }); }
153
+ catch (e) {}
154
+ };
155
+ // Read once, and keep it: the refusal below names the OTHER hosts you have tokens for,
156
+ // which is the difference between "you never logged in" and "this workspace moved".
157
+ let savedTokens = readSavedTokens();
158
+ if (!TOKEN) TOKEN = (savedTokens[HOST] || {}).token || "";
159
+
160
+ // A WORKSPACE THAT MOVED HOSTNAME takes its token map with it (the store carries it), so
161
+ // the credential still works — only this file's key is stale. Nobody should have to
162
+ // learn that: when a host we hold a token for now redirects to this one, that redirect
163
+ // is the instance itself saying "same place, new address", and the token follows it.
164
+ // A token is never tried against a host that did not prove the move that way — sending
165
+ // one instance's credential to another would be worse than asking.
166
+ async function tokenAcrossMove() {
167
+ for (const other of Object.keys(savedTokens)) {
168
+ if (other === HOST || !(savedTokens[other] || {}).token) continue;
169
+ for (const scheme of ["https", "http"]) {
170
+ let r;
171
+ try { r = await fetch(`${scheme}://${other}/_build.json`, { redirect: "manual", signal: AbortSignal.timeout(6000) }); }
172
+ catch (e) { continue; }
173
+ if (r.status < 300 || r.status >= 400) break;
174
+ let to = "";
175
+ try { to = new URL(r.headers.get("location") || "", `${scheme}://${other}`).host; } catch (e) {}
176
+ if (to !== HOST) break;
177
+ savedTokens[HOST] = { ...savedTokens[other], movedFrom: other, at: new Date().toISOString() };
178
+ saveTokens(savedTokens);
179
+ log(`${other} now redirects to ${HOST} — the publish token followed the move.`);
180
+ return savedTokens[HOST].token;
181
+ }
182
+ }
183
+ return "";
184
+ }
185
+ if (!TOKEN) TOKEN = await tokenAcrossMove();
186
+
187
+ // NO TOKEN, OR A DEAD ONE, IS NOT A QUESTION FOR THE PERSON. When the instance offers device
188
+ // pairing, the fix is a link and a code in a browser they are already signed in to —
189
+ // no password, nothing typed here — so it runs right here, inside the publish, and the
190
+ // publish carries on. Off for machine tokens (see TOKEN_FROM_ENV), in CI, or by flag.
191
+ const NO_PAIR = flag("--no-pair") || process.env.AUGUR_NO_PAIR === "1" || !!process.env.CI;
192
+ async function pairHere(why) {
193
+ if (NO_PAIR || TOKEN_FROM_ENV) return false;
194
+ let start;
195
+ try {
196
+ start = await fetch(`${ORIGIN}/__publish/_pair/start`, {
197
+ method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: AbortSignal.timeout(8000),
198
+ });
199
+ } catch (e) { return false; }
200
+ if (start.status !== 200) return false; // the instance has not switched pairing on
201
+ log(`${why} — pairing this machine with a browser that is signed in to ${HOST}. Nothing is typed here.`);
202
+ const code = await new Promise((resolve) => {
203
+ spawn(process.execPath, [path.join(ROOT, "scripts", "connect.mjs"), "--origin", ORIGIN],
204
+ { stdio: ["ignore", process.stderr, "inherit"] }).on("close", resolve);
205
+ });
206
+ if (code !== 0) return false;
207
+ savedTokens = readSavedTokens();
208
+ TOKEN = (savedTokens[HOST] || {}).token || "";
209
+ return !!TOKEN;
210
+ }
211
+ if (!TOKEN && !(await pairHere(`no publish token for ${HOST}`))) {
212
+ // NAME THE HOSTS YOU DO HAVE, because the commonest way to arrive here is a MOVE. A
213
+ // workspace that changed hostname leaves a token file full of entries for the old one,
214
+ // and "no publish token" on its own reads as "you never logged in" — which sends
215
+ // somebody looking for a problem they solved months ago.
216
+ const others = Object.keys(savedTokens).filter((h) => h !== HOST);
217
+ die(`no publish token for ${HOST}.\n`
218
+ + (others.length
219
+ ? ` You have one for ${others.join(", ")} — if this workspace moved, that is why.\n`
220
+ + ` Run \`augur login --origin ${ORIGIN}\` to get one for its new home.\n\n ${MEANWHILE}`
221
+ : ` Run \`augur login\` once (it uses your web credentials). ${MEANWHILE}`));
222
+ }
223
+
224
+ // Space discovery: GV_SPACES_ROOT when set (explicit wins, same as build.js),
225
+ // else sibling clones (the maintainer-workspace layout), else ./spaces mounts. cwd
226
+ // inference: running inside a space repo publishes THAT space.
227
+ const PARENT = path.join(ROOT, "..");
228
+ const discoverIn = (root) => {
229
+ // A root that IS a space (space.json at its top) is a one-space site — same
230
+ // semantics as build.js discoverSpaces(). Symlinked space dirs count too.
231
+ try {
232
+ if (existsSync(path.join(root, "space.json"))) return [path.resolve(root)];
233
+ return readdirSync(root, { withFileTypes: true })
234
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".")
235
+ && existsSync(path.join(root, e.name, "space.json")))
236
+ .map((e) => path.join(root, e.name));
237
+ } catch (e) { return []; }
238
+ };
239
+ let spaceDirs = process.env.GV_SPACES_ROOT ? discoverIn(process.env.GV_SPACES_ROOT) : discoverIn(PARENT);
240
+ const usingSiblings = !process.env.GV_SPACES_ROOT && spaceDirs.length > 0;
241
+ const SPACES_ROOT = process.env.GV_SPACES_ROOT || (usingSiblings ? PARENT : path.join(ROOT, "spaces"));
242
+ if (!process.env.GV_SPACES_ROOT && !usingSiblings) spaceDirs = discoverIn(SPACES_ROOT);
243
+ const idOf = (dir) => {
244
+ try { return JSON.parse(readFileSync(path.join(dir, "space.json"), "utf8")).id || path.basename(dir); }
245
+ catch (e) { return path.basename(dir); }
246
+ };
247
+ // Two sibling checkouts of the SAME space id make publish ambiguous and unsafe:
248
+ // byId keeps whichever sorts LAST, so `ship`/`publish` from one clone can push a
249
+ // different clone's (even uncommitted) tree — and the build then emits the space at
250
+ // BOTH / and /<id>/, moving every live URL onto /<id>/ and serving shared links the
251
+ // login page. Refuse it before anything is built: one checkout per space.
252
+ {
253
+ const seenDir = {};
254
+ for (const d of spaceDirs) {
255
+ const id = idOf(d);
256
+ if (seenDir[id]) {
257
+ die(`two checkouts beside this engine both declare space "${id}":\n` +
258
+ ` ${seenDir[id]}\n ${d}\n\n` +
259
+ ` Publishing would be ambiguous — it could ship the wrong tree or split the\n` +
260
+ ` site across / and /${id}/. Keep one checkout per space next to the engine\n` +
261
+ ` (move or remove the other), then re-run.`);
262
+ }
263
+ seenDir[id] = d;
264
+ }
265
+ }
266
+ const byId = Object.fromEntries(spaceDirs.map((d) => [idOf(d), d]));
267
+
268
+ // Running inside a space repo publishes THAT space — the contract documented up
269
+ // top. `byId` above only ever looked at clones sitting next to the engine, so a
270
+ // cwd that is NOT a direct sibling (a nested clone, a worktree, the
271
+ // collab-sandbox layout) never lands in it — which also means the
272
+ // duplicate-checkout guard above never sees it. Left alone, a DIFFERENT sibling
273
+ // clone that happens to declare the SAME id keeps winning byId, and this run
274
+ // would silently build and publish THAT tree instead of the one it's standing
275
+ // in — exit 0, a plausible live URL, nothing to say it shipped the wrong thing.
276
+ // Make the cwd win for its own id. (Reaching the actual build too needs one
277
+ // more step — see BUILD_SPACES_ROOT below; byId alone only fixes what THIS
278
+ // script derives from the source dir: git sha, dirty flag, the shallow-clone
279
+ // guard, conflict-fork naming.)
280
+ // realpath, not path.resolve: cwd and a discovered sibling can be the SAME directory
281
+ // reached through different symlinks (e.g. macOS's /tmp → /private/tmp) — a lexical
282
+ // compare would call that a collision and pay for a mirror copy that changes nothing.
283
+ const real = (p) => { try { return realpathSync(p); } catch (e) { return path.resolve(p); } };
284
+ const cwdSpaceDir = existsSync(path.join(process.cwd(), "space.json")) ? real(process.cwd()) : "";
285
+ let overriddenSiblingId = null;
286
+ if (cwdSpaceDir) {
287
+ const cwdId = idOf(cwdSpaceDir);
288
+ if (byId[cwdId] && real(byId[cwdId]) !== cwdSpaceDir) overriddenSiblingId = cwdId;
289
+ byId[cwdId] = cwdSpaceDir;
290
+ }
291
+
292
+ let targetSpace = opt("--space");
293
+ if (!targetSpace && !ALL && !ENGINE_ONLY && existsSync(path.join(process.cwd(), "space.json"))) {
294
+ targetSpace = idOf(process.cwd());
295
+ }
296
+ if (!targetSpace && !ALL && !ENGINE_ONLY) die("name a target: --space <id>, --all, --engine, or run from a space repo.");
297
+ if (targetSpace && !byId[targetSpace]) die(`unknown space "${targetSpace}" (have: ${Object.keys(byId).join(", ")})`);
298
+
299
+ // ── credential pre-flight ─────────────────────────────────────────────────────
300
+ // A token that merely EXISTS is not a token that WORKS — expired, revoked, or
301
+ // scoped to a different space all look identical to the "no token" check above,
302
+ // but nothing used to catch them until deep inside the actual upload, by which
303
+ // point the build below had already run and left a real artifact on disk. Ping
304
+ // the same auth path the real publish hits (POST .../check, with no files — the
305
+ // same read the "true no-op" branch already relies on, so it costs nothing the
306
+ // classic path wasn't going to spend anyway) before spawning that build, so a
307
+ // bad-but-present token fails exactly as loud and exactly as early as an absent
308
+ // one: nothing is ever built for an agent to mistake for a completed hand-off.
309
+ //
310
+ // An unreachable origin fails exactly the same way, on purpose. It would be
311
+ // tempting to let a network blip through and let the real publish below retry —
312
+ // but that is precisely how the build got a chance to run and leave a local
313
+ // artifact lying around in the first place. There is no way to build here that
314
+ // isn't offline-first, and offline-first is what created the file:// hazard this
315
+ // whole check exists to close, so this errs terminal rather than guessing at
316
+ // "just a blip": if publishing can't be verified as possible, nothing gets built.
317
+ {
318
+ const probeSpace = targetSpace || "_engine";
319
+ for (let attempt = 0; ; attempt++) {
320
+ let r;
321
+ try {
322
+ r = await fetch(`${ORIGIN}/__publish/${probeSpace}/check`, {
323
+ method: "POST",
324
+ headers: { Authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
325
+ body: JSON.stringify({ files: {} }),
326
+ });
327
+ } catch (e) {
328
+ die(`can't reach ${ORIGIN} to verify the publish token (${e.message}) — check your connection ` +
329
+ `or AUGUR_ORIGIN. ${MEANWHILE}`);
330
+ }
331
+ if (r.status !== 401 && r.status !== 403) break;
332
+ // The instance usually knows exactly what is wrong, and saying so beats a list of
333
+ // three guesses. A publish token expires now (30 days by default), so "it's likely
334
+ // expired, revoked, or not scoped" is about to become the message every holder sees
335
+ // on the one failure that has a fix they can run themselves.
336
+ let body = null;
337
+ try { body = await r.json(); } catch (e) {}
338
+ const expired = !!(body && body.error === "token-expired");
339
+ // A dead credential is the one refusal a person can repair on the spot — and where the
340
+ // instance offers pairing, without typing anything here. One attempt: a second refusal
341
+ // after a fresh token is a different problem, and gets the messages below.
342
+ // Any refusal but a viewer's: a token minted under an address the roster no longer
343
+ // lists first, a revoked one, an unexplained 403 — a fresh pairing settles all of them,
344
+ // and a person who cannot sign in cannot approve one, so nothing is bypassed.
345
+ if (attempt === 0 && !(body && body.error === "viewer-role")
346
+ && await pairHere(expired ? "this publish token has EXPIRED"
347
+ : `${ORIGIN} rejected the publish token${body && body.message ? ` (${body.message})` : ""}`)) continue;
348
+ if (expired) {
349
+ die(`this publish token has EXPIRED.\n\n`
350
+ + ` Run \`augur login\` (or \`augur connect\`) again — that is the whole fix, and it\n`
351
+ + ` takes a few seconds. Nothing published is affected.\n\n ${MEANWHILE}`);
352
+ }
353
+ if (body && body.message) {
354
+ die(`publish token rejected by ${ORIGIN}: ${body.message}\n\n ${MEANWHILE}`);
355
+ }
356
+ // An older instance, or a refusal with no reason to give.
357
+ die(`publish token rejected (${r.status}) by ${ORIGIN} — it's likely expired, revoked, or not ` +
358
+ `scoped for "${probeSpace}". Run \`node scripts/login.mjs\` (or \`augur login\`) again. ${MEANWHILE}`);
359
+ }
360
+ }
361
+
362
+ // ── build (single space unless --all; engine chrome always emitted) ──────────
363
+ // The byId override above fixed this script's OWN bookkeeping but not what
364
+ // build.js actually reads: it discovers space CONTENT itself, straight off
365
+ // SPACES_ROOT, which still resolves the collided id to the sibling — and a
366
+ // symlink swap wouldn't reach it either (discoverSpaces() only counts real
367
+ // directories; a symlinked entry reads as "not a space" and drops out
368
+ // silently). So when the cwd actually won something above, hand the build a
369
+ // throwaway mirror instead: a real copy of every directory byId now names,
370
+ // keyed by id, so it reads exactly the tree this script just resolved — same
371
+ // answer for "id → dir" on both sides. Skipped entirely when nothing was
372
+ // overridden (the overwhelmingly common case): SPACES_ROOT is used as-is, no
373
+ // copying, no cost.
374
+ let BUILD_SPACES_ROOT = SPACES_ROOT;
375
+ if (overriddenSiblingId && !ENGINE_ONLY) {
376
+ const { mkdtempSync, cpSync } = await import("node:fs");
377
+ const os = await import("node:os");
378
+ const mirror = mkdtempSync(path.join(os.tmpdir(), "augur-publish-spaces-"));
379
+ const skip = new Set([".git", "node_modules", "dist"]);
380
+ for (const [id, dir] of Object.entries(byId)) {
381
+ cpSync(dir, path.join(mirror, id), {
382
+ recursive: true, dereference: true,
383
+ filter: (src) => !skip.has(path.basename(src)),
384
+ });
385
+ }
386
+ BUILD_SPACES_ROOT = mirror;
387
+ log(`cwd wins space "${overriddenSiblingId}" over a same-id sibling next to the engine — building from a throwaway copy of the resolved tree`);
388
+ }
389
+ const SHELL_DIR = findShellDir(ROOT, (() => { try { return new URL(ORIGIN).host; } catch { return ""; } })());
390
+ const IDENTITY_PATH = process.env.GV_IDENTITY_PATH
391
+ || (SHELL_DIR && existsSync(path.join(SHELL_DIR, "identity.json")) ? path.join(SHELL_DIR, "identity.json") : null);
392
+ const DEPLOY_CONFIG_PATH = process.env.GV_DEPLOY_CONFIG_PATH
393
+ || (SHELL_DIR && existsSync(path.join(SHELL_DIR, "deploy.config.json")) ? path.join(SHELL_DIR, "deploy.config.json") : null);
394
+ const BUILD_ENV = {
395
+ ...process.env,
396
+ GV_SPACES_ROOT: BUILD_SPACES_ROOT,
397
+ ...(IDENTITY_PATH ? { GV_IDENTITY_PATH: IDENTITY_PATH } : {}),
398
+ ...(DEPLOY_CONFIG_PATH ? { GV_DEPLOY_CONFIG_PATH: DEPLOY_CONFIG_PATH } : {}),
399
+ ...(targetSpace ? { GV_ONLY_SPACE: targetSpace } : {}),
400
+ ...(ENGINE_ONLY ? { GV_ENGINE_ONLY: "1" } : {}),
401
+ };
402
+ // Contributor profiles come from the INSTANCE, always — the build only ever reads a
403
+ // file, and a file cannot know two things the live roster does: who has been invited
404
+ // since (the KV roster overlay) and, more importantly, what everyone's face is. A
405
+ // photo is a KV overlay that deliberately beats the config (it belongs to the person,
406
+ // not to the deployment), so a build that trusts the file alone renders initials for
407
+ // everyone who set one — and bakes `/__avatar/<seed-key>` URLs that stop resolving the
408
+ // moment the seed leaves identity.json, blanking the faces on every page published
409
+ // before that. Fetching here means each publish stamps the URLs the instance serves
410
+ // NOW.
411
+ //
412
+ // - no identity file (a bare collaborator clone): the profiles ARE the identity.
413
+ // - identity file (the workspace/shell layout): keep it as the roster of record and
414
+ // take only `avatar` from the instance, so a locally-added user still builds.
415
+ // Either way a failed fetch is non-fatal: the file (or, without one, the engine's
416
+ // empty placeholder) still builds — with the faces it can name.
417
+ //
418
+ // NEVER under --engine. An engine publish builds no space cards, and it PUSHES the
419
+ // identity file as the live instance config — the config must carry each seed
420
+ // avatar's data: URI (what /__avatar/<key> serves), not the /__avatar/ URLs the
421
+ // profiles endpoint derives from it. Merging here would replace every data: URI
422
+ // with its own derived URL, and every seed face on the instance would 404.
423
+ if (!ENGINE_ONLY) try {
424
+ const r = await fetch(`${ORIGIN}/__publish/_instance/profiles`, {
425
+ headers: { Authorization: `Bearer ${TOKEN}` },
426
+ });
427
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
428
+ const { profiles } = await r.json();
429
+ const os = await import("node:os");
430
+ const { writeFile } = await import("node:fs/promises");
431
+ const f = path.join(os.tmpdir(), `augur-profiles-${process.pid}.json`);
432
+ let users = profiles;
433
+ let note = `contributor profiles fetched (${profiles.length}) — card faces preserved`;
434
+ if (IDENTITY_PATH) {
435
+ // Index every address a profile answers to (people commit from several), so the
436
+ // overlay lands on the same entry the build attributes commits to.
437
+ const byEmail = new Map();
438
+ for (const p of profiles) {
439
+ for (const e of [p.email, ...(p.emails || [])]) if (e) byEmail.set(String(e).toLowerCase(), p);
440
+ }
441
+ const local = JSON.parse(readFileSync(IDENTITY_PATH, "utf8"));
442
+ let faces = 0;
443
+ users = local.map((u) => {
444
+ const p = byEmail.get(String(u.email || "").toLowerCase());
445
+ if (!p || !p.avatar) return u;
446
+ faces++;
447
+ return { ...u, avatar: p.avatar };
448
+ });
449
+ note = `contributor photos fetched (${faces}/${local.length}) — live faces on cards`;
450
+ }
451
+ await writeFile(f, JSON.stringify(users));
452
+ BUILD_ENV.GV_IDENTITY_PATH = f;
453
+ log(note);
454
+ } catch (e) {
455
+ log(`profiles unavailable (${e.message}) — publishing with ${IDENTITY_PATH ? "config faces only" : "no editor faces"}`);
456
+ }
457
+
458
+ // Keeping the engine current cannot depend on anyone remembering to do it — that is
459
+ // what already failed. The adversarial-test sandbox sat for weeks on a lineage the
460
+ // public repo had re-cut, and every finding it produced was about code nobody runs,
461
+ // reported with total confidence. Discipline is not a mechanism.
462
+ //
463
+ // So the check rides the command everyone has to run anyway, throttled hard: at most
464
+ // once every 12h per clone, the stamp kept inside .git/ where it can never be committed
465
+ // or show up in `git status`. If the clone is clean and behind, selfUpdate fast-forwards
466
+ // it and re-execs; a dirty or diverged clone is left alone with a note to the agent.
467
+ // Never fatal, never blocking: a failed fetch just means we try again tomorrow.
468
+ const REFRESH_EVERY_MS = 12 * 60 * 60 * 1000;
469
+ async function maybeRefreshEngine() {
470
+ if (NO_SELF_UPDATE || process.env.AUGUR_SELF_UPDATED === "1") return;
471
+ const stamp = path.join(ROOT, ".git", "augur-last-refresh");
472
+ try {
473
+ const { statSync } = await import("node:fs");
474
+ if (Date.now() - statSync(stamp).mtimeMs < REFRESH_EVERY_MS) return;
475
+ } catch (e) { /* never checked before */ }
476
+ try {
477
+ // Touch the stamp FIRST: a fetch that hangs or fails must not make every subsequent
478
+ // publish retry it, which would turn a network problem into a permanent slowdown.
479
+ const { writeFileSync } = await import("node:fs");
480
+ writeFileSync(stamp, new Date().toISOString());
481
+ execFileSync("git", ["-C", ROOT, "fetch", "--quiet", "origin"], { timeout: 10_000, stdio: "ignore" });
482
+ } catch (e) { return; }
483
+ const behind = (() => {
484
+ try {
485
+ return Number(execFileSync("git", ["-C", ROOT, "rev-list", "--count", "HEAD..@{u}"], { encoding: "utf8" }).trim());
486
+ } catch (e) { return 0; }
487
+ })();
488
+ if (behind > 0) selfUpdate(`engine clone is ${behind} commit(s) behind its upstream`);
489
+ }
490
+
491
+ // The targeted version of the same problem, and the one that actually bit.
492
+ //
493
+ // A shell's engine submodule IS auto-bumped, so an instance's worker and shared chrome
494
+ // stay current on their own. But `publish` deliberately does not go through CI — that is
495
+ // the invariant that stops a redeploy overwriting a publish — which means the pages
496
+ // everyone sees are composed by THIS clone, on whoever's machine ran the command.
497
+ // Nothing was keeping that clone current, so a laptop three weeks behind would quietly
498
+ // rebuild every page with three-week-old chrome and publish it over the current ones.
499
+ //
500
+ // The instance already publishes what it is running, ungated: /_build.json engine.sha.
501
+ // If that commit is not in our history, the instance has an engine we do not — so our
502
+ // build would be a downgrade. One public GET, no auth, and it is the exact condition
503
+ // rather than a timer's guess.
504
+ async function refreshIfInstanceIsAhead(origin) {
505
+ if (NO_SELF_UPDATE || process.env.AUGUR_SELF_UPDATED === "1") return;
506
+ let deployed;
507
+ try {
508
+ const stamp = await buildStamp(origin);
509
+ deployed = stamp && stamp.engine && stamp.engine.sha;
510
+ } catch (e) { return; } // stamp unreachable or shapeless: not worth blocking a publish
511
+ if (!deployed || !/^[0-9a-f]{40}$/.test(deployed)) return;
512
+ try {
513
+ // Already in our history (or IS our head) → our build is at least as new. Being
514
+ // AHEAD is normal and fine: an engine change is published before CI catches up.
515
+ execFileSync("git", ["-C", ROOT, "merge-base", "--is-ancestor", deployed, "HEAD"], { stdio: "ignore" });
516
+ return;
517
+ } catch (e) { /* not an ancestor — the instance has something we do not */ }
518
+ selfUpdate(`${origin} runs engine ${deployed.slice(0, 12)}, which this clone does not have`);
519
+ }
520
+
521
+ const started = Date.now();
522
+ async function runBuild(label) {
523
+ log(label || `building ${targetSpace || "all spaces"}…`);
524
+ const code = await new Promise((resolve) => {
525
+ spawn("node", ["build.js"], { cwd: ROOT, env: BUILD_ENV, stdio: ["ignore", 2, 2] }).on("close", resolve);
526
+ });
527
+ if (code !== 0) die(`build failed (exit ${code}). ${MEANWHILE}`);
528
+ }
529
+ await runBuild();
530
+
531
+ // Who forked, for conflict folder names — what git will actually sign as, not the
532
+ // often-unset user.email config (ship.mjs derives it the same way).
533
+ function whoFor(dir) {
534
+ let ident = "";
535
+ try {
536
+ ident = execFileSync("git", ["-C", dir, "var", "GIT_AUTHOR_IDENT"],
537
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
538
+ } catch (e) {}
539
+ const email = (/<([^>]*)>/.exec(ident) || [, ""])[1];
540
+ const n = email.split("@")[0] || (ident.split("<")[0] || "").trim() || process.env.USER || "someone";
541
+ return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "someone";
542
+ }
543
+
544
+ function repoState(dir) {
545
+ const out = { sha: null, dirty: false };
546
+ try {
547
+ out.sha = execFileSync("git", ["-C", dir, "rev-parse", "HEAD"],
548
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
549
+ out.dirty = execFileSync("git", ["-C", dir, "status", "--porcelain"],
550
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().length > 0;
551
+ } catch (e) {}
552
+ return out;
553
+ }
554
+
555
+ // Shallow clones collapse git history into one commit, which silently wrecks
556
+ // everything the build derives from it (per-card "Edited" dates, editor
557
+ // avatars). Refuse to publish content built from one — unshallowing is cheap.
558
+ function refuseShallow(dir) {
559
+ try {
560
+ const shallow = execFileSync("git", ["-C", dir, "rev-parse", "--is-shallow-repository"],
561
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
562
+ if (shallow === "true") {
563
+ die(`${path.basename(dir)} is a SHALLOW clone — edit dates and editor chips would all collapse to the clone moment. Run \`git -C ${dir} fetch --unshallow origin\` first.` + `\n\n ${MEANWHILE}`);
564
+ }
565
+ } catch (e) {}
566
+ }
567
+
568
+ // ── last-committed manifest cache (per origin+space) ─────────────────────────
569
+ // Lets a publish compute its delta locally and ship a small edit as ONE
570
+ // request (commit with blobs inline) instead of check → PUTs → commit. Purely
571
+ // an optimization: a stale/missing cache falls back to the classic protocol.
572
+ const CACHE_DIR = path.join(
573
+ (await import("node:os")).homedir(), ".config", "augur", "published", new URL(ORIGIN).host);
574
+ function readPubCache(id) {
575
+ try { return JSON.parse(readFileSync(path.join(CACHE_DIR, id + ".json"), "utf8")); }
576
+ catch (e) { return null; }
577
+ }
578
+ async function writePubCache(id, data) {
579
+ try {
580
+ const { mkdir, writeFile } = await import("node:fs/promises");
581
+ await mkdir(CACHE_DIR, { recursive: true });
582
+ await writeFile(path.join(CACHE_DIR, id + ".json"), JSON.stringify(data));
583
+ } catch (e) {}
584
+ }
585
+ const INLINE_MAX_BLOBS = 16;
586
+ const INLINE_MAX_BYTES = 900_000;
587
+ // CLIENT_PROTOCOL (scripts/lib/store.mjs) is the version this CLI speaks; check
588
+ // responses echo the worker's. Newer worker → nudge the operator to pull; older
589
+ // worker → the fast path's failure fallback already degrades gracefully. An
590
+ // instance that publishes a floor above ours refuses us outright — see dieOutdated.
591
+ let warnedSkew = false;
592
+
593
+ // ── the digest protocol, per target ──────────────────────────────────────────
594
+ const api = (p) => `${ORIGIN}/__publish/${p}`;
595
+ const auth = { Authorization: `Bearer ${TOKEN}` };
596
+ async function req(url, init = {}) {
597
+ const r = await fetch(url, { ...init, headers: { ...auth, ...(init.headers || {}) } });
598
+ if (!r.ok && r.status !== 204) {
599
+ const body = await r.text().catch(() => "");
600
+ const e = new Error(`${init.method || "GET"} ${url} → ${r.status} ${body.slice(0, 300)}`);
601
+ // Keep the structured refusal alongside the message: callers that know a
602
+ // particular error can explain it properly instead of printing raw JSON.
603
+ e.status = r.status;
604
+ try { e.info = JSON.parse(body); } catch (_) {}
605
+ throw e;
606
+ }
607
+ return r;
608
+ }
609
+
610
+ // The one refusal a person is likely to meet, so it gets a real explanation rather
611
+ // than a status code. Lists what would go dark and names both ways out — fix the
612
+ // checkout (nearly always right) or say you meant it.
613
+ function dieUnpublish(id, removed, count) {
614
+ const shown = removed.slice(0, 12);
615
+ die(`${id}: this publish would REMOVE ${count} public page(s) that are live right now:\n` +
616
+ shown.map((p) => ` ${p}`).join("\n") +
617
+ (count > shown.length ? `\n … and ${count - shown.length} more` : "") + "\n\n" +
618
+ ` Nothing was shipped. A composed publish only removes a page when git shows\n` +
619
+ ` this tree deleted it (or --takeover sent the whole tree), so these removals\n` +
620
+ ` look real — but anyone's shared links and embeds for those pages would start\n` +
621
+ ` showing the login page the moment they go. AGENT: confirm the deletions are\n` +
622
+ ` intended; do not ask the person you are working for to run git.\n\n` +
623
+ ` If you really are taking them down, re-run with --allow-unpublish.\n\n ${MEANWHILE}`);
624
+ }
625
+
626
+ // The instance set a protocol floor and this clone is below it. Say what to do, and
627
+ // say plainly that nothing shipped — a refusal that reads as a transport error is how
628
+ // someone concludes the publish "mostly worked" and walks away.
629
+ // The engine clone is behind what the instance speaks. Fix it here rather than telling
630
+ // anyone to fix it: an editor has no reason to know this repo is a git checkout, and
631
+ // "run `git pull`" is a product failure dressed as a helpful message. The agent working
632
+ // on their behalf should not have to be told either — the default is that it just works.
633
+ //
634
+ // Strictly bounded, because this mutates a checkout nobody asked us to touch:
635
+ // · only a clean tree (no local work to disturb, nothing to merge)
636
+ // · only --ff-only (never a merge, never a rewrite — it cannot invent a conflict)
637
+ // · only with an upstream configured, and only once per run (the re-exec carries a
638
+ // guard so a server that still outranks us cannot loop)
639
+ // Anything else falls through to the caller's message, which addresses the AGENT.
640
+ // Returns true only if it re-executed (in which case this process has already exited).
641
+ function selfUpdate(why) {
642
+ if (NO_SELF_UPDATE || selfUpdateTried || process.env.AUGUR_SELF_UPDATED === "1") return false;
643
+ selfUpdateTried = true;
644
+ const git = (...a) => execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
645
+ try {
646
+ if (git("rev-parse", "--is-inside-work-tree") !== "true") return false;
647
+ if (git("status", "--porcelain")) {
648
+ log(`engine checkout has uncommitted changes — leaving it alone (${why}).`);
649
+ return false;
650
+ }
651
+ git("rev-parse", "--abbrev-ref", "@{u}"); // throws when no upstream is configured
652
+ const before = git("rev-parse", "--short", "HEAD");
653
+ git("pull", "--ff-only");
654
+ const after = git("rev-parse", "--short", "HEAD");
655
+ if (before === after) return false; // already current; the skew is something else
656
+ log(`engine updated ${before} → ${after} (${why}) — retrying the publish.`);
657
+ } catch (e) {
658
+ return false; // not a git clone, no upstream, diverged, offline: caller reports it
659
+ }
660
+ // Re-exec so the NEW code runs: this process is still the old one.
661
+ const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
662
+ cwd: process.cwd(),
663
+ env: { ...process.env, AUGUR_SELF_UPDATED: "1" },
664
+ stdio: "inherit",
665
+ });
666
+ process.exit(r.status ?? 1);
667
+ }
668
+
669
+ function dieOutdated(id, minProtocol) {
670
+ die(`${id}: this instance requires publish protocol ${minProtocol}; this clone speaks ${CLIENT_PROTOCOL}.\n\n` +
671
+ ` Nothing was shipped and the live site is untouched.\n\n` +
672
+ ` An older client does not just miss features — it silently skips guards it has\n` +
673
+ ` never heard of. A pre-3 client sends no baseVersion, so the store cannot tell\n` +
674
+ ` whether this tree is built on what is live, and a stale checkout can revert\n` +
675
+ ` whoever published last without either of you seeing it happen.\n\n` +
676
+ ` This clone could not update itself (uncommitted changes, no upstream, or offline).\n` +
677
+ ` AGENT: bring the engine clone at ${ROOT} up to date and re-run. Do not hand this\n` +
678
+ ` instruction to the person you are working for — they have no reason to know the\n` +
679
+ ` engine is a git checkout.\n\n ${MEANWHILE}`);
680
+ }
681
+
682
+ async function publishOne(id, sourceDir) {
683
+ if (id !== "_engine") refuseShallow(sourceDir);
684
+ const fetchBlob = async (h) => {
685
+ // The tolerant-equality check reads live blobs, sometimes many back to back —
686
+ // a single transient store hiccup must not abort the whole publish. Retry.
687
+ for (let t = 1; t <= 3; t++) {
688
+ try { return Buffer.from(await (await req(api(`${id}/blob/${h}`))).arrayBuffer()); }
689
+ catch (e) { if (t < 3) await new Promise((r) => setTimeout(r, 600 * t)); }
690
+ }
691
+ return null;
692
+ };
693
+ let cached = readPubCache(id);
694
+
695
+ for (let attempt = 1; attempt <= 4; attempt++) {
696
+ const manifest = JSON.parse(await readFile(path.join(ROOT, "dist", "__manifests", id + ".json"), "utf8"));
697
+ // `sanitizeActor` strips the reserved `augur:` namespace. $USER is an environment
698
+ // variable, so without this a real publish could stamp itself `augur:seed` and walk
699
+ // straight past anything that asks "has this workspace published something REAL yet".
700
+ // Enforced at the write because a read-side check has to be remembered by every
701
+ // future consumer of provenance, and this one cannot be.
702
+ manifest.source = { ...repoState(sourceDir), actor: sanitizeActor(process.env.USER) };
703
+ // Hard rule: tree folders named *-conflict-* never ship implicitly — stale fork
704
+ // litter must not re-enter the live site. Real conflicts compose fork prefixes
705
+ // in the MANIFEST below; the tree is never the source of a fork URL.
706
+ const litter = filterLitter(manifest);
707
+ if (litter.length && attempt === 1) {
708
+ log(`${id}: ${litter.length} conflict-fork folder(s) in this tree will NOT publish — fold what matters into the real folder, then delete them`);
709
+ }
710
+ // Rides in the commit body; the store strips it before persisting. Sent only when
711
+ // asked for, so an older instance (which would keep an unknown field) sees nothing.
712
+ if (ALLOW_UNPUBLISH) manifest.allowUnpublish = true;
713
+ const files = manifest.files;
714
+ const total = Object.keys(files).length;
715
+
716
+ // Fast path: with a cache of the last commit, a small delta ships as ONE
717
+ // request — the commit carries its fresh blobs base64-inline. Any failure
718
+ // (stale cache, older worker, sentinel) falls through to the classic path.
719
+ // It only rides when it can carry `baseVersion` (the instance speaks
720
+ // protocol ≥3, learned on any classic publish) and nothing is unresolved:
721
+ // the store then proves live is exactly my last publish, which is the one
722
+ // situation where committing a whole tree cannot revert anyone.
723
+ if (!DRY && !TAKEOVER && attempt === 1 && cached && cached.files
724
+ && (cached.protocol || 0) >= 3 && !(cached.kept || []).length) {
725
+ const had = new Set(Object.values(cached.files).map((f) => f && f.h));
726
+ const freshHashes = new Map(); // hash → one path that has it
727
+ for (const [p, f] of Object.entries(files)) {
728
+ if (f && f.h && !had.has(f.h) && !freshHashes.has(f.h)) freshHashes.set(f.h, p);
729
+ }
730
+ const changed = freshHashes.size > 0
731
+ || Object.keys(files).length !== Object.keys(cached.files).length
732
+ || Object.entries(files).some(([p, f]) => !cached.files[p] || cached.files[p].h !== f.h)
733
+ || (cached.source || {}).sha !== manifest.source.sha
734
+ || !!(cached.source || {}).dirty !== !!manifest.source.dirty;
735
+ const bytes = [...freshHashes.values()].reduce((n, p) => n + files[p].s, 0);
736
+ if (changed && freshHashes.size <= INLINE_MAX_BLOBS && bytes <= INLINE_MAX_BYTES) {
737
+ try {
738
+ const blobs = {};
739
+ for (const [h, p] of freshHashes) {
740
+ blobs[h] = (await readFile(path.join(ROOT, "dist", p.slice(1)))).toString("base64");
741
+ }
742
+ // Omit the blobs key when empty: a pre-inline worker would persist it
743
+ // verbatim into the stored manifest (it only strips what it knows).
744
+ const res = await (await req(api(`${id}/commit`), {
745
+ method: "POST",
746
+ headers: { "content-type": "application/json" },
747
+ body: JSON.stringify({
748
+ ...(freshHashes.size ? { ...manifest, blobs } : manifest),
749
+ baseVersion: cached.version, clientProtocol: CLIENT_PROTOCOL,
750
+ ...(FORK_ON_CONFLICT ? { forkOnConflict: true } : {}),
751
+ }),
752
+ })).json();
753
+ log(`${id}: ${total} files, ${freshHashes.size} blobs inline (${(bytes / 1e6).toFixed(1)} MB), v${res.version}${manifest.source.dirty ? " \x1b[33m[dirty]\x1b[0m" : ""}`);
754
+ await writePubCache(id, { version: res.version, files, source: manifest.source, protocol: cached.protocol, kept: [] });
755
+ return res.version;
756
+ } catch (e) {
757
+ // A refusal is a verdict, not a transport hiccup: the classic path would
758
+ // upload blobs and then be told the same thing. Report it here.
759
+ if (e.info && e.info.error === "cli-outdated") dieOutdated(id, e.info.minProtocol);
760
+ if (e.info && e.info.error === "unpublish-refused") {
761
+ dieUnpublish(id, e.info.removed || [], e.info.count || (e.info.removed || []).length);
762
+ }
763
+ if (e.info && e.info.error === "stale-base") {
764
+ log(`${id}: someone published v${e.info.liveVersion} since my last — reconciling`);
765
+ } else {
766
+ log(`${id}: fast commit declined (${e.message.slice(0, 80)}) — classic path`);
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ let check = await (await req(api(`${id}/check`), {
773
+ method: "POST",
774
+ headers: { "content-type": "application/json" },
775
+ body: JSON.stringify({ files }),
776
+ })).json();
777
+ // Too old to be allowed: stop HERE, before uploading a single blob. The commit
778
+ // would refuse anyway (426 cli-outdated), and the difference between finding out
779
+ // now and finding out after the upload is the whole point of check advertising it.
780
+ if (check.minProtocol && CLIENT_PROTOCOL < check.minProtocol) {
781
+ selfUpdate(`this instance requires publish protocol ${check.minProtocol}`); // exits on success
782
+ dieOutdated(id, check.minProtocol);
783
+ }
784
+ // Behind the instance, but not below its floor (or it has none). This used to read
785
+ // "for the faster path", which is how a correctness problem gets filed as a
786
+ // performance tip and ignored: what an older client actually loses is the GUARD.
787
+ // Below protocol 3 it sends no `baseVersion`, so the store cannot tell whether this
788
+ // tree was built on what is live, and a stale checkout can revert whoever published
789
+ // last with neither of them seeing it happen.
790
+ if (!warnedSkew && check.protocol && check.protocol > CLIENT_PROTOCOL) {
791
+ warnedSkew = true;
792
+ const unguarded = CLIENT_PROTOCOL < 3 && check.protocol >= 3;
793
+ // Try to make the problem go away before describing it.
794
+ selfUpdate(`${ORIGIN} speaks publish protocol ${check.protocol}, this clone ${CLIENT_PROTOCOL}`);
795
+ log(`⚠ this clone speaks publish protocol ${CLIENT_PROTOCOL}; ${ORIGIN} speaks ${check.protocol}, and it could not be updated automatically.`);
796
+ if (unguarded) {
797
+ log(` Publishes from this clone are NOT covered by the revert guard: it cannot tell the`);
798
+ log(` store what version it was built on, so a stale tree can roll back someone else's`);
799
+ log(` work silently.`);
800
+ }
801
+ // Addressed to the agent. Whoever is being helped has no reason to know this is a
802
+ // git checkout, and should never be handed a git command to run.
803
+ log(` AGENT: update the engine clone at ${ROOT} yourself and re-run. Do not ask the`);
804
+ log(` person you are working for to do it.`);
805
+ }
806
+
807
+ // ── Composition (protocol 5): live is the base, not the casualty. ─────────
808
+ // Unless live is empty, exactly my own last publish (version match, nothing
809
+ // kept back last time), or --takeover was given, the manifest that ships is
810
+ // COMPOSED: my build lands per unit only where live's recorded source is a
811
+ // clean commit in my history (fast-forward) or where git shows I edited it;
812
+ // a genuinely concurrent edit keeps theirs at the URL and publishes mine at
813
+ // a -conflict fork prefix, in the manifest only. The working tree is never
814
+ // touched, nothing is adopted, and a stale checkout cannot revert or
815
+ // unpublish anyone — by construction, not by rule (lib/publish-compose.mjs).
816
+ let composed = null;
817
+ if (id !== "_engine" && !TAKEOVER && check.liveVersion !== 0
818
+ && !(cached && cached.version === check.liveVersion && !(cached.kept || []).length)) {
819
+ const live = await (await req(api(`${id}/manifest`))).json();
820
+ const spaceBase = (manifest.space || {}).default ? "" : "/" + id;
821
+ const ev = collectEvidence({ sourceDir, spaceBase, mine: manifest, live });
822
+ const distBytes = async (p) => {
823
+ try { return await readFile(path.join(ROOT, "dist", p.slice(1))); } catch (e) { return null; }
824
+ };
825
+ // "Really different, or just volatile head bytes?" — asked only for the
826
+ // rare contested unit, so the blob reads stay cheap.
827
+ const tolerantEqual = async (u) => {
828
+ const mp = unitPaths(manifest, u), lp = unitPaths(live, u);
829
+ if (mp.length !== lp.length) return false;
830
+ const lh = new Map(lp.map((p) => [p, (live.files[p] || {}).h]));
831
+ for (const p of mp) {
832
+ if (!lh.has(p)) return false;
833
+ if (lh.get(p) === (manifest.files[p] || {}).h) continue;
834
+ if (!/\.html?$/i.test(p)) return false;
835
+ const [a, b] = await Promise.all([distBytes(p), fetchBlob(lh.get(p))]);
836
+ if (!a || !b || stripVolatileHead(a.toString("utf8")) !== stripVolatileHead(b.toString("utf8"))) return false;
837
+ }
838
+ return true;
839
+ };
840
+ composed = await composePublish({
841
+ mine: manifest, live, who: whoFor(sourceDir),
842
+ evidence: ev, ffUnits: ev.ffUnits,
843
+ allowUnpublish: ALLOW_UNPUBLISH, tolerantEqual,
844
+ });
845
+ const s = composed.summary;
846
+ if (s.kept.length) log(`${id}: keeping ${s.kept.length} live unit(s)/file(s) this tree shows no edit for`);
847
+ // A seed unit is the platform's welcome content, not anybody's work: it yields to
848
+ // this tree outright (decided in the composer, where the store decides it too).
849
+ // Information, not a warning — nothing was asked for and nothing was held back.
850
+ for (const u of s.seeded || []) log(`${id}: ${u} replaces the seeded page`);
851
+ // A unit that differs, where nothing proves whose work is newer (live was
852
+ // published from history this clone has never seen), stays live — say so
853
+ // per unit, because "kept" here can mean a committed local edit did NOT
854
+ // ship. The fix is always the same and always agent-side.
855
+ for (const u of ev.unprovable || []) {
856
+ if (s.kept.includes(u)) {
857
+ log(`${id}: ${u} differs from live but neither side is provable — live stays. AGENT: git pull/merge in ${sourceDir}, then ship (it goes out as the union).`);
858
+ }
859
+ }
860
+ for (const u of s.removalBlocked) log(`${id}: ${u} is deleted here, but without --allow-unpublish it stays live`);
861
+ if (ALLOW_UNPUBLISH) composed.manifest.allowUnpublish = true;
862
+ // Re-check with the composed file set: the upload list and the unpublish
863
+ // verdict must be about what actually ships.
864
+ check = await (await req(api(`${id}/check`), {
865
+ method: "POST",
866
+ headers: { "content-type": "application/json" },
867
+ body: JSON.stringify({ files: composed.manifest.files }),
868
+ })).json();
869
+ }
870
+ const ship = composed ? composed.manifest : manifest;
871
+ const shipFiles = ship.files;
872
+
873
+ const missing = new Set(check.missing || []);
874
+ const toUpload = Object.entries(shipFiles).filter(([, f]) => missing.has(f.h));
875
+ // Blobs are content-addressed: many paths can share one hash; upload each once.
876
+ const uniq = new Map();
877
+ for (const [p, f] of toUpload) if (!uniq.has(f.h)) uniq.set(f.h, p);
878
+ const bytes = [...uniq.values()].reduce((n, p) => n + shipFiles[p].s, 0);
879
+ log(`${id}: ${Object.keys(shipFiles).length} files, ${uniq.size} blobs to upload (${(bytes / 1e6).toFixed(1)} MB), live v${check.liveVersion}${ship.source.dirty ? " \x1b[33m[dirty]\x1b[0m" : ""}`);
880
+ // Same verdict the commit will reach, reached before uploading anything — and the
881
+ // only place --dry-run can surface it, since a dry run never commits. `livePrefixes`
882
+ // is absent on older instances; the commit still refuses there.
883
+ if (!ALLOW_UNPUBLISH && check.livePrefixes) {
884
+ const keep = new Set((ship.routing || {}).publicPrefixes || []);
885
+ // A fork the composer retired is not a removal (the store checks the same thing).
886
+ const retired = new Set(composed ? composed.summary.retired || [] : []);
887
+ const removed = [...new Set(check.livePrefixes)].filter((p) => !keep.has(p) && !retired.has(p));
888
+ if (removed.length) dieUnpublish(id, removed, removed.length);
889
+ }
890
+ if (DRY) {
891
+ if (composed) {
892
+ for (const u of composed.summary.shipped) log(`${id}: would ship ${u} (fast-forward)`);
893
+ for (const u of composed.summary.seeded || []) log(`${id}: would ship ${u} (replaces the seeded page)`);
894
+ for (const u of composed.summary.newUnits) log(`${id}: would ship ${u} (new)`);
895
+ for (const f of composed.summary.forked) log(`${id}: would fork — ${f.unit} stays ${f.theirs}'s, yours at ${f.fork}`);
896
+ for (const u of composed.summary.removed) log(`${id}: would remove ${u}`);
897
+ for (const f of composed.summary.retired || []) log(`${id}: would retire ${f} (its edit is at the URL now)`);
898
+ if ((composed.summary.healed || []).length) log(`${id}: would re-stamp ${composed.summary.healed.length} untouched unit(s) as clean at ${String(ship.source.sha || "").slice(0, 7)} (bytes identical)`);
899
+ }
900
+ return null;
901
+ }
902
+
903
+ // True no-op: live already holds these exact files AND this exact provenance
904
+ // (sha + dirty) AND was baked by this same engine — a commit would bump the
905
+ // version without changing anything. When only the ENGINE differs (identical
906
+ // bytes from a newer engine — worker-only changes do this), commit anyway:
907
+ // zero blobs upload, and builtWithEngine advances so the chrome-drift check
908
+ // stays truthful instead of re-flagging this space on every deploy.
909
+ if (check.filesUnchanged && check.liveSource
910
+ && check.liveSource.sha === ship.source.sha
911
+ && !!check.liveSource.dirty === !!ship.source.dirty
912
+ && (!check.liveBuiltWith || !(ship.builtWith && ship.builtWith.engine)
913
+ || check.liveBuiltWith === ship.builtWith.engine)) {
914
+ log(`${id}: unchanged — commit skipped (live v${check.liveVersion})`);
915
+ await writePubCache(id, {
916
+ version: check.liveVersion, files: shipFiles, source: ship.source,
917
+ protocol: check.protocol || 0, kept: composed ? [...composed.summary.kept, ...(composed.summary.seedKept || [])] : [],
918
+ });
919
+ return check.liveVersion;
920
+ }
921
+
922
+ const entries = [...uniq.entries()];
923
+ let done = 0, failed = 0;
924
+ const workers = Array.from({ length: 8 }, async () => {
925
+ while (entries.length) {
926
+ const [h, p] = entries.pop();
927
+ // A composed fork path has no dist file of its own: its bytes live at the
928
+ // real unit's dist path (readMap), or were synthesized (extraBlobs).
929
+ const body = (composed && composed.extraBlobs[h])
930
+ || await readFile(path.join(ROOT, "dist", ((composed && composed.readMap[p]) || p).slice(1)));
931
+ for (let tryN = 0; ; tryN++) {
932
+ try {
933
+ await req(api(`${id}/blob/${h}`), { method: "PUT", body });
934
+ done++;
935
+ if (done % 200 === 0) log(`${id}: ${done}/${uniq.size} blobs…`);
936
+ break;
937
+ } catch (e) {
938
+ if (tryN >= 2) { failed++; log(`${id}: blob ${h.slice(0, 12)} failed: ${e.message}`); break; }
939
+ }
940
+ }
941
+ }
942
+ });
943
+ await Promise.all(workers);
944
+ if (failed) die(`${id}: ${failed} blob uploads failed — nothing committed, live site untouched. ${MEANWHILE}`);
945
+
946
+ let res;
947
+ try {
948
+ res = await (await req(api(`${id}/commit`), {
949
+ method: "POST",
950
+ headers: { "content-type": "application/json" },
951
+ body: JSON.stringify((check.protocol || 0) >= 3
952
+ ? { ...ship, baseVersion: check.liveVersion, clientProtocol: CLIENT_PROTOCOL,
953
+ ...(FORK_ON_CONFLICT ? { forkOnConflict: true } : {}) }
954
+ : { ...ship, clientProtocol: CLIENT_PROTOCOL }),
955
+ })).json();
956
+ } catch (e) {
957
+ // Reachable when the early check couldn't see it (an instance predating
958
+ // `livePrefixes`, or someone else publishing in between).
959
+ if (e.info && e.info.error === "cli-outdated") dieOutdated(id, e.info.minProtocol);
960
+ if (e.info && e.info.error === "unpublish-refused") {
961
+ dieUnpublish(id, e.info.removed || [], e.info.count || (e.info.removed || []).length);
962
+ }
963
+ if (e.info && e.info.error === "stale-base") {
964
+ // Live moved between my check and my commit — recompose against it.
965
+ log(`${id}: live moved to v${e.info.liveVersion} mid-publish — re-evaluating`);
966
+ cached = readPubCache(id);
967
+ continue;
968
+ }
969
+ throw e;
970
+ }
971
+ if (composed) {
972
+ for (const f of composed.summary.forked) {
973
+ log(`${id}: conflict — ${f.unit} stays ${f.theirs}'s; yours is live at ${f.fork} (tree untouched)`);
974
+ }
975
+ for (const f of composed.summary.retired || []) log(`${id}: retired ${f} — its edit is at the URL now`);
976
+ if ((composed.summary.healed || []).length) {
977
+ log(`${id}: ${composed.summary.healed.length} untouched unit(s) re-stamped as clean at ${String(ship.source.sha || "").slice(0, 7)} — bytes identical, so they fast-forward for everyone from here`);
978
+ }
979
+ }
980
+ // ⚠️ THE SERVER CAN RESOLVE ONE TOO, and it says so in the same words. This CLI never
981
+ // asks it to — it has git, so it recomposes and retries above, which keeps the decision
982
+ // where the evidence is. A repo-less publisher has no evidence and asks the store
983
+ // instead (`forkOnConflict`), and when it does the answer comes back on this field.
984
+ // Printing it here means one vocabulary for one event however it was decided.
985
+ for (const f of res.forks || []) {
986
+ log(`${id}: conflict — ${f.unit} stays ${f.theirs}'s; yours is live at ${f.fork} (resolved by the store)`);
987
+ }
988
+ await writePubCache(id, {
989
+ version: res.version, files: shipFiles, source: ship.source,
990
+ protocol: check.protocol || 0,
991
+ // `seedKept` rides here too: a seed page this tree carries with only its decoration
992
+ // changed kept live's bytes, so live is NOT exactly this tree and the fast path
993
+ // (which commits the whole tree) must not run over it next time.
994
+ kept: composed ? [...composed.summary.kept, ...composed.summary.keptDiffer.map(String),
995
+ ...composed.summary.removalBlocked, ...(composed.summary.seedKept || [])] : [],
996
+ });
997
+ return res.version;
998
+ }
999
+ die(`${id}: live kept changing while publishing — re-run. ${MEANWHILE}`);
1000
+ }
1001
+
1002
+ let results = [];
1003
+ if (ALL || ENGINE_ONLY) {
1004
+ // Config, engine chrome and every space are independent pipelines — run them
1005
+ // concurrently (each is its own check/upload/commit chain against its own
1006
+ // manifest); total time is the slowest chain, not the sum.
1007
+ const jobs = [];
1008
+ if (!DRY && !NO_CONFIG) {
1009
+ jobs.push([null, (async () => {
1010
+ const inst = await readFile(path.join(ROOT, "dist", "__config", "instance.json"), "utf8");
1011
+ await req(api("_instance/config"), { method: "POST", headers: { "content-type": "application/json" }, body: inst });
1012
+ log("instance config pushed");
1013
+ })()]);
1014
+ }
1015
+ jobs.push(["_engine", publishOne("_engine", ROOT)]);
1016
+ if (!ENGINE_ONLY) for (const id of Object.keys(byId)) jobs.push([id, publishOne(id, byId[id])]);
1017
+ const settled = await Promise.allSettled(jobs.map(([, p]) => p));
1018
+ const failed = settled.filter((s) => s.status === "rejected");
1019
+ for (const f of failed) log(`FAILED: ${f.reason && f.reason.message}`);
1020
+ // A token that runs out BETWEEN the preflight and here is rare — seconds apart — but
1021
+ // when it happens the raw `403 {"error":"token-expired"}` above is the only clue, and it
1022
+ // is buried in a list of failures. Say it once, in words, at the end.
1023
+ if (failed.some((f) => f.reason && f.reason.info && f.reason.info.error === "token-expired")) {
1024
+ die(`this publish token EXPIRED mid-publish. Run \`augur login\` again and re-run. ${MEANWHILE}`);
1025
+ }
1026
+ if (failed.length) die(`${failed.length} target(s) failed — see above. ${MEANWHILE}`);
1027
+ results = jobs
1028
+ .map(([id], i) => [id, settled[i].value])
1029
+ .filter(([id]) => id !== null);
1030
+ } else {
1031
+ results.push([targetSpace, await publishOne(targetSpace, byId[targetSpace])]);
1032
+ }
1033
+
1034
+ log(`done in ${((Date.now() - started) / 1000).toFixed(1)}s`);
1035
+ if (DRY) { console.log("(dry-run, nothing shipped)"); process.exit(0); }
1036
+ const spaceMeta = (id) => {
1037
+ try { return JSON.parse(readFileSync(path.join(byId[id], "space.json"), "utf8")); } catch (e) { return {}; }
1038
+ };
1039
+ const last = results[results.length - 1];
1040
+ const base = last[0] === "_engine" || (spaceMeta(last[0]) || {}).default ? "" : `/${last[0]}/`;
1041
+ console.log(`${ORIGIN}${base} v${last[1]}`);