website-xp-phone 2.2.1 → 2.2.11
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/.github/workflows/ci.yml +8 -0
- package/Makefile +5 -1
- package/README.md +9 -0
- package/build/routes.ts +27 -10
- package/package.json +2 -1
- package/public/Click.ogg +0 -0
- package/public/addons/rage-volley.txt +1 -1
- package/public/addons/rueg-wa-druid.txt +1 -1
- package/public/addons/rueg-wa-mage.txt +1 -1
- package/public/addons/rueg-wa-paladin.txt +1 -0
- package/public/addons/rueg-wa-priest.txt +1 -1
- package/public/addons/rueg-wa-rueg.txt +1 -1
- package/public/addons/rueg-wa-shaman.txt +1 -1
- package/public/blog/random/branch.md +29 -0
- package/public/blog/random/google.md +25 -0
- package/public/checksums.txt +70 -47
- package/public/documents/drift-report.json +46 -148
- package/public/loading.css +7 -0
- package/scripts/asset-reference.mjs +37 -0
- package/scripts/mutable-assets.mjs +46 -39
- package/scripts/refresh-soundcloud.mjs +152 -414
- package/scripts/verify-policy.mjs +206 -0
- package/scripts/write-dist-checksums.mjs +251 -135
- package/src/content/addons.json +13 -17
- package/src/content/blog.json +5 -26
- package/src/content/characters.json +6 -6
- package/src/content/configs.json +6 -2
- package/src/content/index.ts +105 -12
- package/src/content/pages.json +10 -1
- package/src/content/projects/development.json +9 -1
- package/src/content/projects/doftiles.json +9 -1
- package/src/content/projects/frontend.json +9 -1
- package/src/content/projects/graphbrowser.json +9 -1
- package/src/content/projects/herbstutil.json +93 -4
- package/src/content/projects/lightdni-jssas-toggle.json +9 -1
- package/src/content/projects/pagerts.json +13 -2
- package/src/content/projects/rarebert.json +12 -2
- package/src/content/projects/webuser.json +9 -1
- package/src/content/schema.ts +10 -1
- package/src/content/sections.json +2 -3
- package/src/content/soundcloud.json +204 -72
- package/src/lib/audio.ts +14 -0
- package/src/lib/clippy.ts +1 -7
- package/src/lib/embeds.ts +1 -1
- package/src/lib/musicSchema.ts +1 -0
- package/src/main.tsx +8 -0
- package/src/pages/MusicPage.tsx +0 -1
- package/src/pages/SitemapPage.tsx +1 -1
- package/src/pages/WowPage.tsx +1 -1
- package/src/shell/App.tsx +4 -1
- package/src/styles/extras.css +85 -30
- package/src/styles/layout.css +27 -18
- package/src/styles/window.css +53 -50
- package/src/themes/pageSlide/index.ts +115 -13
- package/src/themes/pageSlide/pageSlide.css +99 -6
- package/src/window/Embed.tsx +3 -1
- package/src/window/MaximizedWindow.tsx +250 -0
- package/src/window/OkButton.tsx +6 -1
- package/src/window/PageWindows.tsx +3 -9
- package/src/window/Window.tsx +129 -53
- package/src/window/model.ts +22 -0
- package/src/window/state.tsx +54 -30
- package/test/asset-reference.test.js +53 -0
- package/test/content.test.js +60 -6
- package/test/mutable-assets.test.js +27 -0
- package/test/verify-policy.test.js +92 -0
- package/tsconfig.node.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/xp.css/README.md +10 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CI drift gate for the checksums pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Reads the sidecar `public/documents/drift-report.json` that
|
|
6
|
+
* write-dist-checksums.mjs emits during the build and fails when the
|
|
7
|
+
* build observed genuine STABLE-invariant drift relative to the live
|
|
8
|
+
* deployment baseline:
|
|
9
|
+
*
|
|
10
|
+
* - a non-empty `violations` array (content drift or an artifact that
|
|
11
|
+
* could not be verified against the live counterpart), or
|
|
12
|
+
* - a corrupt/inconsistent report (`violationCount` disagreeing with
|
|
13
|
+
* the `violations` array, reconciled resolutions inside `violations`,
|
|
14
|
+
* missing fields) — the gate fails closed on those.
|
|
15
|
+
*
|
|
16
|
+
* Rebuild noise that write-dist-checksums.mjs already reconciled against
|
|
17
|
+
* the live deployment (`rehash-only`, `live-match`) is NOT a failure; it
|
|
18
|
+
* is printed for observability only.
|
|
19
|
+
*
|
|
20
|
+
* The gate runs in CI (where the remote baseline is reachable) rather
|
|
21
|
+
* than only locally. On pull requests it runs in advisory mode so that
|
|
22
|
+
* legitimate content edits under review do not block CI — pass
|
|
23
|
+
* `--nonblocking` (or set `VERIFY_POLICY_NONBLOCKING=1`) to downgrade
|
|
24
|
+
* genuine-drift findings to warnings. A missing or corrupt report is
|
|
25
|
+
* always a hard failure: an indefinite signal cannot gate anything.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFile } from "node:fs/promises";
|
|
29
|
+
import { dirname, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
31
|
+
|
|
32
|
+
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
|
+
const driftReportPath = resolve(
|
|
34
|
+
projectRoot,
|
|
35
|
+
"public",
|
|
36
|
+
"documents",
|
|
37
|
+
"drift-report.json",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** Reconciled resolutions must never appear inside `violations`. */
|
|
41
|
+
const RECONCILED_RESOLUTIONS = new Set(["rehash-only", "live-match"]);
|
|
42
|
+
|
|
43
|
+
function isRecord(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decode the drift report. Unknown shape fails closed.
|
|
49
|
+
* Returns `{ ok: true, report }` or `{ ok: false, issues }`.
|
|
50
|
+
*/
|
|
51
|
+
export function decodeReport(value) {
|
|
52
|
+
if (!isRecord(value)) {
|
|
53
|
+
return { ok: false, issues: ["drift report is not a JSON object"] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const issues = [];
|
|
57
|
+
const { baselineUrl, generatedAt, violationCount, violations } = value;
|
|
58
|
+
|
|
59
|
+
if (typeof baselineUrl !== "string" || baselineUrl.length === 0) {
|
|
60
|
+
issues.push("baselineUrl is missing or not a non-empty string");
|
|
61
|
+
}
|
|
62
|
+
if (typeof generatedAt !== "string" || generatedAt.length === 0) {
|
|
63
|
+
issues.push("generatedAt is missing or not a non-empty string");
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
typeof violationCount !== "number" ||
|
|
67
|
+
!Number.isInteger(violationCount) ||
|
|
68
|
+
violationCount < 0
|
|
69
|
+
) {
|
|
70
|
+
issues.push("violationCount is missing or not a non-negative integer");
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(violations)) {
|
|
73
|
+
issues.push("violations is missing or not an array");
|
|
74
|
+
}
|
|
75
|
+
if (issues.length > 0) {
|
|
76
|
+
return { ok: false, issues };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [index, entry] of violations.entries()) {
|
|
80
|
+
if (!isRecord(entry) || typeof entry.relPath !== "string") {
|
|
81
|
+
issues.push(`violations[${index}] has no relPath`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (typeof entry.resolution !== "string") {
|
|
85
|
+
issues.push(`violations[${index}] (${entry.relPath}) has no resolution`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (violationCount !== violations.length) {
|
|
89
|
+
issues.push(
|
|
90
|
+
`violationCount (${violationCount}) does not match ` +
|
|
91
|
+
`violations length (${violations.length})`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return issues.length === 0
|
|
96
|
+
? {
|
|
97
|
+
ok: true,
|
|
98
|
+
report: { baselineUrl, generatedAt, violationCount, violations },
|
|
99
|
+
}
|
|
100
|
+
: { ok: false, issues };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Evaluate a decoded report. Returns `{ ok, reasons }` where `reasons`
|
|
105
|
+
* is empty exactly when there is no genuine drift.
|
|
106
|
+
*/
|
|
107
|
+
export function evaluateReport(report) {
|
|
108
|
+
const reasons = [];
|
|
109
|
+
for (const entry of report.violations) {
|
|
110
|
+
if (RECONCILED_RESOLUTIONS.has(entry.resolution)) {
|
|
111
|
+
reasons.push(
|
|
112
|
+
`${entry.relPath}: reconciled resolution "${entry.resolution}" ` +
|
|
113
|
+
`must not appear in violations (generator regression)`,
|
|
114
|
+
);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const note = entry.note ? ` — ${entry.note}` : "";
|
|
118
|
+
reasons.push(
|
|
119
|
+
`${entry.relPath}: ${entry.status ?? "unknown"} ${entry.resolution}${note}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { ok: reasons.length === 0, reasons };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function observabilityLines(report) {
|
|
126
|
+
const rehashOnly = Array.isArray(report.rehashOnly) ? report.rehashOnly : [];
|
|
127
|
+
const liveMatched = Array.isArray(report.liveMatched)
|
|
128
|
+
? report.liveMatched
|
|
129
|
+
: [];
|
|
130
|
+
return [
|
|
131
|
+
`rebuild noise reconciled against live: ${rehashOnly.length} rehash-only, ` +
|
|
132
|
+
`${liveMatched.length} live-match (not drift)`,
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function main() {
|
|
137
|
+
const nonblocking =
|
|
138
|
+
process.argv.includes("--nonblocking") ||
|
|
139
|
+
process.env.VERIFY_POLICY_NONBLOCKING === "1";
|
|
140
|
+
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = await readFile(driftReportPath, "utf8");
|
|
144
|
+
} catch {
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
`Drift policy FAIL — drift report not found at ${driftReportPath}. ` +
|
|
147
|
+
`The build pipeline (write-dist-checksums.mjs) must run first so ` +
|
|
148
|
+
`the gate consumes a definite signal.\n`,
|
|
149
|
+
);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(raw);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
process.stderr.write(
|
|
158
|
+
`Drift policy FAIL — drift report at ${driftReportPath} is not valid ` +
|
|
159
|
+
`JSON: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
160
|
+
);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const decoded = decodeReport(parsed);
|
|
165
|
+
if (!decoded.ok) {
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
`Drift policy FAIL — drift report at ${driftReportPath} failed validation:\n` +
|
|
168
|
+
decoded.issues.map((issue) => ` ${issue}`).join("\n") +
|
|
169
|
+
"\n",
|
|
170
|
+
);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { report } = decoded;
|
|
175
|
+
const evaluation = evaluateReport(report);
|
|
176
|
+
const observability = observabilityLines(report).map((line) => ` ${line}`);
|
|
177
|
+
|
|
178
|
+
if (evaluation.ok) {
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
`Drift policy PASS — ${report.violationCount} violation(s) vs ${report.baselineUrl}\n` +
|
|
181
|
+
`${observability.join("\n")}\n` +
|
|
182
|
+
`Report generated at ${report.generatedAt}\n`,
|
|
183
|
+
);
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const verdict = nonblocking ? "WARN (nonblocking)" : "FAIL";
|
|
188
|
+
const stream = nonblocking ? process.stdout : process.stderr;
|
|
189
|
+
stream.write(
|
|
190
|
+
`Drift policy ${verdict} — ${report.violationCount} genuine drift ` +
|
|
191
|
+
`artifact(s) vs ${report.baselineUrl}:\n` +
|
|
192
|
+
evaluation.reasons.map((reason) => ` ${reason}`).join("\n") +
|
|
193
|
+
"\n" +
|
|
194
|
+
`${observability.join("\n")}\n` +
|
|
195
|
+
`Report generated at ${report.generatedAt}\n`,
|
|
196
|
+
);
|
|
197
|
+
process.exit(nonblocking ? 0 : 1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const invokedDirectly =
|
|
201
|
+
process.argv[1] !== undefined &&
|
|
202
|
+
pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
203
|
+
|
|
204
|
+
if (invokedDirectly) {
|
|
205
|
+
main();
|
|
206
|
+
}
|
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
20
20
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
copyFile,
|
|
22
|
+
mkdir,
|
|
23
|
+
readdir,
|
|
24
|
+
readFile,
|
|
25
|
+
writeFile,
|
|
26
26
|
} from "node:fs/promises";
|
|
27
27
|
import { dirname, relative, resolve } from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
+
import {
|
|
30
|
+
canonicalizeAssetRefs,
|
|
31
|
+
isCanonicalizablePath,
|
|
32
|
+
} from "./asset-reference.mjs";
|
|
29
33
|
import { isMutablePath, MUTABLE_PATH_SET } from "./mutable-assets.mjs";
|
|
30
34
|
|
|
31
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -46,30 +50,100 @@ const driftReportPath = resolve(publicRoot, "documents", "drift-report.json");
|
|
|
46
50
|
|
|
47
51
|
/** Where the previous deployment's manifest lives. */
|
|
48
52
|
const REMOTE_CHECKSUMS_URL =
|
|
49
|
-
|
|
53
|
+
process.env.CHECKSUMS_URL || "https://akinevz.com/checksums.txt";
|
|
54
|
+
|
|
55
|
+
/** Directory of the remote manifest; built artifacts are fetched here. */
|
|
56
|
+
const REMOTE_BASE_URL = REMOTE_CHECKSUMS_URL.replace(/checksums\.txt$/, "");
|
|
50
57
|
|
|
51
58
|
async function walk(dirPath) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (entry.isFile()) {
|
|
63
|
-
files.push(fullPath);
|
|
64
|
-
}
|
|
59
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
60
|
+
const files = [];
|
|
61
|
+
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const fullPath = resolve(dirPath, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
files.push(...(await walk(fullPath)));
|
|
66
|
+
continue;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
if (entry.isFile()) {
|
|
70
|
+
files.push(fullPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return files;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function sha256Text(text) {
|
|
78
|
+
return createHash("sha256").update(text).digest("hex");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch the previously deployed counterpart of a built artifact.
|
|
83
|
+
*
|
|
84
|
+
* Returns null when the remote is unreachable or the file does not exist
|
|
85
|
+
* there yet — the caller then treats the artifact as unverifiable.
|
|
86
|
+
*/
|
|
87
|
+
async function fetchLiveCounterpart(relPath, cache) {
|
|
88
|
+
if (cache.has(relPath)) {
|
|
89
|
+
return cache.get(relPath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let live = null;
|
|
93
|
+
if (typeof fetch === "function") {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(`${REMOTE_BASE_URL}${encodeURI(relPath)}`, {
|
|
96
|
+
cache: "no-store",
|
|
97
|
+
headers: { Accept: "text/plain" },
|
|
98
|
+
});
|
|
99
|
+
if (response.ok) {
|
|
100
|
+
live = await response.text();
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
live = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
cache.set(relPath, live);
|
|
107
|
+
return live;
|
|
68
108
|
}
|
|
69
109
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Decide whether a non-STABLE non-mutable artifact actually drifted.
|
|
112
|
+
*
|
|
113
|
+
* Vite rebuilds every HTML entry page whenever any bundle's content hash
|
|
114
|
+
* changes, so raw byte comparison reports false drift on unchanged pages.
|
|
115
|
+
* Two false-positive classes are resolved against the live deployment:
|
|
116
|
+
*
|
|
117
|
+
* - "live-match": the artifact is byte-identical to the live file but
|
|
118
|
+
* the baseline manifest predates it (baseline staleness).
|
|
119
|
+
* - "rehash-only": the page differs only in the content-hashed asset
|
|
120
|
+
* references Vite re-injects at build time.
|
|
121
|
+
*
|
|
122
|
+
* Anything else — including an unfetchable counterpart — is treated as
|
|
123
|
+
* real drift, the loud failure mode.
|
|
124
|
+
*/
|
|
125
|
+
function classifyDrift({ relPath, localText, liveText }) {
|
|
126
|
+
const localCanonical = isCanonicalizablePath(relPath)
|
|
127
|
+
? canonicalizeAssetRefs(localText)
|
|
128
|
+
: localText;
|
|
129
|
+
|
|
130
|
+
if (liveText === null) {
|
|
131
|
+
return {
|
|
132
|
+
drift: true,
|
|
133
|
+
resolution: "unverifiable",
|
|
134
|
+
note: "no counterpart at the baseline URL",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (localText === liveText) {
|
|
139
|
+
return { drift: false, resolution: "live-match" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (localCanonical === canonicalizeAssetRefs(liveText)) {
|
|
143
|
+
return { drift: false, resolution: "rehash-only" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { drift: true, resolution: "content-drift" };
|
|
73
147
|
}
|
|
74
148
|
|
|
75
149
|
/**
|
|
@@ -78,142 +152,184 @@ async function sha256File(filePath) {
|
|
|
78
152
|
* Two or more spaces separate the digest from the path.
|
|
79
153
|
*/
|
|
80
154
|
function parseChecksumsText(text) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
for (const line of text.split(/\r?\n/)) {
|
|
84
|
-
const trimmed = line.trim();
|
|
85
|
-
if (!trimmed) {
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
155
|
+
const entries = new Map();
|
|
88
156
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
157
|
+
for (const line of text.split(/\r?\n/)) {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (!trimmed) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
93
162
|
|
|
94
|
-
|
|
163
|
+
const [digest, relPath] = trimmed.split(/\s{2,}/);
|
|
164
|
+
if (!digest || !relPath) {
|
|
165
|
+
continue;
|
|
95
166
|
}
|
|
96
167
|
|
|
97
|
-
|
|
168
|
+
entries.set(relPath, digest);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return entries;
|
|
98
172
|
}
|
|
99
173
|
|
|
100
174
|
async function readRemoteChecksums() {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
175
|
+
if (typeof fetch !== "function") {
|
|
176
|
+
return new Map();
|
|
177
|
+
}
|
|
104
178
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
const text = await response.text();
|
|
114
|
-
return parseChecksumsText(text);
|
|
115
|
-
} catch {
|
|
116
|
-
return new Map();
|
|
179
|
+
try {
|
|
180
|
+
const response = await fetch(REMOTE_CHECKSUMS_URL, {
|
|
181
|
+
cache: "no-store",
|
|
182
|
+
headers: { Accept: "text/plain" },
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
return new Map();
|
|
117
186
|
}
|
|
187
|
+
const text = await response.text();
|
|
188
|
+
return parseChecksumsText(text);
|
|
189
|
+
} catch {
|
|
190
|
+
return new Map();
|
|
191
|
+
}
|
|
118
192
|
}
|
|
119
193
|
|
|
120
194
|
async function run() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const relPath = relative(distRoot, filePath).replace(/\\/g, "/");
|
|
135
|
-
const previous = existing.get(relPath);
|
|
136
|
-
const status = !previous
|
|
137
|
-
? "NEW"
|
|
138
|
-
: previous === digest
|
|
139
|
-
? "STABLE"
|
|
140
|
-
: "CHANGED";
|
|
141
|
-
const mutable = isMutablePath(relPath);
|
|
142
|
-
index += 1;
|
|
143
|
-
|
|
144
|
-
process.stdout.write(
|
|
145
|
-
`${String(index).padStart(3, "0")}. [${status}] ${relPath}${mutable ? " (mutable)" : ""}\n` +
|
|
146
|
-
` prev: ${previous ?? "(none)"}\n` +
|
|
147
|
-
` next: ${digest}\n`,
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
// Drift gate: a non-mutable artifact MUST be STABLE relative to the
|
|
151
|
-
// remote baseline. A mutable artifact may be STABLE, CHANGED, or NEW.
|
|
152
|
-
if (!mutable && status !== "STABLE") {
|
|
153
|
-
driftViolations.push({
|
|
154
|
-
relPath,
|
|
155
|
-
status,
|
|
156
|
-
prev: previous ?? null,
|
|
157
|
-
next: digest,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
rows.push(`${digest} ${relPath}`);
|
|
195
|
+
const files = await walk(distRoot);
|
|
196
|
+
const existing = await readRemoteChecksums();
|
|
197
|
+
const rows = [];
|
|
198
|
+
let index = 0;
|
|
199
|
+
const driftViolations = [];
|
|
200
|
+
const rehashOnly = [];
|
|
201
|
+
const liveMatched = [];
|
|
202
|
+
const liveCounterparts = new Map();
|
|
203
|
+
|
|
204
|
+
for (const filePath of files.sort()) {
|
|
205
|
+
// Skip the manifest file itself so it doesn't appear in its own diff.
|
|
206
|
+
if (filePath === distChecksumsPath) {
|
|
207
|
+
continue;
|
|
162
208
|
}
|
|
163
209
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
210
|
+
const localText = await readFile(filePath, "utf8");
|
|
211
|
+
const digest = await sha256Text(localText);
|
|
212
|
+
const relPath = relative(distRoot, filePath).replace(/\\/g, "/");
|
|
213
|
+
const previous = existing.get(relPath);
|
|
214
|
+
const status = !previous
|
|
215
|
+
? "NEW"
|
|
216
|
+
: previous === digest
|
|
217
|
+
? "STABLE"
|
|
218
|
+
: "CHANGED";
|
|
219
|
+
const mutable = isMutablePath(relPath);
|
|
220
|
+
index += 1;
|
|
221
|
+
|
|
222
|
+
process.stdout.write(
|
|
223
|
+
`${String(index).padStart(3, "0")}. [${status}] ${relPath}${mutable ? " (mutable)" : ""}\n` +
|
|
224
|
+
` prev: ${previous ?? "(none)"}\n` +
|
|
225
|
+
` next: ${digest}\n`,
|
|
176
226
|
);
|
|
177
|
-
await writeFile(driftReportPath, `${driftReport}\n`, "utf8");
|
|
178
|
-
|
|
179
|
-
if (driftViolations.length > 0) {
|
|
180
|
-
const lines = driftViolations.map(
|
|
181
|
-
(v) => ` ${v.relPath}: ${v.status} (prev=${v.prev ?? "(none)"})`,
|
|
182
|
-
);
|
|
183
|
-
process.stderr.write(
|
|
184
|
-
`Drift gate failed — ${driftViolations.length} non-mutable artifact(s) changed ` +
|
|
185
|
-
`vs ${REMOTE_CHECKSUMS_URL}:\n${lines.join("\n")}\n`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
227
|
|
|
189
|
-
|
|
228
|
+
// Drift gate: a non-mutable artifact MUST be STABLE relative to the
|
|
229
|
+
// remote baseline. A mutable artifact may be STABLE, CHANGED, or NEW.
|
|
230
|
+
//
|
|
231
|
+
// Rebuild noise is resolved against the live deployment before it is
|
|
232
|
+
// counted as a violation: Vite re-injects content-hashed asset refs
|
|
233
|
+
// into every HTML entry page, and the baseline manifest can lag the
|
|
234
|
+
// live site, so raw byte comparison alone reports false drift.
|
|
235
|
+
if (!mutable && status !== "STABLE") {
|
|
236
|
+
const liveText = await fetchLiveCounterpart(relPath, liveCounterparts);
|
|
237
|
+
const drift = classifyDrift({ relPath, localText, liveText });
|
|
190
238
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
239
|
+
if (drift.drift) {
|
|
240
|
+
driftViolations.push({
|
|
241
|
+
relPath,
|
|
242
|
+
status,
|
|
243
|
+
prev: previous ?? null,
|
|
244
|
+
next: digest,
|
|
245
|
+
resolution: drift.resolution,
|
|
246
|
+
...(drift.note ? { note: drift.note } : {}),
|
|
247
|
+
});
|
|
248
|
+
} else if (drift.resolution === "rehash-only") {
|
|
249
|
+
rehashOnly.push(relPath);
|
|
250
|
+
} else {
|
|
251
|
+
liveMatched.push(relPath);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
process.stdout.write(` drift: ${drift.resolution}\n`);
|
|
255
|
+
}
|
|
198
256
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// keeps the two in sync even if `public/checksums.txt` was deleted
|
|
202
|
-
// before the build ran.
|
|
203
|
-
await copyFile(publicChecksumsPath, distChecksumsPath).catch(() => { });
|
|
257
|
+
rows.push(`${digest} ${relPath}`);
|
|
258
|
+
}
|
|
204
259
|
|
|
260
|
+
// Always emit a drift report so verify-policy can consume a definite
|
|
261
|
+
// signal rather than relying on file presence. `violations` contains
|
|
262
|
+
// only genuine content drift; rebuild noise that was reconciled against
|
|
263
|
+
// the live deployment is recorded separately for observability.
|
|
264
|
+
const driftReport = JSON.stringify(
|
|
265
|
+
{
|
|
266
|
+
baselineUrl: REMOTE_CHECKSUMS_URL,
|
|
267
|
+
generatedAt: new Date().toISOString(),
|
|
268
|
+
mutablePaths: [...MUTABLE_PATH_SET],
|
|
269
|
+
violationCount: driftViolations.length,
|
|
270
|
+
violations: driftViolations,
|
|
271
|
+
rehashOnlyCount: rehashOnly.length,
|
|
272
|
+
rehashOnly,
|
|
273
|
+
liveMatchCount: liveMatched.length,
|
|
274
|
+
liveMatched,
|
|
275
|
+
},
|
|
276
|
+
null,
|
|
277
|
+
2,
|
|
278
|
+
);
|
|
279
|
+
await writeFile(driftReportPath, `${driftReport}\n`, "utf8");
|
|
280
|
+
|
|
281
|
+
if (rehashOnly.length > 0) {
|
|
205
282
|
process.stdout.write(
|
|
206
|
-
|
|
207
|
-
`
|
|
208
|
-
`Baseline: ${REMOTE_CHECKSUMS_URL}\n`,
|
|
283
|
+
`Drift gate: ${rehashOnly.length} rebuilt artifact(s) differ only in ` +
|
|
284
|
+
`Vite's injected asset hashes (rehash-only, not drift)\n`,
|
|
209
285
|
);
|
|
210
|
-
}
|
|
286
|
+
}
|
|
287
|
+
if (liveMatched.length > 0) {
|
|
288
|
+
process.stdout.write(
|
|
289
|
+
`Drift gate: ${liveMatched.length} artifact(s) match the live site but ` +
|
|
290
|
+
`are missing from the baseline manifest (live-match, not drift)\n`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
211
293
|
|
|
212
|
-
|
|
294
|
+
if (driftViolations.length > 0) {
|
|
295
|
+
const lines = driftViolations.map((v) => {
|
|
296
|
+
const note = v.note ? ` — ${v.note}` : "";
|
|
297
|
+
return ` ${v.relPath}: ${v.status} ${v.resolution}${note} (prev=${v.prev ?? "(none)"})`;
|
|
298
|
+
});
|
|
213
299
|
process.stderr.write(
|
|
214
|
-
|
|
300
|
+
`Drift gate failed — ${driftViolations.length} non-mutable artifact(s) changed ` +
|
|
301
|
+
`vs ${REMOTE_CHECKSUMS_URL}:\n${lines.join("\n")}\n`,
|
|
215
302
|
);
|
|
216
|
-
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const manifest = `${rows.join("\n")}\n`;
|
|
306
|
+
|
|
307
|
+
// Write the deployed copy (`dist/checksums.txt`) and the source copy
|
|
308
|
+
// (`public/checksums.txt`) so the next git commit and the next Render
|
|
309
|
+
// deploy both carry the new manifest.
|
|
310
|
+
await mkdir(distRoot, { recursive: true });
|
|
311
|
+
await mkdir(publicRoot, { recursive: true });
|
|
312
|
+
await writeFile(distChecksumsPath, manifest, "utf8");
|
|
313
|
+
await writeFile(publicChecksumsPath, manifest, "utf8");
|
|
314
|
+
|
|
315
|
+
// Belt-and-braces: if Vite ever changes how it copies `public/`, the
|
|
316
|
+
// `dist/` copy is the authoritative served artifact, so a final copy
|
|
317
|
+
// keeps the two in sync even if `public/checksums.txt` was deleted
|
|
318
|
+
// before the build ran.
|
|
319
|
+
await copyFile(publicChecksumsPath, distChecksumsPath).catch(() => {});
|
|
320
|
+
|
|
321
|
+
process.stdout.write(
|
|
322
|
+
`Wrote ${rows.length} checksums to ${distChecksumsPath}\n` +
|
|
323
|
+
` and to ${publicChecksumsPath}\n` +
|
|
324
|
+
`Baseline: ${REMOTE_CHECKSUMS_URL}\n`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
run().catch((error) => {
|
|
329
|
+
process.stderr.write(
|
|
330
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
331
|
+
);
|
|
332
|
+
process.exit(1);
|
|
217
333
|
});
|
|
218
334
|
|
|
219
335
|
console.log("https://akinevz.dev/ was here");
|