@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,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]}`);
|