dsh-archify-projects 0.0.0-stage → 0.1.0

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