Skill Tracer
Skills › Archify › archify › preview.mjs

preview.mjs

970 lines · archify/bin/preview.mjs at a406594

1import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs';
2import { spawn } from 'node:child_process';
3import { createHash } from 'node:crypto';
4import fs from 'node:fs';
5import http from 'node:http';
6import path from 'node:path';
7import { fileURLToPath } from 'node:url';
8
9import { openLoopbackUrl } from './open-artifact.mjs';
10import { resolveOutputPath } from '../renderers/shared/output-path.mjs';
11import { sameLocation } from '../renderers/shared/path-semantics.mjs';
12import {
13 captureAtomicOutput,
14 captureRegularFileBinding,
15 publishRegularFileBinding,
16 releaseRegularFileBinding,
17 removeEmptyDirectoryWithRetry,
18 removeOwnedRegularFile,
19 verifyAtomicOutput,
20} from '../renderers/shared/atomic-output.mjs';
22const here = path.dirname(fileURLToPath(import.meta.url));
23const cliPath = path.join(here, 'archify.mjs');
24const loopbackHost = '127.0.0.1';
25const defaultDebounceMs = 400;
26const defaultPollMs = 800;
27const defaultStopGraceMs = 3000;
28const defaultStopKillMs = 750;
29const diagramTypes = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle', 'erd', 'tree', 'class', 'timeline', 'waterfall']);
30let previewCommitSequence = 0;
32function sha256(value) {
33 return createHash('sha256').update(value).digest('hex');
36function sourceDigest(inputPath) {
37 try {
38 const bytes = fs.readFileSync(inputPath);
39 return { hash: sha256(bytes), bytes, missing: false };
40 } catch (error) {
41 return { hash: `unreadable:${error.code || 'unknown'}`, bytes: null, missing: true };
42 }
45function initialAuthoredOutput(inputPath) {
46 try {
47 const source = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
48 if (typeof source?.meta?.output === 'string') {
49 return source.meta.output;
50 }
51 } catch {
52 // An invalid initial source still gets a status shell. Its output target is
53 // fixed to the same fallback that `deliver` would use after repair.
54 }
55 return undefined;
58function previewPage() {
59 return `<!doctype html>
60<html lang="en">
61<head>
62 <meta charset="utf-8">${softwareNoticesHtml}
63 <meta name="viewport" content="width=device-width,initial-scale=1">
64 <title>Archify Live Preview</title>
65 <style>
66 :root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
67 * { box-sizing: border-box; }
68 html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #0b111b; }
69 body { display: grid; grid-template-rows: auto minmax(0, 1fr); color: #e8edf5; }
70 header { position: relative; z-index: 2; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 7px 12px; border-bottom: 1px solid #253248; background: rgba(11, 17, 27, .96); box-shadow: 0 8px 22px rgba(0,0,0,.18); }
71 .brand { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #9cadc6; }
72 #status { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; min-height: 30px; padding: 5px 10px; border: 1px solid #33435d; border-radius: 999px; background: #111b2a; font-size: 12px; white-space: nowrap; }
73 #status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #6f819d; }
74 body[data-state="checking"] #status::before { background: #f3b44b; box-shadow: 0 0 0 4px rgba(243,180,75,.12); }
75 body[data-state="verified"] #status::before { background: #45d6a8; box-shadow: 0 0 0 4px rgba(69,214,168,.12); }
76 body[data-state="needs-fix"] #status::before { background: #ff6f78; box-shadow: 0 0 0 4px rgba(255,111,120,.12); }
77 details { max-width: min(62vw, 760px); }
78 summary { cursor: pointer; color: #ffbdc2; font-size: 12px; }
79 .diagnostic { position: absolute; top: 38px; right: 12px; width: min(760px, calc(100vw - 24px)); max-height: min(44vh, 360px); overflow: auto; padding: 14px; border: 1px solid #6a3440; border-radius: 10px; background: #17131b; box-shadow: 0 14px 48px rgba(0,0,0,.42); }
80 pre { margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; color: #f2dfe2; }
81 button { min-height: 32px; padding: 5px 10px; border: 1px solid #4a5d79; border-radius: 7px; background: #1a273a; color: #eef4ff; cursor: pointer; }
82 main { position: relative; min-height: 0; }
83 iframe { display: none; width: 100%; height: 100%; border: 0; background: #fff; }
84 body[data-has-artifact="true"] iframe { display: block; }
85 #empty { position: absolute; inset: 0; display: grid; place-items: center; padding: 32px; color: #91a2bc; text-align: center; background: radial-gradient(circle at 50% 38%, #15233a 0, #0b111b 55%); }
86 body[data-has-artifact="true"] #empty { display: none; }
87 @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
88 </style>
89</head>
90<body data-state="checking" data-has-artifact="false">
91 <header>
92 <span class="brand">Archify Preview</span>
93 <details id="failure" hidden>
94 <summary role="button" aria-controls="diagnostic-panel">View diagnostic</summary>
95 <div class="diagnostic" id="diagnostic-panel"><pre id="diagnostic"></pre><button id="copy" type="button">Copy diagnostic</button></div>
96 </details>
97 <span id="status" role="status" aria-live="polite">Checking · generation 1</span>
98 </header>
99 <main>
100 <div id="empty">Waiting for the first verified diagram. Invalid input will stay here with an exact diagnostic.</div>
101 <iframe id="artifact" title="Verified Archify diagram"></iframe>
102 </main>
103 <script>
104 (function () {
105 'use strict';
106 var body = document.body;
107 var status = document.getElementById('status');
108 var failure = document.getElementById('failure');
109 var diagnostic = document.getElementById('diagnostic');
110 var artifact = document.getElementById('artifact');
111 var lastRevision = 0;
113 function render(state) {
114 body.dataset.state = state.status;
115 if (state.status === 'verified') {
116 status.textContent = 'Verified · rev ' + state.revision;
117 failure.hidden = true;
118 failure.open = false;
119 if (state.revision !== lastRevision) {
120 lastRevision = state.revision;
121 artifact.src = '/artifact.html?revision=' + encodeURIComponent(state.revision) + '&sha=' + encodeURIComponent(state.lastVerified.sha256.slice(0, 12));
122 body.dataset.hasArtifact = 'true';
123 }
124 } else if (state.status === 'needs-fix') {
125 status.textContent = 'Needs fix · ' + (state.revision ? 'showing rev ' + state.revision : 'no verified revision');
126 diagnostic.textContent = 'Generation ' + state.generation + ' · ' + state.failure.stage + '\\n\\n' + state.failure.message;
127 failure.hidden = false;
128 } else {
129 status.textContent = 'Checking · generation ' + state.generation;
130 failure.hidden = true;
131 }
132 }
134 document.getElementById('copy').addEventListener('click', function () {
135 if (navigator.clipboard && navigator.clipboard.writeText) {
136 navigator.clipboard.writeText(diagnostic.textContent).catch(function () {});
137 }
138 });
140 var events = new EventSource('/events');
141 events.addEventListener('state', function (event) {
142 try { render(JSON.parse(event.data)); } catch (_) {}
143 });
144 }());
145 </script>
146</body>
147</html>`;
150function compactMessage(value) {
151 let text = String(value || 'Preview build failed without a diagnostic.').trim();
152 const lines = text.split(/\r?\n/);
153 const errorLine = lines.findIndex((line) => /^Error:\s/.test(line));
154 if (errorLine > 0) text = lines.slice(errorLine).join('\n');
155 const relevant = text.split(/\r?\n/);
156 const stackLine = relevant.findIndex((line, index) => index > 0 && /^\s*at\s/.test(line));
157 if (stackLine > 0) text = relevant.slice(0, stackLine).join('\n');
158 return text.length > 6000 ? `${text.slice(0, 6000)}\n… diagnostic truncated` : text;
161function redactDiagnostic(value, paths) {
162 let text = compactMessage(value);
163 for (const [absolutePath, replacement] of paths) {
164 if (!absolutePath) continue;
165 text = text.split(absolutePath).join(replacement);
166 }
167 return text;
170function safeJson(value) {
171 return JSON.stringify(value).replace(/</g, '\\u003c');
174function responseHeaders(contentType) {
175 return {
176 'Cache-Control': 'no-store',
177 'Content-Type': contentType,
178 'Cross-Origin-Resource-Policy': 'same-origin',
179 'Referrer-Policy': 'no-referrer',
180 'X-Content-Type-Options': 'nosniff',
181 'X-Frame-Options': 'SAMEORIGIN',
182 };
185function parseReceipt(stdout) {
186 try {
187 return JSON.parse(stdout);
188 } catch {
189 return null;
190 }
193function atomicOutputFailure(result) {
194 const code = result?.reason?.code || 'unclassified';
195 let diagnosticCode = 'output/target-indeterminate';
196 let message;
197 if (code === 'target-hardlinked' || code === 'candidate-hardlinked') {
198 diagnosticCode = 'output/target-hardlinked';
199 message = code === 'candidate-hardlinked'
200 ? 'Preview commit candidate has multiple hard-link names and cannot be published safely.'
201 : 'Preview output has multiple hard-link names; atomic publication cannot update every name.';
202 } else if (code === 'target-not-regular-file' || code === 'candidate-not-regular-file') {
203 diagnosticCode = 'output/target-not-regular-file';
204 message = code === 'candidate-not-regular-file'
205 ? 'Preview commit candidate is no longer a regular file.'
206 : 'Preview output already exists and is not a regular file.';
207 } else if (result?.status === 'different') {
208 diagnosticCode = 'output/target-changed';
209 message = `Preview output changed while the verified candidate was being prepared (${code}).`;
210 } else {
211 message = `Preview output stability could not be determined safely (${code}).`;
212 }
213 return {
214 code: diagnosticCode,
215 message,
216 evidence: { relation: result?.reason || { code } },
217 };
220function atomicOutputError(result) {
221 const failure = atomicOutputFailure(result);
222 return Object.assign(new Error(failure.message), { previewFailure: failure });
225function stagePreviewCommit(commitPath, artifact, mode) {
226 for (let attempt = 0; attempt < 100; attempt += 1) {
227 previewCommitSequence += 1;
228 const candidatePath = path.join(
229 path.dirname(commitPath),
230 `.archify-preview-commit-${process.pid}-${Date.now().toString(36)}-${previewCommitSequence}.tmp`,
231 );
232 let descriptor;
233 let identity;
234 try {
235 const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
236 descriptor = fs.openSync(
237 candidatePath,
238 fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
239 mode ?? 0o666,
240 );
241 let metadata;
242 try {
243 metadata = fs.fstatSync(descriptor, { bigint: true });
244 } catch (error) {
245 try {
246 const retry = fs.fstatSync(descriptor, { bigint: true });
247 if (retry.isFile() && retry.ino !== 0n) {
248 identity = { device: retry.dev, inode: retry.ino };
249 }
250 } catch {}
251 throw error;
252 }
253 if (!metadata.isFile() || metadata.ino === 0n) {
254 throw new Error('Preview commit candidate identity could not be verified safely.');
255 }
256 identity = { device: metadata.dev, inode: metadata.ino };
257 fs.writeFileSync(descriptor, artifact);
258 if (mode !== null) fs.fchmodSync(descriptor, mode);
259 fs.closeSync(descriptor);
260 descriptor = undefined;
261 return { candidatePath, identity };
262 } catch (error) {
263 if (descriptor !== undefined) {
264 try { fs.closeSync(descriptor); } catch {}
265 }
266 if (error.code === 'EEXIST') continue;
267 if (identity) removeOwnedRegularFile(candidatePath, identity);
268 throw error;
269 }
270 }
271 throw Object.assign(new Error('Could not reserve a preview commit candidate beside the output.'), {
272 code: 'EEXIST',
273 errno: -17,
274 syscall: 'open',
275 });
278function captureOwnedDirectory(directoryPath) {
279 const metadata = fs.lstatSync(directoryPath, { bigint: true });
280 if (!metadata.isDirectory() || metadata.ino === 0n) {
281 throw new Error('Preview staging directory identity could not be verified safely.');
282 }
283 return { device: metadata.dev, inode: metadata.ino };
286function cleanupOwnedDirectory(directoryPath, identity) {
287 let current;
288 try {
289 current = fs.lstatSync(directoryPath, { bigint: true });
290 } catch (error) {
291 if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return;
292 throw error;
293 }
294 // Preserve a replacement rather than recursively deleting a directory that
295 // this preview instance did not create.
296 if (!current.isDirectory()
297 || current.ino === 0n
298 || current.dev !== identity.device
299 || current.ino !== identity.inode) return;
300 try {
301 removeEmptyDirectoryWithRetry(directoryPath);
302 } catch (error) {
303 // An entry whose identity was never bound to this preview may be an
304 // external claimant. Preserve the private directory as recovery material
305 // instead of recursively deleting unknown contents.
306 if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error;
307 }
310function captureOwnedDeliverySidecars(candidatePath, snapshotPath, receipt) {
311 const suffixes = ['.delivery.json', '.delivery-pending.json'];
312 const capturedSidecars = [];
313 for (const suffix of suffixes) {
314 const sidecarPath = candidatePath.replace(/\.html$/iu, suffix);
315 const captured = captureRegularFileBinding(sidecarPath, {
316 subject: 'preview-delivery-sidecar',
317 expectedLinks: 1,
318 includeContent: true,
319 });
320 if (captured.status !== 'captured') continue;
321 try {
322 const sidecar = JSON.parse(captured.content.buffer.toString('utf8'));
323 const commonMatches = sidecar?.schemaVersion === 1
324 && sidecar.command === 'deliver'
325 && sameLocation(sidecar.output, candidatePath).status === 'match'
326 && (!receipt?.receiptId || sidecar.receiptId === receipt.receiptId);
327 const currentMatches = suffix === '.delivery.json'
328 && commonMatches
329 && sidecar.status === 'current'
330 && receipt?.artifact?.sha256
331 && sidecar.artifact?.sha256 === receipt.artifact.sha256;
332 const pendingMatches = suffix === '.delivery-pending.json'
333 && commonMatches
334 && sidecar.status === 'pending'
335 && sameLocation(sidecar.input, snapshotPath).status === 'match';
336 if (currentMatches || pendingMatches) {
337 capturedSidecars.push({ path: sidecarPath, identity: captured.identity });
338 }
339 } catch {
340 // Preserve malformed or claimant-controlled sidecars for recovery.
341 } finally {
342 releaseRegularFileBinding(captured.binding);
343 }
344 }
345 return capturedSidecars;
348export async function startPreview(options) {
349 const type = options.type;
350 if (!diagramTypes.has(type)) throw new Error(`Unknown diagram type "${type}".`);
351 if (options.quality && !['standard', 'showcase'].includes(options.quality)) {
352 throw new Error(`Unknown quality profile "${options.quality}".`);
353 }
354 const inputPath = path.resolve(options.input);
355 const outputRequest = {
356 requestedOutput: options.output,
357 authoredOutput: initialAuthoredOutput(inputPath),
358 defaultOutput: `${type}.html`,
359 inputPaths: [inputPath],
360 inputDescription: 'its JSON input',
361 cwd: options.cwd || process.cwd(),
362 };
363 const { outputPath } = resolveOutputPath(outputRequest);
364 const outputDirectory = path.dirname(outputPath);
365 const debounceMs = Number.isFinite(options.debounceMs) ? options.debounceMs : defaultDebounceMs;
366 const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : defaultPollMs;
367 const stopGraceMs = Number.isFinite(options.stopGraceMs) ? Math.max(0, options.stopGraceMs) : defaultStopGraceMs;
368 const stopKillMs = Number.isFinite(options.stopKillMs) ? Math.max(0, options.stopKillMs) : defaultStopKillMs;
369 const shouldOpen = options.open !== false;
371 fs.mkdirSync(outputDirectory, { recursive: true });
372 // Keep cleanup bound to the physical directory selected at startup. A
373 // symlink or junction in the requested output parent may be redirected while
374 // preview is running and must not redirect recursive cleanup to a claimant.
375 const physicalOutputDirectory = fs.realpathSync.native(outputDirectory);
376 const stagingDirectory = fs.mkdtempSync(path.toNamespacedPath(
377 path.join(physicalOutputDirectory, '.archify-preview-'),
378 ));
379 let stagingIdentity;
380 try {
381 stagingIdentity = captureOwnedDirectory(stagingDirectory);
382 } catch (error) {
383 try { fs.rmdirSync(stagingDirectory); } catch {}
384 throw error;
385 }
387 let port = 0;
388 let watcher;
389 let debounceTimer;
390 let pollTimer;
391 let stopGraceTimer;
392 let stopKillTimer;
393 let child;
394 let stopping = false;
395 let forceStopping = false;
396 let stopped = false;
397 let serverClosing = false;
398 let serverClosed = false;
399 let queuedHash = null;
400 let activeHash = null;
401 let lastGoodSourceHash = null;
402 let sourceEpoch = 0;
403 let activeEpoch = 0;
404 let pendingBuild = false;
405 let artifactBuffer = null;
406 const clients = new Set();
407 const sockets = new Set();
408 const state = {
409 schemaVersion: 1,
410 status: 'checking',
411 generation: 0,
412 revision: 0,
413 lastVerified: null,
414 failure: null,
415 };
417 let resolveClosed;
418 const closed = new Promise((resolve) => { resolveClosed = resolve; });
420 function publicState() {
421 return JSON.parse(JSON.stringify(state));
422 }
424 function sendState(res) {
425 res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`);
426 }
428 function broadcast() {
429 for (const res of clients) sendState(res);
430 }
432 const page = Buffer.from(previewPage());
433 const server = http.createServer((req, res) => {
434 const expectedHost = `${loopbackHost}:${port}`;
435 if (req.headers.host !== expectedHost) {
436 res.writeHead(403, responseHeaders('text/plain; charset=utf-8'));
437 res.end('Forbidden host');
438 return;
439 }
440 if (req.method !== 'GET' && req.method !== 'HEAD') {
441 res.writeHead(405, { ...responseHeaders('text/plain; charset=utf-8'), Allow: 'GET, HEAD' });
442 res.end('Method not allowed');
443 return;
444 }
446 let url;
447 try {
448 url = new URL(req.url, `http://${expectedHost}`);
449 } catch {
450 res.writeHead(400, responseHeaders('text/plain; charset=utf-8'));
451 res.end('Bad request');
452 return;
453 }
455 if (url.pathname === '/') {
456 res.writeHead(200, {
457 ...responseHeaders('text/html; charset=utf-8'),
458 'Content-Security-Policy': "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
459 'Content-Length': page.byteLength,
460 });
461 if (req.method === 'HEAD') res.end();
462 else res.end(page);
463 return;
464 }
465 if (url.pathname === '/state') {
466 const body = Buffer.from(`${safeJson(publicState())}\n`);
467 res.writeHead(200, { ...responseHeaders('application/json; charset=utf-8'), 'Content-Length': body.byteLength });
468 if (req.method === 'HEAD') res.end();
469 else res.end(body);
470 return;
471 }
472 if (url.pathname === '/artifact.html') {
473 if (!artifactBuffer) {
474 res.writeHead(404, responseHeaders('text/plain; charset=utf-8'));
475 res.end('No verified artifact yet');
476 return;
477 }
478 res.writeHead(200, { ...responseHeaders('text/html; charset=utf-8'), 'Content-Length': artifactBuffer.byteLength });
479 if (req.method === 'HEAD') res.end();
480 else res.end(artifactBuffer);
481 return;
482 }
483 if (url.pathname === '/events' && req.method === 'GET') {
484 res.writeHead(200, {
485 ...responseHeaders('text/event-stream; charset=utf-8'),
486 Connection: 'keep-alive',
487 });
488 res.write('retry: 1000\n\n');
489 clients.add(res);
490 sendState(res);
491 req.on('close', () => clients.delete(res));
492 return;
493 }
495 res.writeHead(404, responseHeaders('text/plain; charset=utf-8'));
496 res.end('Not found');
497 });
499 server.on('connection', (socket) => {
500 sockets.add(socket);
501 socket.once('close', () => sockets.delete(socket));
502 // A connection event already queued when force-stop begins must not keep
503 // server.close() waiting after the current sockets have been destroyed.
504 if (forceStopping) socket.destroy();
505 });
507 try {
508 await new Promise((resolve, reject) => {
509 server.once('error', reject);
510 server.listen(0, loopbackHost, () => {
511 server.off('error', reject);
512 port = server.address().port;
513 resolve();
514 });
515 });
516 } catch (error) {
517 try { server.close(); } catch {}
518 cleanupOwnedDirectory(stagingDirectory, stagingIdentity);
519 throw error;
520 }
522 const url = `http://${loopbackHost}:${port}/`;
524 function finishStop() {
525 if (stopped || child || !serverClosed) return;
526 stopped = true;
527 clearTimeout(debounceTimer);
528 clearInterval(pollTimer);
529 clearTimeout(stopGraceTimer);
530 clearTimeout(stopKillTimer);
531 try {
532 cleanupOwnedDirectory(stagingDirectory, stagingIdentity);
533 } finally {
534 resolveClosed();
535 }
536 }
538 function signalActiveChild(signal) {
539 if (!child || child.exitCode !== null || child.signalCode !== null) return;
540 try {
541 if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
542 else child.kill(signal);
543 } catch (error) {
544 if (error.code === 'ESRCH') return;
545 try { child.kill(signal); } catch {}
546 }
547 }
549 function closeServer() {
550 if (serverClosing) return;
551 serverClosing = true;
552 for (const res of clients) res.end();
553 clients.clear();
554 server.close(() => {
555 serverClosed = true;
556 finishStop();
557 });
558 server.closeIdleConnections?.();
559 }
561 function startBoundedChildDrain() {
562 if (!child || stopGraceTimer || stopKillTimer) return;
563 stopGraceTimer = setTimeout(() => {
564 stopGraceTimer = undefined;
565 if (!child) return finishStop();
566 signalActiveChild('SIGTERM');
567 stopKillTimer = setTimeout(() => {
568 stopKillTimer = undefined;
569 signalActiveChild('SIGKILL');
570 }, stopKillMs);
571 }, stopGraceMs);
572 }
574 async function stop({ force = false } = {}) {
575 if (force) forceStopping = true;
576 if (!stopping) {
577 stopping = true;
578 clearTimeout(debounceTimer);
579 clearInterval(pollTimer);
580 watcher?.close();
581 closeServer();
582 }
583 if (forceStopping) {
584 for (const socket of sockets) socket.destroy();
585 }
586 if (child && force) {
587 clearTimeout(stopGraceTimer);
588 clearTimeout(stopKillTimer);
589 stopGraceTimer = undefined;
590 stopKillTimer = undefined;
591 signalActiveChild('SIGKILL');
592 } else if (child) {
593 startBoundedChildDrain();
594 } else {
595 finishStop();
596 }
597 return closed;
598 }
600 function publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath) {
601 const repairDetails = receipt?.diagnostics
602 ?.slice(0, 12)
603 .map((entry) => {
604 const fix = entry.supportedFixes?.length ? `\nFix: ${entry.supportedFixes.join('; ')}` : '';
605 return `[${entry.code}] ${entry.message}${fix}`;
606 }) || [];
607 const checkerDetails = receipt?.checker?.checks
608 ?.filter((check) => !check.ok)
609 .flatMap((check) => check.details || [])
610 .filter(Boolean)
611 .slice(0, 12) || [];
612 const diagnostic = [
613 receipt?.error,
614 ...(repairDetails.length ? repairDetails : checkerDetails),
615 ].filter(Boolean).join('\n') || stderr || stdout;
616 state.status = 'needs-fix';
617 state.failure = {
618 stage: receipt?.stage || 'render',
619 ...(receipt?.code ? { code: receipt.code } : {}),
620 ...(receipt?.evidence ? { evidence: receipt.evidence } : {}),
621 message: redactDiagnostic(
622 diagnostic,
623 [
624 [inputPath, '<input.json>'],
625 [outputPath, '<output.html>'],
626 [snapshotPath, '<input.json>'],
627 [candidatePath, '<candidate.html>'],
628 [stagingDirectory, '<preview-staging>'],
629 [path.resolve(here, '..'), '<archify-skill>'],
630 [path.resolve(options.cwd || process.cwd()), '<working-directory>'],
631 ...(options.repoRoot ? [[path.resolve(options.repoRoot), '<repo-root>']] : []),
632 ],
633 ),
634 };
635 broadcast();
636 }
638 function commitCandidate(candidatePath, receipt, generationHash, outputCapture) {
639 let candidate;
640 let sourceCandidateBinding;
641 let sourceCandidateIdentity;
642 let commitCandidatePath;
643 let commitCandidateIdentity;
644 let commitCandidateBinding;
645 try {
646 const sourceCapture = captureRegularFileBinding(candidatePath, {
647 subject: 'candidate',
648 expectedSha256: receipt?.artifact?.sha256,
649 expectedBytes: receipt?.artifact?.bytes,
650 includeContent: true,
651 });
652 if (sourceCapture.status !== 'captured') throw atomicOutputError(sourceCapture);
653 sourceCandidateBinding = sourceCapture.binding;
654 sourceCandidateIdentity = sourceCapture.identity;
655 candidate = sourceCapture.content.buffer;
656 const digest = sourceCapture.content.sha256;
657 const releasedSource = releaseRegularFileBinding(sourceCandidateBinding);
658 sourceCandidateBinding = undefined;
659 if (releasedSource.status !== 'released') throw atomicOutputError(releasedSource);
660 resolveOutputPath(outputRequest);
661 const sameArtifact = state.lastVerified?.sha256 === digest;
662 const currentSource = sourceDigest(inputPath);
663 if (currentSource.hash !== generationHash) {
664 return {
665 committed: false,
666 supersededBy: currentSource,
667 candidateIdentity: sourceCandidateIdentity,
668 };
669 }
670 const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
671 if (beforeStage.status !== 'match') throw atomicOutputError(beforeStage);
672 ({
673 candidatePath: commitCandidatePath,
674 identity: commitCandidateIdentity,
675 } = stagePreviewCommit(outputCapture.commitPath, candidate, outputCapture.mode));
676 const candidateCapture = captureRegularFileBinding(commitCandidatePath, {
677 subject: 'candidate',
678 expectedSha256: digest,
679 expectedBytes: candidate.byteLength,
680 expectedIdentity: commitCandidateIdentity,
681 ...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
682 });
683 if (candidateCapture.status !== 'captured') throw atomicOutputError(candidateCapture);
684 commitCandidateBinding = candidateCapture.binding;
685 const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
686 if (beforeCommit.status !== 'match') throw atomicOutputError(beforeCommit);
687 const publication = publishRegularFileBinding(
688 commitCandidateBinding,
689 commitCandidatePath,
690 outputCapture.snapshot,
691 { subject: 'candidate' },
692 );
693 if (!['committed', 'committed-with-warning'].includes(publication.status)) {
694 throw atomicOutputError(publication);
695 }
696 const releasedCandidate = releaseRegularFileBinding(commitCandidateBinding);
697 commitCandidateBinding = undefined;
698 if (releasedCandidate.status !== 'released') throw atomicOutputError(releasedCandidate);
699 commitCandidatePath = undefined;
700 commitCandidateIdentity = undefined;
701 artifactBuffer = candidate;
702 lastGoodSourceHash = generationHash;
703 state.status = 'verified';
704 if (!sameArtifact) {
705 state.revision += 1;
706 state.lastVerified = {
707 sha256: digest,
708 bytes: candidate.byteLength,
709 checksPassed: receipt.validation.checksPassed,
710 checkCount: receipt.validation.checkCount,
711 compositionProfile: receipt.validation.compositionProfile,
712 compositionStatus: receipt.validation.compositionStatus,
713 };
714 }
715 state.failure = null;
716 broadcast();
717 return { committed: true, supersededBy: null, candidateIdentity: sourceCandidateIdentity };
718 } catch (error) {
719 publishFailure({
720 stage: 'commit',
721 error: `Could not publish the verified preview: ${error.message}`,
722 ...(error.previewFailure || {}),
723 }, '', '', candidatePath);
724 return { committed: false, supersededBy: null, candidateIdentity: sourceCandidateIdentity };
725 } finally {
726 if (sourceCandidateBinding) releaseRegularFileBinding(sourceCandidateBinding);
727 if (commitCandidateBinding) releaseRegularFileBinding(commitCandidateBinding);
728 if (commitCandidatePath && commitCandidateIdentity) {
729 removeOwnedRegularFile(commitCandidatePath, commitCandidateIdentity);
730 }
731 }
732 }
734 function beginBuild(digest, epoch) {
735 if (stopping || child) return;
736 activeHash = digest.hash;
737 activeEpoch = epoch;
738 state.generation += 1;
739 state.status = 'checking';
740 state.failure = null;
741 broadcast();
743 const candidatePath = path.join(stagingDirectory, `generation-${state.generation}.html`);
744 const snapshotPath = path.join(stagingDirectory, `generation-${state.generation}.json`);
745 let snapshotIdentity;
746 const outputCapture = captureAtomicOutput(outputPath);
747 if (outputCapture.status !== 'captured') {
748 const failure = atomicOutputFailure(outputCapture);
749 publishFailure(
750 { stage: 'prepare', error: failure.message, ...failure },
751 '',
752 '',
753 candidatePath,
754 snapshotPath,
755 );
756 return;
757 }
758 const beforeBuild = verifyAtomicOutput(outputCapture.snapshot);
759 if (beforeBuild.status !== 'match') {
760 const failure = atomicOutputFailure(beforeBuild);
761 publishFailure(
762 { stage: 'prepare', error: failure.message, ...failure },
763 '',
764 '',
765 candidatePath,
766 snapshotPath,
767 );
768 return;
769 }
770 if (digest.bytes !== null) {
771 try {
772 fs.writeFileSync(snapshotPath, digest.bytes, { flag: 'wx', mode: 0o600 });
773 const metadata = fs.lstatSync(snapshotPath, { bigint: true });
774 if (metadata.isFile() && metadata.ino !== 0n) {
775 snapshotIdentity = { device: metadata.dev, inode: metadata.ino };
776 }
777 } catch (error) {
778 publishFailure(
779 { stage: 'prepare', error: `Could not snapshot the observed input: ${error.message}` },
780 '',
781 '',
782 candidatePath,
783 snapshotPath,
784 );
785 return;
786 }
787 }
788 const args = [options.deliveryCli || cliPath, 'deliver', type, snapshotPath, candidatePath, '--json'];
789 if (options.quality) args.push('--quality', options.quality);
790 if (options.repoRoot) args.push('--repo-root', path.resolve(options.repoRoot));
791 let stdout = '';
792 let stderr = '';
793 child = spawn(process.execPath, args, {
794 cwd: options.cwd || process.cwd(),
795 env: process.env,
796 stdio: ['ignore', 'pipe', 'pipe'],
797 detached: process.platform !== 'win32',
798 });
799 child.stdout.setEncoding('utf8');
800 child.stderr.setEncoding('utf8');
801 child.stdout.on('data', (chunk) => { stdout += chunk; });
802 child.stderr.on('data', (chunk) => { stderr += chunk; });
803 child.on('error', (error) => { stderr += error.message; });
804 child.on('close', (code) => {
805 const receipt = parseReceipt(stdout);
806 const generationEpoch = activeEpoch;
807 const generationHash = activeHash;
808 const stale = generationEpoch !== sourceEpoch;
809 let supersededBy = null;
810 let candidateIdentity;
811 const deliverySidecars = captureOwnedDeliverySidecars(
812 candidatePath,
813 snapshotPath,
814 receipt,
815 );
816 child = null;
817 clearTimeout(stopGraceTimer);
818 clearTimeout(stopKillTimer);
819 stopGraceTimer = undefined;
820 stopKillTimer = undefined;
821 if (!stopping && !stale && code === 0 && receipt?.ok) {
822 ({ supersededBy, candidateIdentity } = commitCandidate(
823 candidatePath,
824 receipt,
825 generationHash,
826 outputCapture,
827 ));
828 } else if (code === 0 && receipt?.ok) {
829 const abandonedCandidate = captureRegularFileBinding(candidatePath, {
830 subject: 'abandoned-preview-candidate',
831 expectedSha256: receipt.artifact?.sha256,
832 expectedBytes: receipt.artifact?.bytes,
833 });
834 if (abandonedCandidate.status === 'captured') {
835 candidateIdentity = abandonedCandidate.identity;
836 releaseRegularFileBinding(abandonedCandidate.binding);
837 }
838 } else if (!stopping && !stale) {
839 publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath);
840 }
841 if (candidateIdentity) removeOwnedRegularFile(candidatePath, candidateIdentity);
842 if (snapshotIdentity) removeOwnedRegularFile(snapshotPath, snapshotIdentity, { subject: 'snapshot' });
843 for (const deliverySidecar of deliverySidecars) {
844 removeOwnedRegularFile(deliverySidecar.path, deliverySidecar.identity, {
845 subject: 'preview-delivery-sidecar',
846 });
847 }
849 if (stopping) {
850 finishStop();
851 } else if (pendingBuild || stale || supersededBy) {
852 pendingBuild = false;
853 if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1;
854 const digest = supersededBy || sourceDigest(inputPath);
855 queueStableBuild(digest.hash, true);
856 }
857 });
858 }
860 function queueStableBuild(hash, immediate = false) {
861 queuedHash = hash;
862 clearTimeout(debounceTimer);
863 const launch = () => {
864 if (stopping) return;
865 const digest = sourceDigest(inputPath);
866 if (digest.hash !== queuedHash) {
867 queueStableBuild(digest.hash);
868 return;
869 }
870 if (digest.hash === lastGoodSourceHash) {
871 if (state.status !== 'verified' && state.lastVerified) {
872 state.status = 'verified';
873 state.failure = null;
874 broadcast();
875 }
876 return;
877 }
878 if (child) {
879 pendingBuild = true;
880 return;
881 }
882 beginBuild(digest, sourceEpoch);
883 };
884 debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs);
885 }
887 function observeSource({ immediate = false } = {}) {
888 const digest = sourceDigest(inputPath);
889 if (!immediate && digest.hash === queuedHash) return;
890 sourceEpoch += 1;
891 queueStableBuild(digest.hash, immediate);
892 }
894 if (options.watch !== false) {
895 try {
896 // `path.resolve` keeps Windows 8.3 names intact. Canonicalize short names
897 // and junctions before libuv opens the directory so its callback path has
898 // the same prefix as the watched path.
899 const watchedDirectory = fs.realpathSync.native(path.dirname(inputPath));
900 watcher = fs.watch(watchedDirectory, (event, filename) => {
901 // On Windows the watcher hands us just the basename; on POSIX it can be
902 // null. Resolve named events against the directory libuv actually opened
903 // so file-level case, 8.3, and hard-link aliases still identify the input.
904 if (
905 !filename
906 || sameLocation(path.join(watchedDirectory, filename.toString()), inputPath).status === 'match'
907 ) {
908 observeSource();
909 }
910 });
911 watcher.on('error', () => {
912 const failedWatcher = watcher;
913 watcher = undefined;
914 failedWatcher?.close();
915 });
916 } catch (error) {
917 await stop();
918 throw new Error(`Could not watch the input directory: ${error.message}`);
919 }
920 }
921 pollTimer = setInterval(() => observeSource(), pollMs);
923 let opener = null;
924 if (shouldOpen) {
925 try {
926 opener = openLoopbackUrl(url);
927 } catch {
928 opener = { requested: true, status: 'unsupported', target: url, method: null };
929 }
930 }
932 observeSource({ immediate: true });
934 return {
935 url,
936 input: inputPath,
937 output: outputPath,
938 opener,
939 state: publicState,
940 stop,
941 closed,
942 };
945export async function runPreview(options) {
946 const preview = await startPreview(options);
947 console.log(`preview ${preview.url}`);
948 console.log(`watching ${preview.input}`);
949 console.log(`output ${preview.output}`);
950 if (preview.opener && preview.opener.status !== 'opened') {
951 console.error(`Could not open the preview (${preview.opener.status}). ${preview.opener.failure?.reason || 'Open it manually.'} Target: ${preview.url}`);
952 }
954 let signalCount = 0;
955 const stop = () => {
956 signalCount += 1;
957 if (signalCount === 1) {
958 console.log('\nstopping preview…');
959 preview.stop();
960 } else {
961 console.log('\nforcing preview shutdown…');
962 preview.stop({ force: true });
963 }
964 };
965 process.on('SIGINT', stop);
966 process.on('SIGTERM', stop);
967 await preview.closed;
968 process.off('SIGINT', stop);
969 process.off('SIGTERM', stop);