@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,385 @@
|
|
|
1
|
+
// draft.mjs — the CLI half of drafts that land, as functions over an injected client.
|
|
2
|
+
//
|
|
3
|
+
// Every verb here is `do<Verb>({client, dir, …}) → result`, and the entry points in
|
|
4
|
+
// scripts/{open,save,land,sync,close}.mjs only print the result. The client is the small
|
|
5
|
+
// object `unitClient` returns, so a test can stand in a fake instance and drive the whole
|
|
6
|
+
// loop on disk without a network. See docs/drafts-that-land.md §4 and §7.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { merge3 } from "./merge3.mjs";
|
|
12
|
+
|
|
13
|
+
export const STATE_FILE = ".augur/draft.json";
|
|
14
|
+
export const THEIRS_DIR = ".augur/theirs";
|
|
15
|
+
// The machine-wide registry of open draft folders. `AUGUR_DRAFTS_REGISTRY` exists for the
|
|
16
|
+
// test suite, which must never write into the developer's own home folder.
|
|
17
|
+
export const REGISTRY = path.join(os.homedir(), ".config", "augur", "drafts.json");
|
|
18
|
+
const registryPath = () => process.env.AUGUR_DRAFTS_REGISTRY || REGISTRY;
|
|
19
|
+
|
|
20
|
+
const MIME = {
|
|
21
|
+
html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
|
|
22
|
+
js: "application/javascript; charset=utf-8", mjs: "application/javascript; charset=utf-8",
|
|
23
|
+
json: "application/json; charset=utf-8", txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8",
|
|
24
|
+
svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
|
|
25
|
+
ico: "image/x-icon", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
|
|
26
|
+
mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", wasm: "application/wasm",
|
|
27
|
+
};
|
|
28
|
+
export const mimeOf = (name) => MIME[path.extname(name).slice(1).toLowerCase()] || "application/octet-stream";
|
|
29
|
+
export const hashBytes = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
30
|
+
export const relOf = (unit, urlPath) => urlPath.slice(unit.length);
|
|
31
|
+
export const urlOf = (unit, rel) => unit + rel;
|
|
32
|
+
|
|
33
|
+
export function scanFolder(dir) {
|
|
34
|
+
const out = {};
|
|
35
|
+
const walk = (d, rel) => {
|
|
36
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
37
|
+
if (rel === "" && e.name === ".augur") continue;
|
|
38
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
39
|
+
if (e.isDirectory()) walk(path.join(d, e.name), r);
|
|
40
|
+
else if (e.isFile()) {
|
|
41
|
+
const buf = fs.readFileSync(path.join(d, e.name));
|
|
42
|
+
out[r] = { h: hashBytes(buf), ct: mimeOf(e.name), s: buf.length };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(dir, "");
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readState(dir) {
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, STATE_FILE), "utf8")); } catch (e) { return null; }
|
|
52
|
+
}
|
|
53
|
+
export function writeState(dir, state) {
|
|
54
|
+
fs.mkdirSync(path.join(dir, ".augur"), { recursive: true });
|
|
55
|
+
const p = path.join(dir, STATE_FILE);
|
|
56
|
+
fs.writeFileSync(p + ".tmp", JSON.stringify(state, null, 2));
|
|
57
|
+
fs.renameSync(p + ".tmp", p);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function changesBetween(unit, savedTable, localScan) {
|
|
61
|
+
const changes = [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const [rel, f] of Object.entries(localScan)) {
|
|
64
|
+
const p = urlOf(unit, rel);
|
|
65
|
+
seen.add(p);
|
|
66
|
+
const prior = savedTable[p];
|
|
67
|
+
if (prior && prior.h === f.h) continue;
|
|
68
|
+
changes.push({ path: p, h: f.h, ct: f.ct, s: f.s, baseHash: prior ? prior.h : null });
|
|
69
|
+
}
|
|
70
|
+
for (const [p, f] of Object.entries(savedTable)) if (!seen.has(p)) changes.push({ path: p, baseHash: f.h, delete: true });
|
|
71
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── the machine-wide registry of open draft folders ──────────────────────────
|
|
75
|
+
function readRegistry() {
|
|
76
|
+
try { return JSON.parse(fs.readFileSync(registryPath(), "utf8")); } catch (e) { return { drafts: [] }; }
|
|
77
|
+
}
|
|
78
|
+
function writeRegistry(reg) {
|
|
79
|
+
const p = registryPath();
|
|
80
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
81
|
+
fs.writeFileSync(p + ".tmp", JSON.stringify(reg, null, 2));
|
|
82
|
+
fs.renameSync(p + ".tmp", p);
|
|
83
|
+
}
|
|
84
|
+
export function registryAdd(entry) {
|
|
85
|
+
const reg = readRegistry();
|
|
86
|
+
reg.drafts = reg.drafts.filter((d) => d.dir !== entry.dir).concat([entry]);
|
|
87
|
+
writeRegistry(reg);
|
|
88
|
+
}
|
|
89
|
+
export function registryRemove(dir) {
|
|
90
|
+
const reg = readRegistry();
|
|
91
|
+
reg.drafts = reg.drafts.filter((d) => d.dir !== dir);
|
|
92
|
+
writeRegistry(reg);
|
|
93
|
+
}
|
|
94
|
+
export const registryList = () => readRegistry().drafts;
|
|
95
|
+
|
|
96
|
+
// ── the client ───────────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* `fetchJson(url, init)` is fetch with the bearer header added; the unit routes live at
|
|
99
|
+
* `/__unit/<verb>` and blobs at `/__publish/<space>/blob/<hash>`. Non-2xx answers come back
|
|
100
|
+
* as `{status, ...body}` rather than throwing, because a 409 is an answer, not a failure.
|
|
101
|
+
*/
|
|
102
|
+
export function unitClient({ origin, token, space, session }) {
|
|
103
|
+
const headers = { Authorization: `Bearer ${token}`, "X-Augur-Session": session || "" };
|
|
104
|
+
const post = async (verb, body) => {
|
|
105
|
+
const r = await fetch(`${origin}/__unit/${verb}`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
106
|
+
const out = await r.json().catch(() => ({}));
|
|
107
|
+
return r.ok ? out : { status: r.status, ...out };
|
|
108
|
+
};
|
|
109
|
+
const get = async (verb, unit) => {
|
|
110
|
+
const r = await fetch(`${origin}/__unit/${verb}?unit=${encodeURIComponent(unit)}`, { headers });
|
|
111
|
+
const out = await r.json().catch(() => ({}));
|
|
112
|
+
return r.ok ? out : { status: r.status, ...out };
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
open: (b) => post("open", b), save: (b) => post("save", b), land: (b) => post("land", b),
|
|
116
|
+
sync: (b) => post("sync", b), discard: (b) => post("discard", b), presence: (unit) => get("presence", unit),
|
|
117
|
+
main: (unit) => get("main", unit),
|
|
118
|
+
async blobPut(h, body) {
|
|
119
|
+
const r = await fetch(`${origin}/__publish/${space}/blob/${h}`, { method: "PUT", headers, body });
|
|
120
|
+
if (!r.ok && r.status !== 204) throw new Error(`blob upload failed: ${r.status}`);
|
|
121
|
+
},
|
|
122
|
+
async blobGet(h) {
|
|
123
|
+
const r = await fetch(`${origin}/__publish/${space}/blob/${h}`, { headers });
|
|
124
|
+
if (!r.ok) throw new Error(`blob fetch failed: ${r.status}`);
|
|
125
|
+
return Buffer.from(await r.arrayBuffer());
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── the verbs ────────────────────────────────────────────────────────────────
|
|
131
|
+
// `blobPut`/`blobGet` throw on a non-2xx answer, and a raw `fetch` can reject outright
|
|
132
|
+
// (offline, DNS, a dropped connection mid-transfer) — a transient failure anywhere inside
|
|
133
|
+
// a verb must come back as a result, not an unhandled rejection. `guarded` is that catch,
|
|
134
|
+
// applied once to each `do<Verb>` export rather than five times: whatever the body threw
|
|
135
|
+
// becomes `{ok: false, error: "network", message}`, and since nothing here writes state
|
|
136
|
+
// until its work has actually succeeded, disk is left exactly as it was at the failure —
|
|
137
|
+
// a retry reprocesses safely.
|
|
138
|
+
function guarded(fn) {
|
|
139
|
+
return async (...args) => {
|
|
140
|
+
try { return await fn(...args); }
|
|
141
|
+
catch (err) { return { ok: false, error: "network", message: String((err && err.message) || err) }; }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function materialise(client, unit, table, dir) {
|
|
146
|
+
for (const [p, f] of Object.entries(table)) {
|
|
147
|
+
const dest = path.join(dir, relOf(unit, p));
|
|
148
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
149
|
+
fs.writeFileSync(dest, await client.blobGet(f.h));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function doOpenImpl({ client, unit, dir, origin, space, session, now, isNew = false }) {
|
|
154
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length) return { ok: false, error: "folder-not-empty", dir };
|
|
155
|
+
const createdFolder = !fs.existsSync(dir);
|
|
156
|
+
const o = await client.open({ unit });
|
|
157
|
+
if (o.status) return { ok: false, ...o };
|
|
158
|
+
// A unit with no files is one that does not exist yet. Creating one is a decision the
|
|
159
|
+
// caller states with `isNew`; without it, a typo would quietly open an empty draft on a
|
|
160
|
+
// prototype that was never there. Either mismatch hands the draft straight back.
|
|
161
|
+
const exists = Object.keys(o.table || {}).length > 0;
|
|
162
|
+
if (exists !== !isNew) {
|
|
163
|
+
try { await client.discard({ unit, draftId: o.draftId }); } catch (e) { /* best-effort */ }
|
|
164
|
+
return { ok: false, error: exists ? "unit-exists" : "unknown-unit", unit };
|
|
165
|
+
}
|
|
166
|
+
// From here the server-side draft exists, so any failure below must both undo what we
|
|
167
|
+
// wrote to disk and tell the server to drop the orphan — otherwise a retry finds a
|
|
168
|
+
// half-materialised folder (`folder-not-empty`) and the draft it opened is never freed.
|
|
169
|
+
try {
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
await materialise(client, unit, o.table, dir);
|
|
172
|
+
// `table` is what the DRAFT last saved (the per-file bases a save is checked against);
|
|
173
|
+
// `baseTable` is what MAIN held at the draft's base revision (what a sync merges from).
|
|
174
|
+
const state = { origin, space, unit, address: o.address, draftId: o.draftId, session, baseRevision: o.baseRevision, draftRevision: 0, table: o.table, baseTable: o.table, openedAt: now };
|
|
175
|
+
writeState(dir, state);
|
|
176
|
+
registryAdd({ dir, unit, draftId: o.draftId, origin, openedAt: now });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// When we created `dir` ourselves, the whole thing is ours to remove. When it
|
|
179
|
+
// pre-existed, the empty-folder check above guarantees everything under it now is
|
|
180
|
+
// ALSO ours (nothing else can have written there since) — so every entry goes, not
|
|
181
|
+
// just the ones named in `o.table`, leaving the pre-existing folder itself in place.
|
|
182
|
+
if (createdFolder) fs.rmSync(dir, { recursive: true, force: true });
|
|
183
|
+
else for (const e of fs.readdirSync(dir)) fs.rmSync(path.join(dir, e), { recursive: true, force: true });
|
|
184
|
+
try { await client.discard({ unit, draftId: o.draftId }); } catch (e) { /* best-effort */ }
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
const others = (o.presence || []).filter((d) => d.id !== o.draftId);
|
|
188
|
+
return { ok: true, draftId: o.draftId, address: `${origin}${o.address}`, files: Object.keys(o.table).length, others, isNew: !exists };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function doSaveImpl({ client, dir, baseRevision, baseTable }) {
|
|
192
|
+
const st = readState(dir);
|
|
193
|
+
if (!st) return { ok: false, error: "not-a-draft", dir };
|
|
194
|
+
const local = scanFolder(dir);
|
|
195
|
+
const changes = changesBetween(st.unit, st.table, local);
|
|
196
|
+
if (!changes.length && baseRevision === undefined) return { ok: true, changed: [], draftRevision: st.draftRevision };
|
|
197
|
+
for (const c of changes) if (!c.delete) await client.blobPut(c.h, fs.readFileSync(path.join(dir, relOf(st.unit, c.path))));
|
|
198
|
+
const r = await client.save({ unit: st.unit, draftId: st.draftId, draftRevision: st.draftRevision, changes, ...(baseRevision !== undefined ? { baseRevision } : {}) });
|
|
199
|
+
if (r.status) return { ok: false, ...r };
|
|
200
|
+
// A caller mid-sync (`doSyncImpl`) hands its advanced `baseTable` in here rather than
|
|
201
|
+
// writing state itself, so the whole revision — `draftRevision`, `table`, `baseRevision`
|
|
202
|
+
// and `baseTable` — lands in the ONE `writeState` below, only once the server has
|
|
203
|
+
// actually accepted the save. A failure anywhere above this line leaves the file on disk
|
|
204
|
+
// byte-identical to before the call, whatever advanced state a caller was carrying.
|
|
205
|
+
st.draftRevision = r.draftRevision; st.table = r.table;
|
|
206
|
+
if (baseRevision !== undefined) st.baseRevision = baseRevision;
|
|
207
|
+
if (baseTable !== undefined) st.baseTable = baseTable;
|
|
208
|
+
writeState(dir, st);
|
|
209
|
+
return { ok: true, changed: changes.map((c) => relOf(st.unit, c.path)), draftRevision: r.draftRevision };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function doLandImpl({ client, dir, note }) {
|
|
213
|
+
const st = readState(dir);
|
|
214
|
+
if (!st) return { ok: false, error: "not-a-draft", dir };
|
|
215
|
+
const saved = await doSave({ client, dir });
|
|
216
|
+
if (!saved.ok) return saved;
|
|
217
|
+
const r = await client.land({ unit: st.unit, draftId: st.draftId, baseRevision: st.baseRevision, note: note || "" });
|
|
218
|
+
if (r.status) return { ok: false, ...r };
|
|
219
|
+
st.landed = true; st.landedRevision = r.revision;
|
|
220
|
+
writeState(dir, st);
|
|
221
|
+
registryRemove(dir);
|
|
222
|
+
// `recorded: false` means the bytes are live and the history entry is not — the server
|
|
223
|
+
// wrote the manifest and could not tell the unit's object about it. Carried through so
|
|
224
|
+
// the command can say so; the landing itself happened either way.
|
|
225
|
+
return { ok: true, url: r.url, revision: r.revision, version: r.version,
|
|
226
|
+
recorded: r.recorded !== false, warning: r.warning || null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function doSyncImpl({ client, dir }) {
|
|
230
|
+
const st = readState(dir);
|
|
231
|
+
if (!st) return { ok: false, error: "not-a-draft", dir };
|
|
232
|
+
const r = await client.sync({ unit: st.unit, draftId: st.draftId });
|
|
233
|
+
if (r.status) return { ok: false, ...r };
|
|
234
|
+
const local = scanFolder(dir);
|
|
235
|
+
const baseTable = st.baseTable || {};
|
|
236
|
+
const nextBase = { ...baseTable };
|
|
237
|
+
const taken = [], merged = [], conflicts = [], kept = [];
|
|
238
|
+
const isText = (ct) => /^text\//.test(ct) || /javascript|json|svg/.test(ct);
|
|
239
|
+
for (const c of r.changed) {
|
|
240
|
+
const rel = relOf(st.unit, c.path);
|
|
241
|
+
const base = baseTable[c.path] || null; // what main held when this draft was based
|
|
242
|
+
nextBase[c.path] = { h: c.h, ct: c.ct, s: c.s };
|
|
243
|
+
if (base && base.h === c.h) continue; // main's file is what my base already had
|
|
244
|
+
const mine = local[rel] || null;
|
|
245
|
+
const theirBytes = await client.blobGet(c.h);
|
|
246
|
+
const dest = path.join(dir, rel);
|
|
247
|
+
if (!mine && base) { // absent locally but the base had it: I deleted it.
|
|
248
|
+
// main changed it too (we would have `continue`d above otherwise) — that's a real
|
|
249
|
+
// conflict, not a resurrection: leave the file gone, drop theirs beside it.
|
|
250
|
+
// `nextBase[c.path]` above already advanced to main's version despite the conflict staying open, so a later land is possible once the agent has decided; landing without deciding lands the draft's table (this file absent) as it stands.
|
|
251
|
+
writeTheirs(dir, rel, theirBytes);
|
|
252
|
+
conflicts.push({ rel, hunks: [], deleted: true });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (!mine || (base && mine.h === base.h)) { // new on main, or I did not touch it: take theirs
|
|
256
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
257
|
+
fs.writeFileSync(dest, theirBytes);
|
|
258
|
+
taken.push(rel);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (mine.h === c.h) { kept.push(rel); continue; } // we made the same change
|
|
262
|
+
if (!base || !isText(c.ct)) { // no common base, or binary: theirs beside, mine stays
|
|
263
|
+
writeTheirs(dir, rel, theirBytes); conflicts.push({ rel, hunks: [] }); continue;
|
|
264
|
+
}
|
|
265
|
+
const baseBytes = await client.blobGet(base.h);
|
|
266
|
+
const m = merge3(baseBytes.toString("utf8"), fs.readFileSync(dest, "utf8"), theirBytes.toString("utf8"));
|
|
267
|
+
if (m.ok) { fs.writeFileSync(dest, m.text); merged.push(rel); }
|
|
268
|
+
else { writeTheirs(dir, rel, theirBytes); conflicts.push({ rel, hunks: m.conflicts }); }
|
|
269
|
+
}
|
|
270
|
+
for (const p of r.removed) {
|
|
271
|
+
const rel = relOf(st.unit, p);
|
|
272
|
+
const base = baseTable[p], mine = local[rel];
|
|
273
|
+
delete nextBase[p];
|
|
274
|
+
if (mine && base && mine.h === base.h) { fs.rmSync(path.join(dir, rel), { force: true }); taken.push(rel); }
|
|
275
|
+
else if (mine) kept.push(rel);
|
|
276
|
+
}
|
|
277
|
+
// The draft is now based on main's current revision. Do not write that here: hand
|
|
278
|
+
// `nextBase` to `doSave` and let it land in the SAME `writeState` the trailing save
|
|
279
|
+
// already does after the server accepts it — a save that fails on the network must
|
|
280
|
+
// leave `.augur/draft.json` exactly as it was before this sync, not holding an advanced
|
|
281
|
+
// `baseTable` alongside a stale `table`/`baseRevision`.
|
|
282
|
+
const saved = await doSave({ client, dir, baseRevision: r.mainRevision, baseTable: nextBase });
|
|
283
|
+
if (!saved.ok) return saved;
|
|
284
|
+
return { ok: true, mainRevision: r.mainRevision, taken, merged, kept, conflicts };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function writeTheirs(dir, rel, bytes) {
|
|
288
|
+
const dest = path.join(dir, THEIRS_DIR, rel);
|
|
289
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
290
|
+
fs.writeFileSync(dest, bytes);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function doCloseImpl({ client, dir, discard }) {
|
|
294
|
+
const st = readState(dir);
|
|
295
|
+
if (!st) {
|
|
296
|
+
// A read-only copy has no state file; the registry is what says it is ours to remove.
|
|
297
|
+
const copy = registryList().find((e) => e.readOnly && path.resolve(e.dir) === path.resolve(dir));
|
|
298
|
+
if (!copy) return { ok: false, error: "not-a-draft", dir };
|
|
299
|
+
registryRemove(copy.dir);
|
|
300
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
301
|
+
return { ok: true, readOnly: true };
|
|
302
|
+
}
|
|
303
|
+
if (!st.landed && !discard) return { ok: false, error: "draft-still-open", draftId: st.draftId, address: st.address };
|
|
304
|
+
if (!st.landed && discard) {
|
|
305
|
+
const r = await client.discard({ unit: st.unit, draftId: st.draftId });
|
|
306
|
+
if (r.status && r.status !== 404) return { ok: false, ...r };
|
|
307
|
+
}
|
|
308
|
+
registryRemove(dir);
|
|
309
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
310
|
+
return { ok: true, discarded: !st.landed };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── read-only copies (§7 `read`) ─────────────────────────────────────────────
|
|
314
|
+
// A unit materialised for CONTEXT, not for editing: beside the draft folders under `_read/`,
|
|
315
|
+
// files with no write bit, and a registry row the deny hook reads so an editor that ignores
|
|
316
|
+
// the mode is refused with the reason. No `.augur/draft.json` — a copy is not a draft.
|
|
317
|
+
export const READ_DIR = "_read";
|
|
318
|
+
export const readDirFor = (unit) => path.join(READ_DIR, ...unit.split("/").filter(Boolean));
|
|
319
|
+
|
|
320
|
+
async function doReadImpl({ client, unit, dir, origin, now }) {
|
|
321
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length) return { ok: false, error: "folder-not-empty", dir };
|
|
322
|
+
const m = await client.main(unit);
|
|
323
|
+
if (m.status) return { ok: false, ...m };
|
|
324
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
325
|
+
await materialise(client, unit, m.table || {}, dir);
|
|
326
|
+
for (const p of Object.keys(m.table || {})) fs.chmodSync(path.join(dir, relOf(unit, p)), 0o444);
|
|
327
|
+
registryAdd({ dir, unit, origin, readOnly: true, openedAt: now, revision: m.revision });
|
|
328
|
+
return { ok: true, dir, files: Object.keys(m.table || {}).length, revision: m.revision };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── the watch loop (§7 `watch`) ──────────────────────────────────────────────
|
|
332
|
+
// For people editing by hand in an editor that runs no hooks: every burst of changes is one
|
|
333
|
+
// save. `fs.watch` with `recursive` is what every platform this repo supports offers; the
|
|
334
|
+
// state file's own writes are ignored or a save would trigger the next.
|
|
335
|
+
export function watchFolder(dir, onSettle, { debounceMs = 300 } = {}) {
|
|
336
|
+
let timer = null;
|
|
337
|
+
const watcher = fs.watch(dir, { recursive: true }, (_ev, name) => {
|
|
338
|
+
const rel = String(name || "");
|
|
339
|
+
if (rel === ".augur" || rel.startsWith(".augur" + path.sep) || rel.startsWith(".augur/")) return;
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
timer = setTimeout(() => { timer = null; onSettle(); }, debounceMs);
|
|
342
|
+
});
|
|
343
|
+
return { close() { clearTimeout(timer); watcher.close(); } };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── does the instance serve drafts at all (§10) ───────────────────────────────
|
|
347
|
+
// Read from the public well-known file, so it needs no token and answers before anything
|
|
348
|
+
// is committed or built. False on ANY failure: an instance that cannot be asked is treated
|
|
349
|
+
// as one that does not serve drafts, which is the path that already works everywhere.
|
|
350
|
+
export async function draftsServed(origin, { timeoutMs = 2500 } = {}) {
|
|
351
|
+
if (!origin) return false;
|
|
352
|
+
try {
|
|
353
|
+
const r = await fetch(`${String(origin).replace(/\/+$/, "")}/.well-known/augur.json`, {
|
|
354
|
+
signal: AbortSignal.timeout(timeoutMs), headers: { Accept: "application/json" },
|
|
355
|
+
});
|
|
356
|
+
if (!r.ok) return false;
|
|
357
|
+
const j = await r.json();
|
|
358
|
+
return !!(j && j.drafts && j.drafts.enabled === true);
|
|
359
|
+
} catch (e) { return false; }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── what is open on this machine (§4 `status`) ───────────────────────────────
|
|
363
|
+
export function draftsReport(entries, presenceByUnit = {}) {
|
|
364
|
+
const out = [];
|
|
365
|
+
for (const e of entries || []) {
|
|
366
|
+
if (!e || e.readOnly) continue;
|
|
367
|
+
out.push(`${e.unit.replace(/^\/|\/$/g, "")} draft ${e.draftId} ${e.dir}`);
|
|
368
|
+
for (const d of presenceByUnit[e.unit] || []) {
|
|
369
|
+
if (d.id === e.draftId) continue;
|
|
370
|
+
out.push(` also here: ${d.name ? d.name + " · " : ""}${d.session || "someone"} (${d.active ? "active" : "idle"})`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Each verb's public surface is its body wrapped in `guarded` — see the comment above
|
|
377
|
+
// `guarded` itself. Internal callers (`doLandImpl` → `doSave`, `doSyncImpl` → `doSave`) go
|
|
378
|
+
// through the same wrapped export, so a blob failure partway through a land or a sync comes
|
|
379
|
+
// back as the same `{ok: false, error: "network"}` shape a bare save would return.
|
|
380
|
+
export const doOpen = guarded(doOpenImpl);
|
|
381
|
+
export const doSave = guarded(doSaveImpl);
|
|
382
|
+
export const doLand = guarded(doLandImpl);
|
|
383
|
+
export const doSync = guarded(doSyncImpl);
|
|
384
|
+
export const doClose = guarded(doCloseImpl);
|
|
385
|
+
export const doRead = guarded(doReadImpl);
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Graduation — take ONE prototype out, standing on its own, with nothing of this
|
|
2
|
+
// engine left in it.
|
|
3
|
+
//
|
|
4
|
+
// `F-graduate-path`. `augur clone` already turns a whole workspace back into an editable
|
|
5
|
+
// tree, and `augur migrate` already moves a whole workspace between instances. Neither is
|
|
6
|
+
// the thing observed usage asks for: a single tool inside a research workspace acquires a
|
|
7
|
+
// stable audience that is not the team, and then it needs to LEAVE — one artifact, not the
|
|
8
|
+
// workspace around it. Without a doorway it stays, and the research workspace quietly
|
|
9
|
+
// becomes a production host for somebody's customer-facing tool.
|
|
10
|
+
//
|
|
11
|
+
// So this is a doorway, not a policy. Everything here answers one question: what does a
|
|
12
|
+
// person need so that the folder they end up with can be served by anything, from anywhere,
|
|
13
|
+
// forever, with this engine deleted?
|
|
14
|
+
//
|
|
15
|
+
// THREE THINGS HAVE TO HAPPEN, and only the first is obvious.
|
|
16
|
+
//
|
|
17
|
+
// 1. RE-ROOT. A prototype lives at `<project>/prototypes/<name>/` in a repo and at
|
|
18
|
+
// `/<project>/<name>/` on the site. Standalone it lives at `/`. Its files move up,
|
|
19
|
+
// and the `../../../skills/<ds>/` references its author wrote have to move with them.
|
|
20
|
+
// `stripBuildDecorations` already reverses that depth rewrite exactly (it is the peel
|
|
21
|
+
// the adopt path uses), so re-rooting is that same peel with the new relative dir.
|
|
22
|
+
//
|
|
23
|
+
// 2. BRING THE DESIGN SYSTEM. A prototype is self-contained HTML except for the one
|
|
24
|
+
// thing it deliberately shares: the workspace's UI skill. Left behind, the graduated
|
|
25
|
+
// copy is an unstyled page. So the skill folders the prototype REFERENCES come too —
|
|
26
|
+
// whole folders, not the files a scan happened to see, because a stylesheet reaches
|
|
27
|
+
// its own fonts and a component script reaches its own assets by paths no reference
|
|
28
|
+
// scan will find.
|
|
29
|
+
//
|
|
30
|
+
// 3. PROVE IT. "No Augur dependency" is not a claim to make by reading the diff. Every
|
|
31
|
+
// written file is scanned afterwards: an injected-chrome marker, an engine `/__…`
|
|
32
|
+
// route or a page global is FATAL (the peel failed, and the copy would call home from
|
|
33
|
+
// somebody else's domain); a reference that resolves to nothing in the folder is a
|
|
34
|
+
// warning with the file and the line, because off its old origin it is a 404 that the
|
|
35
|
+
// original site quietly answered.
|
|
36
|
+
//
|
|
37
|
+
// Pure functions only — the fetching and the writing live in the command. That split is
|
|
38
|
+
// what lets the whole decision be tested against a real built manifest instead of mocked.
|
|
39
|
+
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { stripBuildDecorations } from "./publish-conflict.mjs";
|
|
42
|
+
|
|
43
|
+
/** Text the reference scanner reads. Anything else is bytes and is copied through. */
|
|
44
|
+
const TEXT_EXT = new Set([".html", ".htm", ".css", ".js", ".mjs", ".json", ".svg", ".txt", ".md", ".webmanifest", ".map"]);
|
|
45
|
+
export const isText = (p) => TEXT_EXT.has(path.posix.extname(String(p)).toLowerCase());
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Files a skill folder carries that are this engine's contract with itself, not assets a
|
|
49
|
+
* page loads. A published manifest never has them (a skill ships what its skill.json
|
|
50
|
+
* declares); a source tree does, and copying them would put engine vocabulary into a
|
|
51
|
+
* folder whose whole point is that the engine is gone.
|
|
52
|
+
*/
|
|
53
|
+
const SKILL_INTERNAL = new Set(["skill.json", "graph.js"]);
|
|
54
|
+
export const isSkillInternal = (rel) => {
|
|
55
|
+
const base = path.posix.basename(rel);
|
|
56
|
+
return SKILL_INTERNAL.has(base) || base.toLowerCase().endsWith(".md");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whatever a person typed → the `publicPrefixes` entry it means.
|
|
61
|
+
*
|
|
62
|
+
* They will type the URL they know (`/garden/seed-swap/`), the repo path they edit
|
|
63
|
+
* (`garden/prototypes/seed-swap`), or just the name (`seed-swap`). All three resolve; an
|
|
64
|
+
* ambiguous bare name lists the candidates rather than picking one, because picking one
|
|
65
|
+
* graduates the wrong tool and nothing about the result would say so.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveUnit(arg, prefixes = []) {
|
|
68
|
+
const list = prefixes.filter((p) => typeof p === "string" && p.startsWith("/"));
|
|
69
|
+
const norm = String(arg || "").trim().replace(/^\/+|\/+$/g, "").replace(/\/prototypes\//, "/");
|
|
70
|
+
if (!norm) throw new Error("name a prototype: --prototype <project>/<name>");
|
|
71
|
+
|
|
72
|
+
const exact = list.find((p) => p === `/${norm}/`);
|
|
73
|
+
if (exact) return { prefix: exact, name: exact.replace(/^\/+|\/+$/g, "").split("/").pop() };
|
|
74
|
+
|
|
75
|
+
const byName = list.filter((p) => p.replace(/^\/+|\/+$/g, "").split("/").pop() === norm);
|
|
76
|
+
if (byName.length === 1) return { prefix: byName[0], name: norm };
|
|
77
|
+
if (byName.length > 1) {
|
|
78
|
+
throw new Error(`"${norm}" names ${byName.length} prototypes — say which: ${byName.join(" ")}`);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`no published prototype "${norm}". Published: ${list.join(" ") || "(none)"}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every published prototype, for a listing. */
|
|
84
|
+
export const publishedUnits = (manifest) =>
|
|
85
|
+
((manifest && manifest.routing && manifest.routing.publicPrefixes) || []).slice().sort();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The unit's own files, from a published manifest → where each lands in the standalone
|
|
89
|
+
* folder. The unit's URL prefix is stripped, so `/garden/seed-swap/index.html` becomes
|
|
90
|
+
* `index.html` — the page a domain's root serves.
|
|
91
|
+
*/
|
|
92
|
+
export function unitFilesFromManifest(manifest, prefix) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const [url, meta] of Object.entries((manifest && manifest.files) || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
95
|
+
if (!url.startsWith(prefix)) continue;
|
|
96
|
+
const rel = url.slice(prefix.length);
|
|
97
|
+
if (!rel) continue;
|
|
98
|
+
out.push({ url, out: rel, h: meta && meta.h, s: (meta && meta.s) || 0 });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The skill folders a set of files references. Reads the DIST form (`../../skills/x/`) and
|
|
105
|
+
* the repo form (`../../../skills/x/`) identically — the depth is exactly what differs
|
|
106
|
+
* between them, and it is not what is being asked here.
|
|
107
|
+
*/
|
|
108
|
+
export function skillDirsReferenced(texts) {
|
|
109
|
+
const found = new Set();
|
|
110
|
+
const re = /(?:\.\.\/)*skills\/([A-Za-z0-9._-]+)\//g;
|
|
111
|
+
for (const t of texts) {
|
|
112
|
+
let m;
|
|
113
|
+
while ((m = re.exec(String(t)))) found.add(m[1]);
|
|
114
|
+
}
|
|
115
|
+
return [...found].sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Everything those skill folders published, at the same path in the standalone folder. */
|
|
119
|
+
export function skillFilesFromManifest(manifest, dirs) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [url, meta] of Object.entries((manifest && manifest.files) || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
122
|
+
const m = /^\/skills\/([^/]+)\/(.+)$/.exec(url);
|
|
123
|
+
if (!m || !dirs.includes(m[1])) continue;
|
|
124
|
+
if (isSkillInternal(m[2])) continue;
|
|
125
|
+
out.push({ url, out: `skills/${m[1]}/${m[2]}`, h: meta && meta.h, s: (meta && meta.s) || 0 });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The peel, aimed at the standalone folder rather than at a repo.
|
|
132
|
+
*
|
|
133
|
+
* Same transformer the adopt path uses — marker chrome out, og/twitter meta out, the
|
|
134
|
+
* linked-assets stamp out, the title emoji off — with the skills depth rewritten for where
|
|
135
|
+
* this file now SITS, which for the page at the root of a domain is no depth at all.
|
|
136
|
+
* A file already in authored form (graduating straight from a repo) passes through it
|
|
137
|
+
* unchanged except for that depth, which is exactly the one thing that has to change.
|
|
138
|
+
*/
|
|
139
|
+
export function rerootHtml(text, outPath) {
|
|
140
|
+
const dir = path.posix.dirname(outPath);
|
|
141
|
+
const relDir = dir === "." ? "" : dir;
|
|
142
|
+
return stripBuildDecorations(String(text), relDir)
|
|
143
|
+
.replace(/\n[ \t]*\n(\s*<\/head>)/g, "\n$1")
|
|
144
|
+
.replace(/\n[ \t]*\n(\s*<\/body>)/g, "\n$1");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── the proof ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What this engine leaves behind if the peel misses, in the two shapes that cannot be
|
|
151
|
+
* anything else. A marker is the peel's own output — one surviving means the peel failed.
|
|
152
|
+
* A page global is state this engine assigns; a graduated page has no business holding one.
|
|
153
|
+
*/
|
|
154
|
+
const ENGINE_TRACES = [
|
|
155
|
+
[/<!--gv-[a-z0-9-]+-(?:start|end)/i, "an injected-chrome marker the peel should have removed"],
|
|
156
|
+
[/window\.__GV_[A-Z_]+/, "a page global this engine sets"],
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/** A path only this engine serves. As a REFERENCE it is a request home; as prose it is prose. */
|
|
160
|
+
const ENGINE_PATH = /^\/(?:__|piti\.js|sw\.js|_chrome\.|_build\.json)/i;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The same shape found loose in the text — a fetch inside a script, or a sentence about
|
|
164
|
+
* how publishing works. ⚠️ THE SCANNER CANNOT TELL THOSE APART, and it must not pretend to.
|
|
165
|
+
*
|
|
166
|
+
* The first version of this failed the command on any line containing them, which read
|
|
167
|
+
* correctly against a prototype full of markup and refused outright the first real
|
|
168
|
+
* documentation-shaped one it met — a page whose PROSE names engine routes and the
|
|
169
|
+
* instance's own hostname, dozens of times, and which depends on neither. A doorway that
|
|
170
|
+
* is a wall for a whole class of artifact is not a doorway, and "add an override flag"
|
|
171
|
+
* would have made the refusal meaningless for the case it exists for.
|
|
172
|
+
*
|
|
173
|
+
* So: a reference is decided, loose text is REPORTED. The report says which it might be.
|
|
174
|
+
*/
|
|
175
|
+
const ENGINE_MENTION = /[/"'(\s]\/__[a-z][a-z0-9-]*\//i;
|
|
176
|
+
|
|
177
|
+
const SKIP_REF = /^(?:#|data:|mailto:|tel:|javascript:|blob:|about:)/i;
|
|
178
|
+
const ABSOLUTE_REF = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i;
|
|
179
|
+
|
|
180
|
+
/** Every reference a page or stylesheet makes, with the line it is on. */
|
|
181
|
+
export function referencesIn(text, ext) {
|
|
182
|
+
const refs = [];
|
|
183
|
+
const push = (raw, line) => { if (raw) refs.push({ ref: raw.trim(), line }); };
|
|
184
|
+
const lines = String(text).split("\n");
|
|
185
|
+
const patterns = [
|
|
186
|
+
/\b(?:href|src|poster|action|data-src)\s*=\s*["']([^"']*)["']/gi,
|
|
187
|
+
/\bsrcset\s*=\s*["']([^"']*)["']/gi,
|
|
188
|
+
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
|
189
|
+
/@import\s+(['"])([^'"]+)\1/gi,
|
|
190
|
+
];
|
|
191
|
+
const wanted = ext === ".html" || ext === ".htm" ? patterns : patterns.slice(2);
|
|
192
|
+
lines.forEach((text_, i) => {
|
|
193
|
+
for (const re of wanted) {
|
|
194
|
+
re.lastIndex = 0;
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = re.exec(text_))) {
|
|
197
|
+
const raw = m[2] != null ? m[2] : m[1];
|
|
198
|
+
if (re.source.startsWith("\\bsrcset")) {
|
|
199
|
+
for (const part of raw.split(",")) push(part.trim().split(/\s+/)[0], i + 1);
|
|
200
|
+
} else push(raw, i + 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
return refs;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Scan the whole written folder. `files` is [{path, text}] for text files plus the set of
|
|
209
|
+
* every path present, so a reference can be resolved against what is actually there.
|
|
210
|
+
*
|
|
211
|
+
* Returns findings, each `{level, path, line, ref, why}`:
|
|
212
|
+
*
|
|
213
|
+
* fatal the copy still depends on this engine. The command refuses — a graduation
|
|
214
|
+
* that silently keeps calling home is the failure this item exists to prevent.
|
|
215
|
+
* suspect an engine-shaped string that is not a reference. Could be a fetch in a
|
|
216
|
+
* script; could be a sentence. Reported, never refused. See ENGINE_MENTION.
|
|
217
|
+
* dangling a reference that resolves to nothing here. The old origin answered it.
|
|
218
|
+
* external a request to another origin. Reported so nobody is surprised by it later.
|
|
219
|
+
*/
|
|
220
|
+
export function residualFindings(files, present, { sourceHost = "" } = {}) {
|
|
221
|
+
const have = present instanceof Set ? present : new Set(present || []);
|
|
222
|
+
const findings = [];
|
|
223
|
+
|
|
224
|
+
for (const f of files) {
|
|
225
|
+
const ext = path.posix.extname(f.path).toLowerCase();
|
|
226
|
+
const decided = new Set(); // lines a reference already settled, so prose is not double-reported
|
|
227
|
+
|
|
228
|
+
for (const { ref, line } of referencesIn(f.text, ext)) {
|
|
229
|
+
if (!ref || SKIP_REF.test(ref)) continue;
|
|
230
|
+
if (ABSOLUTE_REF.test(ref)) {
|
|
231
|
+
let host = "";
|
|
232
|
+
try { host = new URL(ref, "https://placeholder.invalid").host; } catch (e) {}
|
|
233
|
+
if (sourceHost && host === sourceHost) {
|
|
234
|
+
decided.add(line);
|
|
235
|
+
findings.push({ level: "fatal", path: f.path, line, ref, why: `a request back to ${sourceHost}, the instance this came from` });
|
|
236
|
+
} else {
|
|
237
|
+
findings.push({ level: "external", path: f.path, line, ref, why: "a request to another origin — it will follow this copy wherever it goes" });
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const clean = ref.split("#")[0].split("?")[0];
|
|
242
|
+
if (!clean) continue;
|
|
243
|
+
if (ENGINE_PATH.test(clean)) {
|
|
244
|
+
decided.add(line);
|
|
245
|
+
findings.push({ level: "fatal", path: f.path, line, ref, why: "a request to a route only this engine serves" });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const dir = path.posix.dirname(f.path);
|
|
249
|
+
let resolved = clean.startsWith("/")
|
|
250
|
+
? clean.replace(/^\/+/, "")
|
|
251
|
+
: path.posix.normalize(path.posix.join(dir === "." ? "" : dir, clean));
|
|
252
|
+
if (resolved.startsWith("..")) {
|
|
253
|
+
findings.push({ level: "dangling", path: f.path, line, ref, why: "points outside the folder" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (resolved.endsWith("/") || resolved === "") resolved += "index.html";
|
|
257
|
+
if (!have.has(resolved) && !have.has(resolved.replace(/\/$/, "")) && !have.has(`${resolved}/index.html`)) {
|
|
258
|
+
findings.push({ level: "dangling", path: f.path, line, ref, why: "nothing in the folder answers this" });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
String(f.text).split("\n").forEach((line, i) => {
|
|
263
|
+
const n = i + 1;
|
|
264
|
+
for (const [re, why] of ENGINE_TRACES) {
|
|
265
|
+
if (re.test(line)) findings.push({ level: "fatal", path: f.path, line: n, ref: line.trim().slice(0, 120), why });
|
|
266
|
+
}
|
|
267
|
+
if (decided.has(n)) return;
|
|
268
|
+
if (ENGINE_MENTION.test(line)) {
|
|
269
|
+
findings.push({ level: "suspect", path: f.path, line: n, ref: line.trim().slice(0, 120), why: "an engine route in the text — prose about it is fine, a request to it is not" });
|
|
270
|
+
} else if (sourceHost && line.includes(sourceHost)) {
|
|
271
|
+
findings.push({ level: "suspect", path: f.path, line: n, ref: line.trim().slice(0, 120), why: `names ${sourceHost} in the text — prose about it is fine, a request to it is not` });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return findings.sort((a, b) => (a.path === b.path ? a.line - b.line : a.path < b.path ? -1 : 1));
|
|
276
|
+
}
|