@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,322 @@
|
|
|
1
|
+
// restore.mjs — put an `augur export` copy back into a bundle store.
|
|
2
|
+
//
|
|
3
|
+
// augur restore <dir> every space in the export
|
|
4
|
+
// … --space <id> one space
|
|
5
|
+
// … --dry-run report what would ship, send nothing
|
|
6
|
+
// … --force overwrite live content that is NEWER than the copy
|
|
7
|
+
// … --allow-unpublish permit taking live public pages down — the copy is
|
|
8
|
+
// the truth, and the target may hold pages it never had
|
|
9
|
+
// … --state ALSO replay the workspace state a `--full` export
|
|
10
|
+
// carries: roster, invites, publish tokens, statuses,
|
|
11
|
+
// card names, boards, comment threads, pins, images
|
|
12
|
+
//
|
|
13
|
+
// A restore is an ordinary publish: blobs first, then one manifest commit per
|
|
14
|
+
// space, so it lands atomically and gets its own version number. It does NOT
|
|
15
|
+
// rewrite history — the versions the store already holds stay where they are, and
|
|
16
|
+
// the restored state arrives on top. That is deliberate: a restore run by mistake
|
|
17
|
+
// is then undone by `rollback`, not by another restore.
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ AND THE COPY'S OWN HISTORY IS NOT REPLAYED EITHER. `augur export --history` walks
|
|
20
|
+
// every retained version's manifest and downloads every blob any of them referenced; this
|
|
21
|
+
// command reads none of it. The target ends up holding ONE version — the restored one —
|
|
22
|
+
// whatever the copy carries, so a workspace that arrives by restore or by `augur migrate`
|
|
23
|
+
// can be rolled back exactly zero publishes. Said out loud at the end of a run rather than
|
|
24
|
+
// discovered on the day somebody needs to roll one back. The archive is still worth
|
|
25
|
+
// taking: the manifests under `versions/` name every file of every past publish and the
|
|
26
|
+
// blobs are all on disk, so a specific past publish can be reconstructed by hand.
|
|
27
|
+
//
|
|
28
|
+
// Provenance survives. The manifest carries `source` (the space repo sha and the
|
|
29
|
+
// dirty flag) through unchanged, so a restored site still reports honestly in
|
|
30
|
+
// /_build.json what it was built from — including that it came from a working tree
|
|
31
|
+
// if it did.
|
|
32
|
+
//
|
|
33
|
+
// Target and token resolve exactly as `augur publish` does; see lib/store.mjs.
|
|
34
|
+
// Recovery walkthrough: `docs/2026-08-09-bundle-store-recovery.md`.
|
|
35
|
+
|
|
36
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
37
|
+
import { existsSync } from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { target, apiClient, buildStamp, CLIENT_PROTOCOL } from "./lib/store.mjs";
|
|
40
|
+
|
|
41
|
+
const log = (msg) => console.error(`\x1b[36m[restore]\x1b[0m ${msg}`);
|
|
42
|
+
const die = (msg) => { log(msg); process.exit(1); };
|
|
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
|
+
// The first bare argument is the directory. Walked by index rather than found by
|
|
48
|
+
// value so a value that happens to equal an option's argument can't confuse it.
|
|
49
|
+
let DIR = null;
|
|
50
|
+
for (let i = 0; i < args.length; i++) {
|
|
51
|
+
if (args[i].startsWith("--")) { if (args[i] === "--space") i++; continue; }
|
|
52
|
+
DIR = args[i];
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
const ONE = opt("--space");
|
|
56
|
+
const DRY = flag("--dry-run");
|
|
57
|
+
const FORCE = flag("--force");
|
|
58
|
+
// Opt-in, and deliberately not implied by the copy having state in it. Replaying the
|
|
59
|
+
// roster and the publish tokens changes WHO CAN GET IN — a different and larger act than
|
|
60
|
+
// putting content back, and the case where somebody wants only the content is real (a
|
|
61
|
+
// content restore onto a workspace whose membership has moved on since).
|
|
62
|
+
const STATE = flag("--state");
|
|
63
|
+
// A restore's commit can legitimately take live pages down — the copy is the truth and
|
|
64
|
+
// the target may hold pages the source never had. Same escape hatch, and the same
|
|
65
|
+
// transport-only field, as publish: nothing is persisted.
|
|
66
|
+
const ALLOW_UNPUBLISH = flag("--allow-unpublish");
|
|
67
|
+
if (!DIR) die("name the export directory: augur restore <dir>");
|
|
68
|
+
if (!existsSync(path.join(DIR, "export.json"))) die(`${DIR} has no export.json — not an augur export.`);
|
|
69
|
+
|
|
70
|
+
// Lists what would go dark and names the way out. A restore that takes pages down is a
|
|
71
|
+
// real case (the copy predates them), which is why the flag exists — and a restore run
|
|
72
|
+
// against the wrong target is the other case, which is why it is not the default.
|
|
73
|
+
function dieUnpublish(id, removed, count) {
|
|
74
|
+
const shown = removed.slice(0, 12);
|
|
75
|
+
die(`${id}: this restore would REMOVE ${count} public page(s) that are live right now:\n` +
|
|
76
|
+
shown.map((p) => ` ${p}`).join("\n") +
|
|
77
|
+
(count > shown.length ? `\n … and ${count - shown.length} more` : "") + "\n\n" +
|
|
78
|
+
` ${id} was not committed. The copy does not carry these pages, so restoring it\n` +
|
|
79
|
+
` takes them off the site — anyone's shared links and embeds for them would start\n` +
|
|
80
|
+
` showing the login page the moment they go. Check this is the target you meant.\n\n` +
|
|
81
|
+
` If you really are taking them down, re-run with --allow-unpublish.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let origin, token;
|
|
85
|
+
try { ({ origin, token } = target()); } catch (e) { die(e.message); }
|
|
86
|
+
const req = apiClient(origin, token);
|
|
87
|
+
|
|
88
|
+
const meta = JSON.parse(await readFile(path.join(DIR, "export.json"), "utf8"));
|
|
89
|
+
log(`${DIR} (taken ${meta.exportedAt} from ${meta.origin}) → ${origin}${DRY ? " [dry run]" : ""}`);
|
|
90
|
+
if (meta.origin && meta.origin !== origin) {
|
|
91
|
+
log(`\x1b[33m⚠ this copy was taken from ${meta.origin}, you are restoring to ${origin}\x1b[0m`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const files = (await readdir(path.join(DIR, "manifests"))).filter((f) => f.endsWith(".json"));
|
|
95
|
+
const ids = ONE ? [ONE] : files.map((f) => f.replace(/\.json$/, ""));
|
|
96
|
+
|
|
97
|
+
// What is live now — so a restore can refuse to bury newer content by accident.
|
|
98
|
+
let live = {};
|
|
99
|
+
try {
|
|
100
|
+
const stamp = await buildStamp(origin);
|
|
101
|
+
live = { ...stamp.spaces, _engine: stamp.engine };
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// ⚠️ NAMED AS A GUARD THAT IS OFF, not as a shrug. An empty store is the expected reason
|
|
104
|
+
// and it is not the only one — a target behind a 500, a wrong origin, a CDN serving an
|
|
105
|
+
// error for `/_build.json` all land here, and in every one of those cases the next few
|
|
106
|
+
// lines will publish over whatever is live without ever comparing dates. "Proceeding" on
|
|
107
|
+
// its own reads as "nothing to worry about", which is true exactly once.
|
|
108
|
+
log("\x1b[33m⚠ could not read the live build stamp at " + origin + " — an EMPTY STORE looks "
|
|
109
|
+
+ "like this, and so does a target that is merely unreachable. The newer-than-this-copy "
|
|
110
|
+
+ "guard is OFF for this run: nothing will stop a restore burying live content.\x1b[0m");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Spaces the TARGET refuses because they are not a workspace's to hold. Today that is one
|
|
114
|
+
// space and one reason: on a deployment that resolves workspaces from the Host, the engine
|
|
115
|
+
// chrome (`_engine`) is a single bundle serving every workspace, so no workspace's own
|
|
116
|
+
// publish token may write it — see `sharedChromeRefusal` in src/_worker.js. A copy taken
|
|
117
|
+
// from a single-workspace instance always carries `_engine`, and `augur migrate` is exactly
|
|
118
|
+
// the command that moves such a copy onto a shared deployment, so this is the ordinary case
|
|
119
|
+
// rather than an error. Named and counted, never silent: a restore that quietly dropped a
|
|
120
|
+
// space would be indistinguishable from a complete one.
|
|
121
|
+
const declined = [];
|
|
122
|
+
const DECLINED_REASON = "chrome-not-writable-here";
|
|
123
|
+
|
|
124
|
+
let restored = 0;
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
const file = path.join(DIR, "manifests", `${id}.json`);
|
|
127
|
+
if (!existsSync(file)) die(`${id}: no manifest in this export.`);
|
|
128
|
+
const m = JSON.parse(await readFile(file, "utf8"));
|
|
129
|
+
const total = Object.keys(m.files || {}).length;
|
|
130
|
+
if (!total) die(`${id}: the exported manifest has no files — refusing to publish an empty space.`);
|
|
131
|
+
|
|
132
|
+
const now = live[id];
|
|
133
|
+
if (now && now.publishedAt && meta.exportedAt && now.publishedAt > meta.exportedAt && !FORCE) {
|
|
134
|
+
// ⚠️ "NEWER" IS NOT THE QUESTION — "DIFFERENT" IS, and the difference is what makes a
|
|
135
|
+
// re-run possible. A restore stamps `publishedAt` at the moment it commits, so its own
|
|
136
|
+
// result is by definition newer than the copy that produced it. Read literally, the date
|
|
137
|
+
// guard therefore fires on the SECOND run of any restore — including the one an operator
|
|
138
|
+
// makes after a run that died halfway, and including `augur migrate`'s, which passes no
|
|
139
|
+
// `--force` and whose header promises re-running is safe. It was not: a migration that
|
|
140
|
+
// failed after committing one space of two could not be repeated at all.
|
|
141
|
+
//
|
|
142
|
+
// So when the date says bury, ask what would be buried. The live manifest's file map
|
|
143
|
+
// against this copy's: identical means live IS this copy and there is nothing to lose.
|
|
144
|
+
// Anything else and the guard stands exactly as it did.
|
|
145
|
+
// Compared on the CONTENT ADDRESSING and nothing else — path → hash, sorted. The rest
|
|
146
|
+
// of a file's record is server-assigned: the commit handler stamps `{by, editedAt}` on
|
|
147
|
+
// every file whose bytes changed, so a live manifest is never field-for-field equal to
|
|
148
|
+
// the copy that produced it even when every byte it serves came from there. Comparing
|
|
149
|
+
// whole records would make this recognise nothing and re-runs would stay broken.
|
|
150
|
+
const addressing = (files) => JSON.stringify(
|
|
151
|
+
Object.keys(files || {}).sort().map((p) => [p, (files[p] || {}).h]),
|
|
152
|
+
);
|
|
153
|
+
let same = false;
|
|
154
|
+
try {
|
|
155
|
+
const liveManifest = await (await req(`${id}/manifest`)).json();
|
|
156
|
+
same = addressing(liveManifest.files) === addressing(m.files);
|
|
157
|
+
} catch (e) { /* cannot read it ⇒ cannot claim it is the same ⇒ refuse below */ }
|
|
158
|
+
if (!same) {
|
|
159
|
+
die(`${id}: live content (published ${now.publishedAt}) is NEWER than this copy (${meta.exportedAt}) ` +
|
|
160
|
+
`and its files DIFFER from it. Restoring would bury it. Re-run with --force if that is what you mean.`);
|
|
161
|
+
}
|
|
162
|
+
log(`${id}: live is newer than this copy but byte-identical to it — this is a re-run, continuing`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Ask the store which blobs it is missing, then send only those. This is also the first
|
|
166
|
+
// request of the space, so it is where a target that will not take this space at all says
|
|
167
|
+
// so — before a single blob is uploaded.
|
|
168
|
+
let check;
|
|
169
|
+
try {
|
|
170
|
+
check = await (await req(`${id}/check`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "content-type": "application/json" },
|
|
173
|
+
body: JSON.stringify({ files: m.files }),
|
|
174
|
+
})).json();
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (!String(e.message).includes(DECLINED_REASON)) throw e;
|
|
177
|
+
// Explicitly asked for ⇒ a refusal is an answer to the question, not a detour around it.
|
|
178
|
+
if (ONE === id) {
|
|
179
|
+
die(`${id}: this target will not take it — the page chrome there is one build shared by `
|
|
180
|
+
+ "every workspace, so no workspace's publish token may write it.");
|
|
181
|
+
}
|
|
182
|
+
log(`\x1b[33m⚠ ${id}: this target serves it from a shared build and will not take a `
|
|
183
|
+
+ "workspace's copy of it — SKIPPED, and nothing else in this copy is affected\x1b[0m");
|
|
184
|
+
declined.push(id);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const missing = [...new Set(check.missing || [])];
|
|
188
|
+
log(`${id}: ${total} files, ${missing.length} blobs to upload, live v${check.liveVersion || 0}`);
|
|
189
|
+
if (DRY) continue;
|
|
190
|
+
|
|
191
|
+
const queue = [...missing];
|
|
192
|
+
let failed = 0;
|
|
193
|
+
await Promise.all(Array.from({ length: 8 }, async () => {
|
|
194
|
+
while (queue.length) {
|
|
195
|
+
const h = queue.pop();
|
|
196
|
+
const blob = path.join(DIR, "blobs", h);
|
|
197
|
+
if (!existsSync(blob)) { failed++; log(`${id}: blob ${h.slice(0, 12)} is not in this export`); continue; }
|
|
198
|
+
const body = await readFile(blob);
|
|
199
|
+
for (let attempt = 0; ; attempt++) {
|
|
200
|
+
try { await req(`${id}/blob/${h}`, { method: "PUT", body }); break; }
|
|
201
|
+
catch (e) {
|
|
202
|
+
if (attempt >= 2) { failed++; log(`${id}: blob ${h.slice(0, 12)} failed: ${e.message}`); break; }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
// Nothing is committed on a partial upload, so the live site is untouched and the
|
|
208
|
+
// run can simply be repeated once the copy is complete.
|
|
209
|
+
if (failed) die(`${id}: ${failed} blob(s) missing or failed — nothing committed, live untouched.`);
|
|
210
|
+
|
|
211
|
+
// Strip the fields the store assigns on commit; everything else (files, routing,
|
|
212
|
+
// space meta, source) is restored verbatim.
|
|
213
|
+
const { version, publishedAt, publishedBy, ...body } = m;
|
|
214
|
+
let res;
|
|
215
|
+
try {
|
|
216
|
+
res = await (await req(`${id}/commit`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "content-type": "application/json" },
|
|
219
|
+
// Same protocol declaration a normal publish makes — a restore is an ordinary
|
|
220
|
+
// publish, so an instance with a floor must be able to judge this client too.
|
|
221
|
+
body: JSON.stringify({ ...body, clientProtocol: CLIENT_PROTOCOL,
|
|
222
|
+
...(ALLOW_UNPUBLISH ? { allowUnpublish: true } : {}) }),
|
|
223
|
+
})).json();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
// The store's unpublish guard, in words rather than a status code — the same guard,
|
|
226
|
+
// and the same way out, a publish gets. Every space committed before this one stays
|
|
227
|
+
// restored; this one and the rest are untouched.
|
|
228
|
+
const refused = /→ 422 (\{.*\})/.exec(String(e && e.message || ""));
|
|
229
|
+
let detail = null;
|
|
230
|
+
try { detail = refused && JSON.parse(refused[1]); } catch (x) { /* not the guard */ }
|
|
231
|
+
if (detail && detail.error === "unpublish-refused") dieUnpublish(id, detail.removed || [], detail.count || 0);
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
log(`${id}: restored as v${res.version}`);
|
|
235
|
+
restored++;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── the workspace state ────────────────────────────────────────────────────
|
|
239
|
+
// After the content, because the content is the thing a half-finished restore most needs
|
|
240
|
+
// to have landed: a workspace with its pages back and its comments missing is recoverable
|
|
241
|
+
// by re-running this, and the other way round is a site serving nothing.
|
|
242
|
+
let stateReport = null;
|
|
243
|
+
let boardPaths = [];
|
|
244
|
+
if (STATE) {
|
|
245
|
+
if (!meta.full) {
|
|
246
|
+
die("--state needs a copy taken with `augur export --full`; this one carries content only.");
|
|
247
|
+
}
|
|
248
|
+
const file = path.join(DIR, "state.json");
|
|
249
|
+
if (!existsSync(file)) die(`${DIR} says it is a full copy but has no state.json — do not trust it.`);
|
|
250
|
+
const doc = JSON.parse(await readFile(file, "utf8"));
|
|
251
|
+
|
|
252
|
+
// The canvas images first. A board that references an image the store does not have
|
|
253
|
+
// renders a hole, and the board doc is what arrives in the same breath.
|
|
254
|
+
const hashes = doc.assets || [];
|
|
255
|
+
// The type each image was served as, recorded by the export beside the bytes. The
|
|
256
|
+
// endpoint takes the type from THIS HEADER and stores anything that is not an image type
|
|
257
|
+
// as `image/jpeg` — so a PUT that sends none silently re-labels every PNG, GIF and WebP
|
|
258
|
+
// on the way in, and content addressing cannot notice because the bytes are right. An
|
|
259
|
+
// older copy has no sidecar; it keeps the old behaviour and says so.
|
|
260
|
+
let types = {};
|
|
261
|
+
try { types = JSON.parse(await readFile(path.join(DIR, "assets.json"), "utf8")); } catch (e) {}
|
|
262
|
+
const untyped = hashes.filter((h) => !types[h]);
|
|
263
|
+
if (untyped.length) {
|
|
264
|
+
log(`\x1b[33m⚠ ${untyped.length} of ${hashes.length} image(s) carry no recorded content type `
|
|
265
|
+
+ `(a copy taken before assets.json existed) — they will be stored as image/jpeg. `
|
|
266
|
+
+ `Re-run \`augur export --full\` against the source to record them.\x1b[0m`);
|
|
267
|
+
}
|
|
268
|
+
let sent = 0, missing = 0;
|
|
269
|
+
for (const h of hashes) {
|
|
270
|
+
const blob = path.join(DIR, "assets", h);
|
|
271
|
+
if (!existsSync(blob)) { missing++; log(`image ${h.slice(0, 12)} is not in this export`); continue; }
|
|
272
|
+
if (DRY) { sent++; continue; }
|
|
273
|
+
try {
|
|
274
|
+
await req(`_state/asset/${h}`, {
|
|
275
|
+
method: "PUT",
|
|
276
|
+
body: await readFile(blob),
|
|
277
|
+
...(types[h] ? { headers: { "content-type": types[h] } } : {}),
|
|
278
|
+
});
|
|
279
|
+
sent++;
|
|
280
|
+
} catch (e) { missing++; log(`image ${h.slice(0, 12)} failed: ${e.message}`); }
|
|
281
|
+
}
|
|
282
|
+
if (missing) die(`${missing} canvas image(s) missing or failed — nothing replayed, live state untouched.`);
|
|
283
|
+
|
|
284
|
+
const families = Object.keys(doc.families || {});
|
|
285
|
+
boardPaths = Object.keys((doc.families || {})["board:"] || {});
|
|
286
|
+
log(`workspace state: ${families.length} famil(y/ies), ${hashes.length} image(s)`);
|
|
287
|
+
if (!DRY) {
|
|
288
|
+
const res = await (await req("_state/import", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "content-type": "application/json" },
|
|
291
|
+
body: JSON.stringify(doc),
|
|
292
|
+
})).json();
|
|
293
|
+
if (!res.ok) die(`the instance refused the state: ${res.reason}${res.failed ? ` (${res.failed.join(", ")})` : ""}`);
|
|
294
|
+
if (res.skipped && res.skipped.length) log(`\x1b[33mskipped (not in the instance's inventory): ${res.skipped.join(", ")}\x1b[0m`);
|
|
295
|
+
stateReport = res;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (DRY) { console.log("(dry run, nothing shipped)"); process.exit(0); }
|
|
300
|
+
console.log(`${origin} ${restored} space(s) restored`
|
|
301
|
+
+ (declined.length ? `, ${declined.length} declined by the target (${declined.join(", ")})` : "")
|
|
302
|
+
+ (stateReport ? `, ${stateReport.written.length} state famil(y/ies) replayed` : ""));
|
|
303
|
+
if (!STATE && meta.full) {
|
|
304
|
+
log("\x1b[33mthis copy also carries the roster, comments, boards and pins — pass --state to replay them\x1b[0m");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── the two things a restore does NOT put back, said every time ──────────────
|
|
308
|
+
// Both are silent by nature: the site comes up, everything anybody looks at is there, and
|
|
309
|
+
// what is missing is only missing the day somebody reaches for it. A line each is cheap.
|
|
310
|
+
if (meta.history) {
|
|
311
|
+
log("\x1b[33m⚠ this copy carries publish HISTORY and a restore does not replay it — "
|
|
312
|
+
+ `${origin} now holds one version per space, so \`augur rollback\` reaches nothing. `
|
|
313
|
+
+ "The archive is intact on disk under versions/.\x1b[0m");
|
|
314
|
+
}
|
|
315
|
+
if (stateReport && boardPaths.length) {
|
|
316
|
+
log(`\x1b[33m⚠ ${boardPaths.length} canvas board(s) came from the KV MIRROR, which lags the `
|
|
317
|
+
+ "room that owns them and has been measured minutes behind. Nothing here read the room. "
|
|
318
|
+
+ "Per board, against the SOURCE, before you trust this:\x1b[0m");
|
|
319
|
+
for (const p of boardPaths) {
|
|
320
|
+
log(` node scripts/board-snapshot.mjs move --from ${meta.origin || "<source>"} --to ${origin} --path ${p}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* review.mjs — read & moderate prototype review comments straight from production.
|
|
4
|
+
*
|
|
5
|
+
* The user just drops pins on the live site (Shift+C → click an element → type).
|
|
6
|
+
* This tool reads them directly from the worker's secret-guarded export endpoint
|
|
7
|
+
* (no site password needed) and can resolve/close threads once addressed.
|
|
8
|
+
*
|
|
9
|
+
* Requires in .env.deploy (gitignored):
|
|
10
|
+
* REVIEW_SITE_URL=https://your-project.pages.dev
|
|
11
|
+
* REVIEW_EXPORT_KEY=<same value set as the Pages REVIEW_EXPORT_KEY secret>
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node scripts/review.mjs # list every thread (rich: anchor, view, position, messages)
|
|
15
|
+
* node scripts/review.mjs --open # only unresolved threads
|
|
16
|
+
* node scripts/review.mjs resolve <path> <id> ["note"] # mark resolved (+ optional reply explaining the fix)
|
|
17
|
+
* node scripts/review.mjs reply <path> <id> "note" # post a reply without resolving
|
|
18
|
+
* node scripts/review.mjs reopen <path> <id> # mark it open again
|
|
19
|
+
* node scripts/review.mjs delete <path> <id> # remove a thread
|
|
20
|
+
*
|
|
21
|
+
* Convention: when resolving, pass a very brief note saying HOW it was fixed — it
|
|
22
|
+
* posts as a "Claude" reply on the thread so the reviewer sees the resolution inline.
|
|
23
|
+
*/
|
|
24
|
+
import { promises as fs } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
29
|
+
|
|
30
|
+
async function loadEnv() {
|
|
31
|
+
const env = { ...process.env };
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fs.readFile(path.join(ROOT, ".env.deploy"), "utf8");
|
|
34
|
+
for (const line of raw.split("\n")) {
|
|
35
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
36
|
+
if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
return env;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fmt(iso) {
|
|
43
|
+
try { return new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }); }
|
|
44
|
+
catch { return iso || ""; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const env = await loadEnv();
|
|
49
|
+
const base = (env.REVIEW_SITE_URL || "").replace(/\/$/, "");
|
|
50
|
+
const key = env.REVIEW_EXPORT_KEY;
|
|
51
|
+
if (!base || !key) {
|
|
52
|
+
console.error("Missing REVIEW_SITE_URL / REVIEW_EXPORT_KEY in .env.deploy.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const exportUrl = `${base}/__review/api/export?key=${encodeURIComponent(key)}`;
|
|
56
|
+
const [cmd, argPath, argId, argNote] = process.argv.slice(2).filter((a) => a !== "--open");
|
|
57
|
+
const openOnly = process.argv.includes("--open");
|
|
58
|
+
|
|
59
|
+
const postOp = async (op) => {
|
|
60
|
+
const res = await fetch(exportUrl, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify(op),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) { console.error(`Failed: ${res.status} ${res.statusText}`); process.exit(1); }
|
|
66
|
+
return res.json();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---- moderation ops (resolve / reopen / delete / reply) ----
|
|
70
|
+
if (cmd === "resolve" || cmd === "reopen" || cmd === "delete" || cmd === "reply") {
|
|
71
|
+
if (!argPath || !argId || (cmd === "reply" && !argNote)) {
|
|
72
|
+
console.error(`Usage: node scripts/review.mjs ${cmd} <path> <id>${cmd === "reply" ? ' "note"' : cmd === "resolve" ? ' ["note"]' : ""}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
// Post the explanation reply first (resolve w/ note, or a bare reply).
|
|
76
|
+
if ((cmd === "resolve" || cmd === "reply") && argNote) {
|
|
77
|
+
await postOp({ path: argPath, op: "reply", id: argId,
|
|
78
|
+
message: { author: "Claude", body: argNote, at: new Date().toISOString() } });
|
|
79
|
+
}
|
|
80
|
+
if (cmd === "reply") {
|
|
81
|
+
console.log(`✓ replied on ${argId} (${argPath}).`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const op = cmd === "delete"
|
|
85
|
+
? { path: argPath, op: "delete", id: argId }
|
|
86
|
+
: { path: argPath, op: "resolve", id: argId, resolved: cmd === "resolve" };
|
|
87
|
+
const data = await postOp(op);
|
|
88
|
+
console.log(`✓ ${cmd} ${argId} on ${argPath}${argNote ? " (+note)" : ""} — ${(data.threads || []).length} thread(s) remain on that page.`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- read ----
|
|
93
|
+
const res = await fetch(exportUrl);
|
|
94
|
+
if (!res.ok) { console.error(`Export failed: ${res.status} ${res.statusText}`); process.exit(1); }
|
|
95
|
+
const data = await res.json();
|
|
96
|
+
const pages = data.pages || {};
|
|
97
|
+
const keys = Object.keys(pages).filter((p) => (pages[p] || []).length).sort();
|
|
98
|
+
console.log(`Review comments — pulled ${fmt(data.generatedAt)} from ${base}\n`);
|
|
99
|
+
const isAnno = (t) => !!t.annotation;
|
|
100
|
+
let total = 0, open = 0, annos = 0;
|
|
101
|
+
if (!keys.length) { console.log("No comments yet."); return; }
|
|
102
|
+
for (const p of keys) {
|
|
103
|
+
// Annotations are always-on dev-delivery notes, not feedback — never list them
|
|
104
|
+
// as actionable. --open hides them; the full list shows them flagged.
|
|
105
|
+
const threads = (pages[p] || []).filter((t) => (openOnly ? (!t.resolved && !isAnno(t)) : true));
|
|
106
|
+
if (!threads.length) continue;
|
|
107
|
+
console.log(`══ ${p}`);
|
|
108
|
+
threads.forEach((t) => {
|
|
109
|
+
total++;
|
|
110
|
+
if (isAnno(t)) annos++; else if (!t.resolved) open++;
|
|
111
|
+
const pos = (t.fx || t.fy)
|
|
112
|
+
? `at ${Math.round(t.fx * 100)}%×${Math.round(t.fy * 100)}% of the element`
|
|
113
|
+
: `page ${Math.round(t.px)},${Math.round(t.py)}`;
|
|
114
|
+
const badge = isAnno(t) ? "📌 ANNOTATION (dev note — do NOT resolve)" : (t.resolved ? "✅" : "🟠");
|
|
115
|
+
console.log(` • ${badge} id=${t.id}`);
|
|
116
|
+
console.log(` module: ${t.sel || "(page)"}`);
|
|
117
|
+
console.log(` view: ${t.view || "(base)"} pin: ${pos}`);
|
|
118
|
+
(t.messages || []).forEach((m) => console.log(` “${m.body}” — ${m.author}, ${fmt(m.at)}`));
|
|
119
|
+
});
|
|
120
|
+
console.log("");
|
|
121
|
+
}
|
|
122
|
+
console.log(`${total} thread(s)${openOnly ? "" : `, ${open} open comment(s), ${annos} annotation(s)`}.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
package/scripts/save.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// augur save — push every changed file in this draft folder. Live at the draft address on
|
|
3
|
+
// return. Exit 1 with the reason when the instance refuses. See docs/drafts-that-land.md §4.
|
|
4
|
+
import { resolveOrigin, resolveToken } from "./lib/store.mjs";
|
|
5
|
+
import { readState, unitClient, doSave } from "./lib/draft.mjs";
|
|
6
|
+
|
|
7
|
+
const die = (m) => { console.error(`\x1b[31m[save]\x1b[0m ${m}`); process.exit(1); };
|
|
8
|
+
const dir = process.cwd();
|
|
9
|
+
const st = readState(dir);
|
|
10
|
+
if (!st) die("not a draft folder — run `augur open <prototype>` first.");
|
|
11
|
+
const origin = st.origin || resolveOrigin();
|
|
12
|
+
const token = resolveToken(origin);
|
|
13
|
+
if (!token) die("no publish token — run `augur connect` once.");
|
|
14
|
+
const client = unitClient({ origin, token, space: st.space, session: st.session });
|
|
15
|
+
const r = await doSave({ client, dir });
|
|
16
|
+
if (!r.ok) {
|
|
17
|
+
if (r.error === "stale-draft" || r.error === "stale-draft-revision") die("this draft moved under you (another process saved to it) — run `augur sync`.");
|
|
18
|
+
die(`save refused: ${r.error || r.status}`);
|
|
19
|
+
}
|
|
20
|
+
if (r.changed.length) console.error(`\x1b[35m[save]\x1b[0m ${r.changed.length} file(s) live at ${origin}${st.address}`);
|
|
21
|
+
console.log(`${origin}${st.address}`);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// shell-lint — a template-drift canary for deploy shells.
|
|
3
|
+
//
|
|
4
|
+
// health.yml asks "is what's LIVE what's in git?" for space content. This asks the
|
|
5
|
+
// same question one layer up: is what a shell is RUNNING what the engine's template
|
|
6
|
+
// says it should be running? Those workflows were hand-authored per shell for a long
|
|
7
|
+
// time, so they drift silently — a fix lands in templates/shell/ and the shells that
|
|
8
|
+
// already had a copy never take it, or a shell edits its own copy and the change is
|
|
9
|
+
// invisible to everyone else.
|
|
10
|
+
//
|
|
11
|
+
// Run it FROM A SHELL, where both halves are already on disk:
|
|
12
|
+
//
|
|
13
|
+
// node engine/scripts/shell-lint.mjs
|
|
14
|
+
//
|
|
15
|
+
// The engine submodule is the shell's pinned engine, so the templates it compares
|
|
16
|
+
// against are the ones that shell is actually on — no fetching, no sha juggling.
|
|
17
|
+
//
|
|
18
|
+
// --shell <path> shell root (default: cwd)
|
|
19
|
+
// --templates <path> template dir (default: <engine>/templates/shell)
|
|
20
|
+
// --strict treat comment-only drift as failure too
|
|
21
|
+
// --quiet only print problems
|
|
22
|
+
//
|
|
23
|
+
// Exit 1 on logic drift (or any drift under --strict). Missing/extra workflows are
|
|
24
|
+
// reported but never fatal: which workflows a shell runs is that shell's call, and a
|
|
25
|
+
// private prototype-delete or reseed workflow has no template by design.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const ENGINE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
32
|
+
const argv = process.argv.slice(2);
|
|
33
|
+
const opt = (f, d) => { const i = argv.indexOf(f); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
|
|
34
|
+
const has = (f) => argv.includes(f);
|
|
35
|
+
|
|
36
|
+
const SHELL = path.resolve(opt("--shell", process.cwd()));
|
|
37
|
+
const TEMPLATES = path.resolve(opt("--templates", path.join(ENGINE, "templates", "shell")));
|
|
38
|
+
const STRICT = has("--strict");
|
|
39
|
+
const QUIET = has("--quiet");
|
|
40
|
+
|
|
41
|
+
const say = (s) => { if (!QUIET) console.log(s); };
|
|
42
|
+
const warn = (s) => console.log(s);
|
|
43
|
+
|
|
44
|
+
// A template line whose VALUE is meant to be replaced before first use. The convention
|
|
45
|
+
// across templates/shell is a `your-…` stand-in (your-site-origin.example,
|
|
46
|
+
// your-pages-project), sometimes with a `# ←` pointer at what to put there; `<angled>`
|
|
47
|
+
// is the older form. A shell that filled one of these in has done the right thing, so
|
|
48
|
+
// a difference on such a line is expected, not drift.
|
|
49
|
+
const isPlaceholder = (line) => /your-[a-z0-9-]+/i.test(line) || /#\s*←/.test(line) || /<[a-z][a-z0-9-]*>/i.test(line);
|
|
50
|
+
|
|
51
|
+
// Not every fill-in has a `your-…` stand-in: some are a real, working value that a
|
|
52
|
+
// shell is nonetheless expected to CHOOSE (engine-bump's TRACK is the case in point —
|
|
53
|
+
// `release` is a legitimate setting, and so is `main`). Those are marked the way the
|
|
54
|
+
// templates already mark them, with a `SET BEFORE USE` note in the comment block
|
|
55
|
+
// directly above. Walk up the contiguous comment run to find it.
|
|
56
|
+
function markedSetBeforeUse(lines, i) {
|
|
57
|
+
for (let k = i - 1; k >= 0; k--) {
|
|
58
|
+
const s = lines[k].trim();
|
|
59
|
+
if (!s.startsWith("#")) return false; // left the comment block without finding it
|
|
60
|
+
if (/SET BEFORE USE/i.test(s)) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Comments and blank lines carry no behaviour. A shell rewording a comment is worth
|
|
66
|
+
// seeing but is not the thing this canary exists to catch, so it is reported apart
|
|
67
|
+
// from logic drift unless --strict.
|
|
68
|
+
const isProse = (line) => { const s = line.trim(); return s === "" || s.startsWith("#"); };
|
|
69
|
+
|
|
70
|
+
// Longest common subsequence over lines — enough to align two versions of the same
|
|
71
|
+
// file so an inserted block does not report every following line as changed.
|
|
72
|
+
function align(a, b) {
|
|
73
|
+
const n = a.length, m = b.length;
|
|
74
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
75
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
76
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
77
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const ops = [];
|
|
81
|
+
let i = 0, j = 0;
|
|
82
|
+
while (i < n && j < m) {
|
|
83
|
+
if (a[i] === b[j]) { ops.push({ t: "=", a: a[i], i, j }); i++; j++; }
|
|
84
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "-", a: a[i], i }); i++; }
|
|
85
|
+
else { ops.push({ t: "+", b: b[j], j }); j++; }
|
|
86
|
+
}
|
|
87
|
+
while (i < n) ops.push({ t: "-", a: a[i], i: i++ });
|
|
88
|
+
while (j < m) ops.push({ t: "+", b: b[j], j: j++ });
|
|
89
|
+
return ops;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A removed line immediately followed by an added line is one line REWRITTEN. That
|
|
93
|
+
// pairing is what lets a filled-in placeholder be recognised: the template side is the
|
|
94
|
+
// line with `your-…` in it, and the shell side is whatever replaced it.
|
|
95
|
+
const yamlKey = (line) => { const m = /^(\s*)([A-Za-z_][\w-]*):/.exec(line); return m ? `${m[1].length}:${m[2]}` : null; };
|
|
96
|
+
|
|
97
|
+
function compare(templateSrc, shellSrc) {
|
|
98
|
+
const tLines = templateSrc.split("\n");
|
|
99
|
+
const ops = align(tLines, shellSrc.split("\n"));
|
|
100
|
+
|
|
101
|
+
// Pair each removal with the addition that REPLACED it, so a rewritten line reads as
|
|
102
|
+
// one change rather than two. Adjacency catches most of it. Same-key pairing catches
|
|
103
|
+
// the rest: when a shell also rewrites the comment block above a setting, the two
|
|
104
|
+
// halves drift apart in the op stream, and `TRACK: release` → `TRACK: main` would
|
|
105
|
+
// otherwise report as an unexplained insertion with nothing to compare it against.
|
|
106
|
+
const dels = [], adds = [];
|
|
107
|
+
for (let k = 0; k < ops.length; k++) {
|
|
108
|
+
const op = ops[k];
|
|
109
|
+
if (op.t === "-") dels.push({ ...op, adjacent: ops[k + 1] && ops[k + 1].t === "+" ? ops[k + 1] : null });
|
|
110
|
+
else if (op.t === "+") adds.push(op);
|
|
111
|
+
}
|
|
112
|
+
const pairs = [], usedAdd = new Set(), usedDel = new Set();
|
|
113
|
+
for (const d of dels) {
|
|
114
|
+
// Adjacency alone is not enough to call two lines the same line rewritten: when a
|
|
115
|
+
// shell expands the comment block above a setting, the template's last comment line
|
|
116
|
+
// sits immediately before the shell's new SETTING, and pairing those two would both
|
|
117
|
+
// mis-report the comment and starve the setting of its real counterpart. Only pair
|
|
118
|
+
// adjacent lines that agree about being a keyed setting.
|
|
119
|
+
if (!d.adjacent || usedAdd.has(d.adjacent.j)) continue;
|
|
120
|
+
if (yamlKey(d.a) !== yamlKey(d.adjacent.b)) continue;
|
|
121
|
+
pairs.push([d, d.adjacent]); usedAdd.add(d.adjacent.j); usedDel.add(d.i);
|
|
122
|
+
}
|
|
123
|
+
for (const d of dels) {
|
|
124
|
+
if (usedDel.has(d.i)) continue;
|
|
125
|
+
const key = yamlKey(d.a);
|
|
126
|
+
if (!key) continue;
|
|
127
|
+
const a = adds.find((x) => !usedAdd.has(x.j) && yamlKey(x.b) === key);
|
|
128
|
+
if (a) { pairs.push([d, a]); usedAdd.add(a.j); usedDel.add(d.i); }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const [d, a] of pairs) {
|
|
133
|
+
if (isPlaceholder(d.a) || markedSetBeforeUse(tLines, d.i)) continue; // filled in / chosen, as intended
|
|
134
|
+
out.push({ kind: isProse(d.a) && isProse(a.b) ? "prose" : "logic", line: d.i + 1, from: d.a, to: a.b });
|
|
135
|
+
}
|
|
136
|
+
for (const d of dels) if (!usedDel.has(d.i)) out.push({ kind: isProse(d.a) ? "prose" : "logic", line: d.i + 1, from: d.a, to: null });
|
|
137
|
+
for (const a of adds) if (!usedAdd.has(a.j)) out.push({ kind: isProse(a.b) ? "prose" : "logic", line: a.j + 1, from: null, to: a.b });
|
|
138
|
+
return out.sort((x, y) => x.line - y.line);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const wfDir = path.join(SHELL, ".github", "workflows");
|
|
142
|
+
if (!fs.existsSync(wfDir)) {
|
|
143
|
+
console.error(`[shell-lint] no .github/workflows/ under ${SHELL} — is this a deploy shell? (pass --shell <path>)`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
if (!fs.existsSync(TEMPLATES)) {
|
|
147
|
+
console.error(`[shell-lint] no templates at ${TEMPLATES} — is the engine submodule checked out? (pass --templates <path>)`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const templates = fs.readdirSync(TEMPLATES).filter((f) => f.endsWith(".yml")).sort();
|
|
152
|
+
const installed = fs.readdirSync(wfDir).filter((f) => f.endsWith(".yml")).sort();
|
|
153
|
+
|
|
154
|
+
let logicDrift = 0, proseDrift = 0;
|
|
155
|
+
const missing = [], extra = [];
|
|
156
|
+
|
|
157
|
+
for (const f of templates) {
|
|
158
|
+
if (!installed.includes(f)) { missing.push(f); continue; }
|
|
159
|
+
const diffs = compare(
|
|
160
|
+
fs.readFileSync(path.join(TEMPLATES, f), "utf8"),
|
|
161
|
+
fs.readFileSync(path.join(wfDir, f), "utf8"),
|
|
162
|
+
);
|
|
163
|
+
const logic = diffs.filter((d) => d.kind === "logic");
|
|
164
|
+
const prose = diffs.filter((d) => d.kind === "prose");
|
|
165
|
+
logicDrift += logic.length;
|
|
166
|
+
proseDrift += prose.length;
|
|
167
|
+
if (!diffs.length) { say(` ok ${f}`); continue; }
|
|
168
|
+
const bad = STRICT ? diffs : logic;
|
|
169
|
+
warn(` ${bad.length ? "DRIFT " : "reworded"} ${f} (${logic.length} logic, ${prose.length} prose)`);
|
|
170
|
+
for (const d of bad.slice(0, 12)) {
|
|
171
|
+
warn(` line ${d.line}:`);
|
|
172
|
+
if (d.from !== null) warn(` template: ${d.from.trim().slice(0, 120)}`);
|
|
173
|
+
if (d.to !== null) warn(` shell: ${d.to.trim().slice(0, 120)}`);
|
|
174
|
+
}
|
|
175
|
+
if (bad.length > 12) warn(` … and ${bad.length - 12} more`);
|
|
176
|
+
}
|
|
177
|
+
for (const f of installed) if (!templates.includes(f)) extra.push(f);
|
|
178
|
+
|
|
179
|
+
say("");
|
|
180
|
+
if (missing.length) warn(` not installed (this shell's call, not an error): ${missing.join(", ")}`);
|
|
181
|
+
if (extra.length) warn(` no template (instance-specific, expected): ${extra.join(", ")}`);
|
|
182
|
+
|
|
183
|
+
const fatal = STRICT ? logicDrift + proseDrift : logicDrift;
|
|
184
|
+
if (fatal) {
|
|
185
|
+
console.log("");
|
|
186
|
+
console.error(`::error::shell-lint: ${fatal} line(s) diverge from the engine's templates — a fix in templates/shell/ has not reached this shell, or this shell edited its copy without sending it upstream.`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
console.log(`shell-lint: clean — ${templates.length - missing.length} workflow(s) match their template${proseDrift ? ` (${proseDrift} comment line(s) reworded; --strict to fail on those)` : ""}.`);
|