@saluzi/saluzi-edu 0.4.7 → 0.4.8

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 (82) hide show
  1. package/dist/cli.js +51 -60
  2. package/dist/guide/guide-data.json +1 -1
  3. package/dist/vendor/archify/LICENSE +22 -0
  4. package/dist/vendor/archify/SKILL.md +137 -0
  5. package/dist/vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  6. package/dist/vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  7. package/dist/vendor/archify/assets/template.html +14934 -0
  8. package/dist/vendor/archify/bin/archify.mjs +2687 -0
  9. package/dist/vendor/archify/bin/open-artifact.mjs +95 -0
  10. package/dist/vendor/archify/bin/preview.mjs +764 -0
  11. package/dist/vendor/archify/bin/visual-check.mjs +1012 -0
  12. package/dist/vendor/archify/brand-marks/README.md +31 -0
  13. package/dist/vendor/archify/brand-marks/catalog.json +131 -0
  14. package/dist/vendor/archify/delta/architecture-delta.mjs +1221 -0
  15. package/dist/vendor/archify/examples/agent-run.lifecycle.json +60 -0
  16. package/dist/vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  17. package/dist/vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  18. package/dist/vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  19. package/dist/vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  20. package/dist/vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  21. package/dist/vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  22. package/dist/vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  23. package/dist/vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  24. package/dist/vendor/archify/examples/event-stream.dataflow.json +57 -0
  25. package/dist/vendor/archify/examples/incident-response.workflow.json +64 -0
  26. package/dist/vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  27. package/dist/vendor/archify/examples/product-analytics.dataflow.json +76 -0
  28. package/dist/vendor/archify/examples/production-deployment.architecture.json +71 -0
  29. package/dist/vendor/archify/examples/release-delivery.workflow.json +62 -0
  30. package/dist/vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  31. package/dist/vendor/archify/examples/web-app-rendered.html +15009 -0
  32. package/dist/vendor/archify/examples/web-app.architecture.json +46 -0
  33. package/dist/vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  34. package/dist/vendor/archify/migrations/workflow-v2.mjs +279 -0
  35. package/dist/vendor/archify/package.json +17 -0
  36. package/dist/vendor/archify/recipes/scenarios.mjs +391 -0
  37. package/dist/vendor/archify/references/authoring-contract.md +243 -0
  38. package/dist/vendor/archify/references/brand-marks.md +65 -0
  39. package/dist/vendor/archify/references/delivery-contract.md +120 -0
  40. package/dist/vendor/archify/references/viewer-runtime.md +45 -0
  41. package/dist/vendor/archify/renderers/architecture/grid.mjs +62 -0
  42. package/dist/vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  43. package/dist/vendor/archify/renderers/dataflow/README.md +104 -0
  44. package/dist/vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  45. package/dist/vendor/archify/renderers/lifecycle/README.md +115 -0
  46. package/dist/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  47. package/dist/vendor/archify/renderers/sequence/README.md +114 -0
  48. package/dist/vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  49. package/dist/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  50. package/dist/vendor/archify/renderers/shared/cli.mjs +218 -0
  51. package/dist/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  52. package/dist/vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  53. package/dist/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  54. package/dist/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  55. package/dist/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  56. package/dist/vendor/archify/renderers/shared/geometry.mjs +1423 -0
  57. package/dist/vendor/archify/renderers/shared/i18n.mjs +595 -0
  58. package/dist/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  59. package/dist/vendor/archify/renderers/shared/legend.mjs +217 -0
  60. package/dist/vendor/archify/renderers/shared/output-path.mjs +340 -0
  61. package/dist/vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  62. package/dist/vendor/archify/renderers/shared/repository-location.mjs +58 -0
  63. package/dist/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  64. package/dist/vendor/archify/renderers/shared/utils.mjs +232 -0
  65. package/dist/vendor/archify/renderers/shared/validator.mjs +86 -0
  66. package/dist/vendor/archify/renderers/workflow/README.md +223 -0
  67. package/dist/vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  68. package/dist/vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  69. package/dist/vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  70. package/dist/vendor/archify/schemas/README.md +211 -0
  71. package/dist/vendor/archify/schemas/architecture.schema.json +178 -0
  72. package/dist/vendor/archify/schemas/common.schema.json +115 -0
  73. package/dist/vendor/archify/schemas/dataflow.schema.json +243 -0
  74. package/dist/vendor/archify/schemas/lifecycle.schema.json +266 -0
  75. package/dist/vendor/archify/schemas/sequence.schema.json +223 -0
  76. package/dist/vendor/archify/schemas/workflow.schema.json +428 -0
  77. package/dist/vendor/archify/scripts/check-render-output.mjs +836 -0
  78. package/dist/vendor/archify/scripts/check-update.mjs +1667 -0
  79. package/dist/vendor/archify/scripts/render-examples.mjs +26 -0
  80. package/dist/vendor/archify/scripts/update-contract.mjs +182 -0
  81. package/dist/vendor/archify/skill-release.json +10 -0
  82. package/package.json +1 -1
@@ -0,0 +1,764 @@
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
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url))
12
+ const cliPath = path.join(here, 'archify.mjs')
13
+ const loopbackHost = '127.0.0.1'
14
+ const defaultDebounceMs = 400
15
+ const defaultPollMs = 800
16
+ const defaultStopGraceMs = 3000
17
+ const defaultStopKillMs = 750
18
+ const diagramTypes = new Set([
19
+ 'architecture',
20
+ 'workflow',
21
+ 'sequence',
22
+ 'dataflow',
23
+ 'lifecycle',
24
+ ])
25
+
26
+ function sha256(value) {
27
+ return createHash('sha256').update(value).digest('hex')
28
+ }
29
+
30
+ function sourceDigest(inputPath) {
31
+ try {
32
+ const bytes = fs.readFileSync(inputPath)
33
+ return { hash: sha256(bytes), bytes, missing: false }
34
+ } catch (error) {
35
+ return {
36
+ hash: `unreadable:${error.code || 'unknown'}`,
37
+ bytes: null,
38
+ missing: true,
39
+ }
40
+ }
41
+ }
42
+
43
+ function initialAuthoredOutput(inputPath) {
44
+ try {
45
+ const source = JSON.parse(fs.readFileSync(inputPath, 'utf8'))
46
+ if (typeof source?.meta?.output === 'string' && source.meta.output) {
47
+ return source.meta.output
48
+ }
49
+ } catch {
50
+ // An invalid initial source still gets a status shell. Its output target is
51
+ // fixed to the same fallback that `deliver` would use after repair.
52
+ }
53
+ return undefined
54
+ }
55
+
56
+ function previewPage() {
57
+ return `<!doctype html>
58
+ <html lang="en">
59
+ <head>
60
+ <meta charset="utf-8">
61
+ <meta name="viewport" content="width=device-width,initial-scale=1">
62
+ <title>Archify Live Preview</title>
63
+ <style>
64
+ :root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
65
+ * { box-sizing: border-box; }
66
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #0b111b; }
67
+ body { display: grid; grid-template-rows: auto minmax(0, 1fr); color: #e8edf5; }
68
+ 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); }
69
+ .brand { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #9cadc6; }
70
+ #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; }
71
+ #status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #6f819d; }
72
+ body[data-state="checking"] #status::before { background: #f3b44b; box-shadow: 0 0 0 4px rgba(243,180,75,.12); }
73
+ body[data-state="verified"] #status::before { background: #45d6a8; box-shadow: 0 0 0 4px rgba(69,214,168,.12); }
74
+ body[data-state="needs-fix"] #status::before { background: #ff6f78; box-shadow: 0 0 0 4px rgba(255,111,120,.12); }
75
+ details { max-width: min(62vw, 760px); }
76
+ summary { cursor: pointer; color: #ffbdc2; font-size: 12px; }
77
+ .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); }
78
+ pre { margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; color: #f2dfe2; }
79
+ button { min-height: 32px; padding: 5px 10px; border: 1px solid #4a5d79; border-radius: 7px; background: #1a273a; color: #eef4ff; cursor: pointer; }
80
+ main { position: relative; min-height: 0; }
81
+ iframe { display: none; width: 100%; height: 100%; border: 0; background: #fff; }
82
+ body[data-has-artifact="true"] iframe { display: block; }
83
+ #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%); }
84
+ body[data-has-artifact="true"] #empty { display: none; }
85
+ @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
86
+ </style>
87
+ </head>
88
+ <body data-state="checking" data-has-artifact="false">
89
+ <header>
90
+ <span class="brand">Archify Preview</span>
91
+ <details id="failure" hidden>
92
+ <summary role="button" aria-controls="diagnostic-panel">View diagnostic</summary>
93
+ <div class="diagnostic" id="diagnostic-panel"><pre id="diagnostic"></pre><button id="copy" type="button">Copy diagnostic</button></div>
94
+ </details>
95
+ <span id="status" role="status" aria-live="polite">Checking · generation 1</span>
96
+ </header>
97
+ <main>
98
+ <div id="empty">Waiting for the first verified diagram. Invalid input will stay here with an exact diagnostic.</div>
99
+ <iframe id="artifact" title="Verified Archify diagram"></iframe>
100
+ </main>
101
+ <script>
102
+ (function () {
103
+ 'use strict';
104
+ var body = document.body;
105
+ var status = document.getElementById('status');
106
+ var failure = document.getElementById('failure');
107
+ var diagnostic = document.getElementById('diagnostic');
108
+ var artifact = document.getElementById('artifact');
109
+ var lastRevision = 0;
110
+
111
+ function render(state) {
112
+ body.dataset.state = state.status;
113
+ if (state.status === 'verified') {
114
+ status.textContent = 'Verified · rev ' + state.revision;
115
+ failure.hidden = true;
116
+ failure.open = false;
117
+ if (state.revision !== lastRevision) {
118
+ lastRevision = state.revision;
119
+ artifact.src = '/artifact.html?revision=' + encodeURIComponent(state.revision) + '&sha=' + encodeURIComponent(state.lastVerified.sha256.slice(0, 12));
120
+ body.dataset.hasArtifact = 'true';
121
+ }
122
+ } else if (state.status === 'needs-fix') {
123
+ status.textContent = 'Needs fix · ' + (state.revision ? 'showing rev ' + state.revision : 'no verified revision');
124
+ diagnostic.textContent = 'Generation ' + state.generation + ' · ' + state.failure.stage + '\\n\\n' + state.failure.message;
125
+ failure.hidden = false;
126
+ } else {
127
+ status.textContent = 'Checking · generation ' + state.generation;
128
+ failure.hidden = true;
129
+ }
130
+ }
131
+
132
+ document.getElementById('copy').addEventListener('click', function () {
133
+ if (navigator.clipboard && navigator.clipboard.writeText) {
134
+ navigator.clipboard.writeText(diagnostic.textContent).catch(function () {});
135
+ }
136
+ });
137
+
138
+ var events = new EventSource('/events');
139
+ events.addEventListener('state', function (event) {
140
+ try { render(JSON.parse(event.data)); } catch (_) {}
141
+ });
142
+ }());
143
+ </script>
144
+ </body>
145
+ </html>`
146
+ }
147
+
148
+ function compactMessage(value) {
149
+ let text = String(
150
+ value || 'Preview build failed without a diagnostic.',
151
+ ).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(
157
+ (line, index) => index > 0 && /^\s*at\s/.test(line),
158
+ )
159
+ if (stackLine > 0) text = relevant.slice(0, stackLine).join('\n')
160
+ return text.length > 6000
161
+ ? `${text.slice(0, 6000)}\n… diagnostic truncated`
162
+ : text
163
+ }
164
+
165
+ function redactDiagnostic(value, paths) {
166
+ let text = compactMessage(value)
167
+ for (const [absolutePath, replacement] of paths) {
168
+ if (!absolutePath) continue
169
+ text = text.split(absolutePath).join(replacement)
170
+ }
171
+ return text
172
+ }
173
+
174
+ function safeJson(value) {
175
+ return JSON.stringify(value).replace(/</g, '\\u003c')
176
+ }
177
+
178
+ function responseHeaders(contentType) {
179
+ return {
180
+ 'Cache-Control': 'no-store',
181
+ 'Content-Type': contentType,
182
+ 'Cross-Origin-Resource-Policy': 'same-origin',
183
+ 'Referrer-Policy': 'no-referrer',
184
+ 'X-Content-Type-Options': 'nosniff',
185
+ 'X-Frame-Options': 'SAMEORIGIN',
186
+ }
187
+ }
188
+
189
+ function parseReceipt(stdout) {
190
+ try {
191
+ return JSON.parse(stdout)
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ export async function startPreview(options) {
198
+ const type = options.type
199
+ if (!diagramTypes.has(type))
200
+ throw new Error(`Unknown diagram type "${type}".`)
201
+ if (options.quality && !['standard', 'showcase'].includes(options.quality)) {
202
+ throw new Error(`Unknown quality profile "${options.quality}".`)
203
+ }
204
+ const inputPath = path.resolve(options.input)
205
+ const outputRequest = {
206
+ requestedOutput: options.output,
207
+ authoredOutput: initialAuthoredOutput(inputPath),
208
+ defaultOutput: `${type}.html`,
209
+ inputPaths: [inputPath],
210
+ inputDescription: 'its JSON input',
211
+ cwd: options.cwd || process.cwd(),
212
+ }
213
+ const { outputPath } = resolveOutputPath(outputRequest)
214
+ const outputDirectory = path.dirname(outputPath)
215
+ const debounceMs = Number.isFinite(options.debounceMs)
216
+ ? options.debounceMs
217
+ : defaultDebounceMs
218
+ const pollMs = Number.isFinite(options.pollMs)
219
+ ? options.pollMs
220
+ : defaultPollMs
221
+ const stopGraceMs = Number.isFinite(options.stopGraceMs)
222
+ ? Math.max(0, options.stopGraceMs)
223
+ : defaultStopGraceMs
224
+ const stopKillMs = Number.isFinite(options.stopKillMs)
225
+ ? Math.max(0, options.stopKillMs)
226
+ : defaultStopKillMs
227
+ const shouldOpen = options.open !== false
228
+
229
+ fs.mkdirSync(outputDirectory, { recursive: true })
230
+ const stagingDirectory = fs.mkdtempSync(
231
+ path.join(outputDirectory, '.archify-preview-'),
232
+ )
233
+
234
+ let port = 0
235
+ let watcher
236
+ let debounceTimer
237
+ let pollTimer
238
+ let stopGraceTimer
239
+ let stopKillTimer
240
+ let child
241
+ let stopping = false
242
+ let stopped = false
243
+ let serverClosing = false
244
+ let serverClosed = false
245
+ let queuedHash = null
246
+ let activeHash = null
247
+ let lastGoodSourceHash = null
248
+ let sourceEpoch = 0
249
+ let activeEpoch = 0
250
+ let pendingBuild = false
251
+ let artifactBuffer = null
252
+ const clients = new Set()
253
+ const state = {
254
+ schemaVersion: 1,
255
+ status: 'checking',
256
+ generation: 0,
257
+ revision: 0,
258
+ lastVerified: null,
259
+ failure: null,
260
+ }
261
+
262
+ let resolveClosed
263
+ const closed = new Promise(resolve => {
264
+ resolveClosed = resolve
265
+ })
266
+
267
+ function publicState() {
268
+ return JSON.parse(JSON.stringify(state))
269
+ }
270
+
271
+ function sendState(res) {
272
+ res.write(`event: state\ndata: ${safeJson(publicState())}\n\n`)
273
+ }
274
+
275
+ function broadcast() {
276
+ for (const res of clients) sendState(res)
277
+ }
278
+
279
+ const page = Buffer.from(previewPage())
280
+ const server = http.createServer((req, res) => {
281
+ const expectedHost = `${loopbackHost}:${port}`
282
+ if (req.headers.host !== expectedHost) {
283
+ res.writeHead(403, responseHeaders('text/plain; charset=utf-8'))
284
+ res.end('Forbidden host')
285
+ return
286
+ }
287
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
288
+ res.writeHead(405, {
289
+ ...responseHeaders('text/plain; charset=utf-8'),
290
+ Allow: 'GET, HEAD',
291
+ })
292
+ res.end('Method not allowed')
293
+ return
294
+ }
295
+
296
+ let url
297
+ try {
298
+ url = new URL(req.url, `http://${expectedHost}`)
299
+ } catch {
300
+ res.writeHead(400, responseHeaders('text/plain; charset=utf-8'))
301
+ res.end('Bad request')
302
+ return
303
+ }
304
+
305
+ if (url.pathname === '/') {
306
+ res.writeHead(200, {
307
+ ...responseHeaders('text/html; charset=utf-8'),
308
+ 'Content-Security-Policy':
309
+ "default-src 'none'; frame-src 'self'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
310
+ 'Content-Length': page.byteLength,
311
+ })
312
+ if (req.method === 'HEAD') res.end()
313
+ else res.end(page)
314
+ return
315
+ }
316
+ if (url.pathname === '/state') {
317
+ const body = Buffer.from(`${safeJson(publicState())}\n`)
318
+ res.writeHead(200, {
319
+ ...responseHeaders('application/json; charset=utf-8'),
320
+ 'Content-Length': body.byteLength,
321
+ })
322
+ if (req.method === 'HEAD') res.end()
323
+ else res.end(body)
324
+ return
325
+ }
326
+ if (url.pathname === '/artifact.html') {
327
+ if (!artifactBuffer) {
328
+ res.writeHead(404, responseHeaders('text/plain; charset=utf-8'))
329
+ res.end('No verified artifact yet')
330
+ return
331
+ }
332
+ res.writeHead(200, {
333
+ ...responseHeaders('text/html; charset=utf-8'),
334
+ 'Content-Length': artifactBuffer.byteLength,
335
+ })
336
+ if (req.method === 'HEAD') res.end()
337
+ else res.end(artifactBuffer)
338
+ return
339
+ }
340
+ if (url.pathname === '/events' && req.method === 'GET') {
341
+ res.writeHead(200, {
342
+ ...responseHeaders('text/event-stream; charset=utf-8'),
343
+ Connection: 'keep-alive',
344
+ })
345
+ res.write('retry: 1000\n\n')
346
+ clients.add(res)
347
+ sendState(res)
348
+ req.on('close', () => clients.delete(res))
349
+ return
350
+ }
351
+
352
+ res.writeHead(404, responseHeaders('text/plain; charset=utf-8'))
353
+ res.end('Not found')
354
+ })
355
+
356
+ try {
357
+ await new Promise((resolve, reject) => {
358
+ server.once('error', reject)
359
+ server.listen(0, loopbackHost, () => {
360
+ server.off('error', reject)
361
+ port = server.address().port
362
+ resolve()
363
+ })
364
+ })
365
+ } catch (error) {
366
+ try {
367
+ server.close()
368
+ } catch {}
369
+ fs.rmSync(stagingDirectory, { recursive: true, force: true })
370
+ throw error
371
+ }
372
+
373
+ const url = `http://${loopbackHost}:${port}/`
374
+
375
+ function finishStop() {
376
+ if (stopped || child || !serverClosed) return
377
+ stopped = true
378
+ clearTimeout(debounceTimer)
379
+ clearInterval(pollTimer)
380
+ clearTimeout(stopGraceTimer)
381
+ clearTimeout(stopKillTimer)
382
+ try {
383
+ fs.rmSync(stagingDirectory, { recursive: true, force: true })
384
+ } finally {
385
+ resolveClosed()
386
+ }
387
+ }
388
+
389
+ function signalActiveChild(signal) {
390
+ if (!child || child.exitCode !== null || child.signalCode !== null) return
391
+ try {
392
+ if (process.platform !== 'win32' && child.pid)
393
+ process.kill(-child.pid, signal)
394
+ else child.kill(signal)
395
+ } catch (error) {
396
+ if (error.code === 'ESRCH') return
397
+ try {
398
+ child.kill(signal)
399
+ } catch {}
400
+ }
401
+ }
402
+
403
+ function closeServer() {
404
+ if (serverClosing) return
405
+ serverClosing = true
406
+ for (const res of clients) res.end()
407
+ clients.clear()
408
+ server.close(() => {
409
+ serverClosed = true
410
+ finishStop()
411
+ })
412
+ server.closeIdleConnections?.()
413
+ }
414
+
415
+ function startBoundedChildDrain() {
416
+ if (!child || stopGraceTimer || stopKillTimer) return
417
+ stopGraceTimer = setTimeout(() => {
418
+ stopGraceTimer = undefined
419
+ if (!child) return finishStop()
420
+ signalActiveChild('SIGTERM')
421
+ stopKillTimer = setTimeout(() => {
422
+ stopKillTimer = undefined
423
+ signalActiveChild('SIGKILL')
424
+ }, stopKillMs)
425
+ }, stopGraceMs)
426
+ }
427
+
428
+ async function stop({ force = false } = {}) {
429
+ if (!stopping) {
430
+ stopping = true
431
+ clearTimeout(debounceTimer)
432
+ clearInterval(pollTimer)
433
+ watcher?.close()
434
+ closeServer()
435
+ }
436
+ if (child && force) {
437
+ clearTimeout(stopGraceTimer)
438
+ clearTimeout(stopKillTimer)
439
+ stopGraceTimer = undefined
440
+ stopKillTimer = undefined
441
+ signalActiveChild('SIGKILL')
442
+ } else if (child) {
443
+ startBoundedChildDrain()
444
+ } else {
445
+ finishStop()
446
+ }
447
+ return closed
448
+ }
449
+
450
+ function publishFailure(
451
+ receipt,
452
+ stdout,
453
+ stderr,
454
+ candidatePath,
455
+ snapshotPath,
456
+ ) {
457
+ const repairDetails =
458
+ receipt?.diagnostics?.slice(0, 12).map(entry => {
459
+ const fix = entry.supportedFixes?.length
460
+ ? `\nFix: ${entry.supportedFixes.join('; ')}`
461
+ : ''
462
+ return `[${entry.code}] ${entry.message}${fix}`
463
+ }) || []
464
+ const checkerDetails =
465
+ receipt?.checker?.checks
466
+ ?.filter(check => !check.ok)
467
+ .flatMap(check => check.details || [])
468
+ .filter(Boolean)
469
+ .slice(0, 12) || []
470
+ const diagnostic =
471
+ [
472
+ receipt?.error,
473
+ ...(repairDetails.length ? repairDetails : checkerDetails),
474
+ ]
475
+ .filter(Boolean)
476
+ .join('\n') ||
477
+ stderr ||
478
+ stdout
479
+ state.status = 'needs-fix'
480
+ state.failure = {
481
+ stage: receipt?.stage || 'render',
482
+ message: redactDiagnostic(diagnostic, [
483
+ [inputPath, '<input.json>'],
484
+ [outputPath, '<output.html>'],
485
+ [snapshotPath, '<input.json>'],
486
+ [candidatePath, '<candidate.html>'],
487
+ [stagingDirectory, '<preview-staging>'],
488
+ [path.resolve(here, '..'), '<archify-skill>'],
489
+ [path.resolve(options.cwd || process.cwd()), '<working-directory>'],
490
+ ...(options.repoRoot
491
+ ? [[path.resolve(options.repoRoot), '<repo-root>']]
492
+ : []),
493
+ ]),
494
+ }
495
+ broadcast()
496
+ }
497
+
498
+ function commitCandidate(candidatePath, receipt, generationHash) {
499
+ let candidate
500
+ try {
501
+ candidate = fs.readFileSync(candidatePath)
502
+ const digest = sha256(candidate)
503
+ if (digest !== receipt?.artifact?.sha256) {
504
+ throw new Error(
505
+ 'Verified candidate bytes do not match the delivery receipt.',
506
+ )
507
+ }
508
+ resolveOutputPath(outputRequest)
509
+ const sameArtifact = state.lastVerified?.sha256 === digest
510
+ let outputMatches = false
511
+ if (sameArtifact) {
512
+ try {
513
+ outputMatches = sha256(fs.readFileSync(outputPath)) === digest
514
+ } catch {}
515
+ }
516
+ const currentSource = sourceDigest(inputPath)
517
+ if (currentSource.hash !== generationHash) {
518
+ return { committed: false, supersededBy: currentSource }
519
+ }
520
+ if (!sameArtifact || !outputMatches)
521
+ fs.renameSync(candidatePath, outputPath)
522
+ artifactBuffer = candidate
523
+ lastGoodSourceHash = generationHash
524
+ state.status = 'verified'
525
+ if (!sameArtifact) {
526
+ state.revision += 1
527
+ state.lastVerified = {
528
+ sha256: digest,
529
+ bytes: candidate.byteLength,
530
+ checksPassed: receipt.validation.checksPassed,
531
+ checkCount: receipt.validation.checkCount,
532
+ compositionProfile: receipt.validation.compositionProfile,
533
+ compositionStatus: receipt.validation.compositionStatus,
534
+ }
535
+ }
536
+ state.failure = null
537
+ broadcast()
538
+ return { committed: true, supersededBy: null }
539
+ } catch (error) {
540
+ publishFailure(
541
+ {
542
+ stage: 'commit',
543
+ error: `Could not publish the verified preview: ${error.message}`,
544
+ },
545
+ '',
546
+ '',
547
+ candidatePath,
548
+ )
549
+ return { committed: false, supersededBy: null }
550
+ }
551
+ }
552
+
553
+ function beginBuild(digest, epoch) {
554
+ if (stopping || child) return
555
+ activeHash = digest.hash
556
+ activeEpoch = epoch
557
+ state.generation += 1
558
+ state.status = 'checking'
559
+ state.failure = null
560
+ broadcast()
561
+
562
+ const candidatePath = path.join(
563
+ stagingDirectory,
564
+ `generation-${state.generation}.html`,
565
+ )
566
+ const snapshotPath = path.join(
567
+ stagingDirectory,
568
+ `generation-${state.generation}.json`,
569
+ )
570
+ if (digest.bytes !== null) {
571
+ try {
572
+ fs.writeFileSync(snapshotPath, digest.bytes, {
573
+ flag: 'wx',
574
+ mode: 0o600,
575
+ })
576
+ } catch (error) {
577
+ publishFailure(
578
+ {
579
+ stage: 'prepare',
580
+ error: `Could not snapshot the observed input: ${error.message}`,
581
+ },
582
+ '',
583
+ '',
584
+ candidatePath,
585
+ snapshotPath,
586
+ )
587
+ return
588
+ }
589
+ }
590
+ const args = [
591
+ options.deliveryCli || cliPath,
592
+ 'deliver',
593
+ type,
594
+ snapshotPath,
595
+ candidatePath,
596
+ '--json',
597
+ ]
598
+ if (options.quality) args.push('--quality', options.quality)
599
+ if (options.repoRoot)
600
+ args.push('--repo-root', path.resolve(options.repoRoot))
601
+ let stdout = ''
602
+ let stderr = ''
603
+ child = spawn(process.execPath, args, {
604
+ cwd: options.cwd || process.cwd(),
605
+ env: process.env,
606
+ stdio: ['ignore', 'pipe', 'pipe'],
607
+ detached: process.platform !== 'win32',
608
+ })
609
+ child.stdout.setEncoding('utf8')
610
+ child.stderr.setEncoding('utf8')
611
+ child.stdout.on('data', chunk => {
612
+ stdout += chunk
613
+ })
614
+ child.stderr.on('data', chunk => {
615
+ stderr += chunk
616
+ })
617
+ child.on('error', error => {
618
+ stderr += error.message
619
+ })
620
+ child.on('close', code => {
621
+ const receipt = parseReceipt(stdout)
622
+ const generationEpoch = activeEpoch
623
+ const generationHash = activeHash
624
+ const stale = generationEpoch !== sourceEpoch
625
+ let supersededBy = null
626
+ child = null
627
+ clearTimeout(stopGraceTimer)
628
+ clearTimeout(stopKillTimer)
629
+ stopGraceTimer = undefined
630
+ stopKillTimer = undefined
631
+ if (!stopping && !stale && code === 0 && receipt?.ok) {
632
+ ;({ supersededBy } = commitCandidate(
633
+ candidatePath,
634
+ receipt,
635
+ generationHash,
636
+ ))
637
+ } else if (!stopping && !stale) {
638
+ publishFailure(receipt, stdout, stderr, candidatePath, snapshotPath)
639
+ }
640
+ try {
641
+ fs.rmSync(candidatePath, { force: true })
642
+ } catch {}
643
+ try {
644
+ fs.rmSync(snapshotPath, { force: true })
645
+ } catch {}
646
+
647
+ if (stopping) {
648
+ finishStop()
649
+ } else if (pendingBuild || stale || supersededBy) {
650
+ pendingBuild = false
651
+ if (supersededBy && sourceEpoch === generationEpoch) sourceEpoch += 1
652
+ const digest = supersededBy || sourceDigest(inputPath)
653
+ queueStableBuild(digest.hash, true)
654
+ }
655
+ })
656
+ }
657
+
658
+ function queueStableBuild(hash, immediate = false) {
659
+ queuedHash = hash
660
+ clearTimeout(debounceTimer)
661
+ const launch = () => {
662
+ if (stopping) return
663
+ const digest = sourceDigest(inputPath)
664
+ if (digest.hash !== queuedHash) {
665
+ queueStableBuild(digest.hash)
666
+ return
667
+ }
668
+ if (digest.hash === lastGoodSourceHash) {
669
+ if (state.status !== 'verified' && state.lastVerified) {
670
+ state.status = 'verified'
671
+ state.failure = null
672
+ broadcast()
673
+ }
674
+ return
675
+ }
676
+ if (child) {
677
+ pendingBuild = true
678
+ return
679
+ }
680
+ beginBuild(digest, sourceEpoch)
681
+ }
682
+ debounceTimer = setTimeout(launch, immediate ? 0 : debounceMs)
683
+ }
684
+
685
+ function observeSource({ immediate = false } = {}) {
686
+ const digest = sourceDigest(inputPath)
687
+ if (!immediate && digest.hash === queuedHash) return
688
+ sourceEpoch += 1
689
+ queueStableBuild(digest.hash, immediate)
690
+ }
691
+
692
+ if (options.watch !== false) {
693
+ try {
694
+ watcher = fs.watch(path.dirname(inputPath), (event, filename) => {
695
+ if (!filename || filename.toString() === path.basename(inputPath))
696
+ observeSource()
697
+ })
698
+ watcher.on('error', () => {
699
+ const failedWatcher = watcher
700
+ watcher = undefined
701
+ failedWatcher?.close()
702
+ })
703
+ } catch (error) {
704
+ await stop()
705
+ throw new Error(`Could not watch the input directory: ${error.message}`)
706
+ }
707
+ }
708
+ pollTimer = setInterval(() => observeSource(), pollMs)
709
+
710
+ let opener = null
711
+ if (shouldOpen) {
712
+ try {
713
+ opener = openLoopbackUrl(url)
714
+ } catch {
715
+ opener = {
716
+ requested: true,
717
+ status: 'unsupported',
718
+ target: url,
719
+ method: null,
720
+ }
721
+ }
722
+ }
723
+
724
+ observeSource({ immediate: true })
725
+
726
+ return {
727
+ url,
728
+ input: inputPath,
729
+ output: outputPath,
730
+ opener,
731
+ state: publicState,
732
+ stop,
733
+ closed,
734
+ }
735
+ }
736
+
737
+ export async function runPreview(options) {
738
+ const preview = await startPreview(options)
739
+ console.log(`preview ${preview.url}`)
740
+ console.log(`watching ${preview.input}`)
741
+ console.log(`output ${preview.output}`)
742
+ if (preview.opener && preview.opener.status !== 'opened') {
743
+ console.error(
744
+ `Could not open the preview (${preview.opener.status}). Open it manually: ${preview.url}`,
745
+ )
746
+ }
747
+
748
+ let signalCount = 0
749
+ const stop = () => {
750
+ signalCount += 1
751
+ if (signalCount === 1) {
752
+ console.log('\nstopping preview…')
753
+ preview.stop()
754
+ } else {
755
+ console.log('\nforcing preview shutdown…')
756
+ preview.stop({ force: true })
757
+ }
758
+ }
759
+ process.on('SIGINT', stop)
760
+ process.on('SIGTERM', stop)
761
+ await preview.closed
762
+ process.off('SIGINT', stop)
763
+ process.off('SIGTERM', stop)
764
+ }