Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// Oven Server
3// Main Express server for the unified bake processing service
4
5import 'dotenv/config';
6import express from 'express';
7import https from 'https';
8import http from 'http';
9import fs from 'fs';
10import { execSync } from 'child_process';
11import { gunzipSync, gzipSync } from 'node:zlib';
12import { WebSocketServer } from 'ws';
13import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs';
14import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser, generateNewsOGImage } from './grabber.mjs';
15import archiver from 'archiver';
16import sharp from 'sharp';
17import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs';
18import { streamOSImage, getOSBuildStatus, invalidateManifest, purgeOSBuildCache, clearOSBuildLocalCache } from './os-builder.mjs';
19import { startOSBaseBuild, getOSBaseBuild, getOSBaseBuildsSummary, cancelOSBaseBuild } from './os-base-build.mjs';
20import { startNativeBuild, getNativeBuild, getNativeBuildsSummary, cancelNativeBuild, onNativeBuildProgress } from './native-builder.mjs';
21import { startPoller as startNativeGitPoller, getPollerStatus as getNativePollerStatus } from './native-git-poller.mjs';
22import { startPapersBuild, getPapersBuild, getPapersBuildsSummary, cancelPapersBuild } from './papers-builder.mjs';
23import { startPoller as startPapersGitPoller, getPollerStatus as getPapersPollerStatus } from './papers-git-poller.mjs';
24import { join, dirname } from 'path';
25import { fileURLToPath } from 'url';
26import { MongoClient } from 'mongodb';
27
28const app = express();
29const PORT = process.env.PORT || 3002;
30const dev = process.env.NODE_ENV === 'development';
31
32// Track server start time for uptime display
33const SERVER_START_TIME = Date.now();
34
35// Get git version at startup (from env var set during deploy, or try git)
36let GIT_VERSION = process.env.OVEN_VERSION || 'unknown';
37if (GIT_VERSION === 'unknown') {
38 try {
39 GIT_VERSION = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
40 } catch (e) {
41 // Not a git repo, that's fine
42 }
43}
44console.log(`📦 Oven version: ${GIT_VERSION}`);
45console.log(`🕐 Server started at: ${new Date(SERVER_START_TIME).toISOString()}`);
46
47// Activity log buffer for streaming to clients
48const activityLogBuffer = [];
49const MAX_ACTIVITY_LOG = 100;
50let wss = null; // Will be set after server starts
51
52const NATIVE_BUILD_COLLECTION =
53 process.env.NATIVE_BUILD_COLLECTION || 'oven-native-builds';
54const NATIVE_BUILD_STATUS_CACHE_MS = 30000;
55let nativeBuildStatusMongoClient = null;
56let nativeBuildStatusMongoDb = null;
57let nativeBuildStatusCacheAt = 0;
58let nativeBuildStatusCache = { byName: new Map(), failedAttempts: [] };
59
60function addServerLog(type, icon, msg) {
61 const entry = { time: new Date().toISOString(), type, icon, msg };
62 activityLogBuffer.unshift(entry);
63 if (activityLogBuffer.length > MAX_ACTIVITY_LOG) {
64 activityLogBuffer.pop();
65 }
66 // Broadcast to connected clients if wss exists and has clients
67 if (wss && wss.clients) {
68 const logMsg = JSON.stringify({ logEntry: entry });
69 wss.clients.forEach(client => {
70 if (client.readyState === 1) client.send(logMsg);
71 });
72 }
73}
74
75function toIsoOrNull(value) {
76 if (!value) return null;
77 const d = new Date(value);
78 if (Number.isNaN(d.getTime())) return null;
79 return d.toISOString();
80}
81
82async function getNativeBuildStatusMongoDb() {
83 if (nativeBuildStatusMongoDb) return nativeBuildStatusMongoDb;
84 const uri = process.env.MONGODB_CONNECTION_STRING;
85 const dbName = process.env.MONGODB_NAME;
86 if (!uri || !dbName) return null;
87 try {
88 nativeBuildStatusMongoClient = await MongoClient.connect(uri);
89 nativeBuildStatusMongoDb = nativeBuildStatusMongoClient.db(dbName);
90 return nativeBuildStatusMongoDb;
91 } catch (err) {
92 console.error('[os-releases] MongoDB connect failed:', err.message);
93 return null;
94 }
95}
96
97async function getNativeBuildStatusData() {
98 const now = Date.now();
99 if (now - nativeBuildStatusCacheAt < NATIVE_BUILD_STATUS_CACHE_MS) {
100 return nativeBuildStatusCache;
101 }
102
103 const db = await getNativeBuildStatusMongoDb();
104 if (!db) {
105 nativeBuildStatusCacheAt = now;
106 nativeBuildStatusCache = { byName: new Map(), failedAttempts: [] };
107 return nativeBuildStatusCache;
108 }
109
110 try {
111 const docs = await db
112 .collection(NATIVE_BUILD_COLLECTION)
113 .find({})
114 .sort({ when: -1 })
115 .limit(250)
116 .toArray();
117
118 const byName = new Map();
119 const failedAttempts = [];
120 for (const doc of docs) {
121 const name = String(doc.buildName || doc.name || '').trim();
122 if (!name) continue;
123 const status = String(doc.status || 'unknown').toLowerCase();
124 const ref = String(doc.ref || doc.gitHash || '').trim();
125 const item = {
126 name,
127 status,
128 ref: ref || null,
129 error: doc.error || null,
130 commitMsg: doc.commitMsg || null,
131 buildTs:
132 toIsoOrNull(doc.finishedAt) ||
133 toIsoOrNull(doc.startedAt) ||
134 toIsoOrNull(doc.createdAt) ||
135 toIsoOrNull(doc.when) ||
136 null,
137 };
138 if (!byName.has(name)) byName.set(name, item);
139 if ((status === 'failed' || status === 'cancelled') && failedAttempts.length < 8) {
140 failedAttempts.push(item);
141 }
142 }
143
144 nativeBuildStatusCacheAt = now;
145 nativeBuildStatusCache = { byName, failedAttempts };
146 return nativeBuildStatusCache;
147 } catch (err) {
148 console.error('[os-releases] Failed to load native build statuses:', err.message);
149 nativeBuildStatusCacheAt = now;
150 nativeBuildStatusCache = { byName: new Map(), failedAttempts: [] };
151 return nativeBuildStatusCache;
152 }
153}
154
155// Export for use in other modules
156export { addServerLog };
157
158// Log server startup
159addServerLog('info', '🔥', 'Oven server starting...');
160
161// OS base-build admin key auth
162// Accepts either OS_BUILD_ADMIN_KEY directly in env, or OS_BUILD_ADMIN_KEY_FILE.
163let cachedOSBuildAdminKey = null;
164let cachedOSBuildAdminMtimeMs = null;
165
166function getConfiguredOSBuildAdminKey() {
167 const envKey = (process.env.OS_BUILD_ADMIN_KEY || '').trim();
168 if (envKey) return envKey;
169
170 const keyFile = (process.env.OS_BUILD_ADMIN_KEY_FILE || '').trim();
171 if (!keyFile) return '';
172
173 try {
174 const stat = fs.statSync(keyFile);
175 if (cachedOSBuildAdminKey && cachedOSBuildAdminMtimeMs === stat.mtimeMs) {
176 return cachedOSBuildAdminKey;
177 }
178 const nextKey = fs.readFileSync(keyFile, 'utf8').trim();
179 cachedOSBuildAdminKey = nextKey;
180 cachedOSBuildAdminMtimeMs = stat.mtimeMs;
181 return nextKey;
182 } catch {
183 return '';
184 }
185}
186
187function getOSBuildRequestKey(req) {
188 const headerKey = (req.get('x-oven-os-key') || '').trim();
189 if (headerKey) return headerKey;
190 const auth = (req.get('authorization') || '').trim();
191 if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
192 return '';
193}
194
195function requireOSBuildAdmin(req, res, next) {
196 const expectedKey = getConfiguredOSBuildAdminKey();
197 if (!expectedKey) {
198 return res.status(503).json({
199 error: 'OS build admin key not configured. Set OS_BUILD_ADMIN_KEY or OS_BUILD_ADMIN_KEY_FILE.',
200 });
201 }
202
203 const providedKey = getOSBuildRequestKey(req);
204 if (!providedKey || providedKey !== expectedKey) {
205 return res.status(401).json({ error: 'Unauthorized' });
206 }
207
208 return next();
209}
210
211// ===== SHARED PROGRESS UI COMPONENTS =====
212// Shared CSS for progress indicators across all oven dashboards
213const PROGRESS_UI_CSS = `
214 /* Oven Progress UI - shared across all dashboards */
215 .oven-loading {
216 position: absolute;
217 inset: 0;
218 display: flex;
219 flex-direction: column;
220 align-items: center;
221 justify-content: center;
222 background: rgba(0,0,0,0.85);
223 color: #888;
224 text-align: center;
225 padding: 10px;
226 z-index: 10;
227 }
228 .oven-loading .preview-img {
229 width: 80px;
230 height: 80px;
231 image-rendering: pixelated;
232 border: 1px solid #333;
233 margin-bottom: 8px;
234 display: none;
235 background: #111;
236 }
237 .oven-loading .loading-text {
238 font-size: 12px;
239 color: #fff;
240 }
241 .oven-loading .progress-text {
242 font-size: 11px;
243 margin-top: 8px;
244 color: #88ff88;
245 font-family: monospace;
246 max-width: 150px;
247 word-break: break-word;
248 }
249 .oven-loading .progress-bar {
250 width: 80%;
251 max-width: 150px;
252 height: 4px;
253 background: #333;
254 border-radius: 2px;
255 margin: 8px auto 0;
256 overflow: hidden;
257 }
258 .oven-loading .progress-bar-fill {
259 height: 100%;
260 background: #88ff88;
261 width: 0%;
262 transition: width 0.3s ease;
263 }
264 .oven-loading.error {
265 color: #f44;
266 }
267 .oven-loading.success {
268 color: #4f4;
269 }
270`;
271
272// Shared JavaScript for progress polling and UI updates
273const PROGRESS_UI_JS = `
274 // Shared progress state
275 let progressPollInterval = null;
276
277 // Update any loading indicator with progress data
278 function updateOvenLoadingUI(container, data, queueInfo) {
279 if (!container) return;
280
281 const loadingText = container.querySelector('.loading-text');
282 const progressText = container.querySelector('.progress-text');
283 const progressBar = container.querySelector('.progress-bar-fill');
284 const previewImg = container.querySelector('.preview-img');
285
286 // Check if item is in queue and get position
287 let queuePosition = null;
288 if (queueInfo && queueInfo.length > 0 && data.piece) {
289 const queueItem = queueInfo.find(q => q.piece === data.piece);
290 if (queueItem) {
291 queuePosition = queueItem.position;
292 }
293 }
294
295 // Map stage to friendly text
296 const stageText = {
297 'loading': '🚀 Loading piece...',
298 'waiting-content': '⏳ Waiting for render...',
299 'settling': '⏸️ Settling...',
300 'capturing': '📸 Capturing...',
301 'encoding': '🔄 Processing...',
302 'uploading': '☁️ Uploading...',
303 'queued': queuePosition ? '⏳ In queue (#' + queuePosition + ')...' : '⏳ In queue...',
304 };
305
306 if (loadingText && data.stage) {
307 loadingText.textContent = stageText[data.stage] || data.stage;
308 }
309 if (progressText && data.stageDetail) {
310 progressText.textContent = data.stageDetail;
311 }
312 if (progressBar && data.percent != null) {
313 progressBar.style.width = data.percent + '%';
314 }
315 // Show streaming preview
316 if (previewImg && data.previewFrame) {
317 previewImg.src = 'data:image/jpeg;base64,' + data.previewFrame;
318 previewImg.style.display = 'block';
319 }
320 }
321
322 // Create loading HTML structure
323 function createOvenLoadingHTML(initialText = '🔥 Loading...') {
324 return '<img class="preview-img" alt="preview">' +
325 '<span class="loading-text">' + initialText + '</span>' +
326 '<div class="progress-text"></div>' +
327 '<div class="progress-bar"><div class="progress-bar-fill"></div></div>';
328 }
329
330 // Start polling /grab-status for progress updates
331 function startProgressPolling(callback, intervalMs = 150) {
332 stopProgressPolling();
333 progressPollInterval = setInterval(async () => {
334 try {
335 const res = await fetch('/grab-status');
336 const data = await res.json();
337 if (callback && data.progress) {
338 callback(data);
339 }
340 } catch (err) {
341 // Ignore polling errors
342 }
343 }, intervalMs);
344 }
345
346 function stopProgressPolling() {
347 if (progressPollInterval) {
348 clearInterval(progressPollInterval);
349 progressPollInterval = null;
350 }
351 }
352`;
353
354// Parse JSON bodies
355app.use(express.json());
356
357// CORS headers for cross-origin image loading (needed for canvas pixel validation)
358app.use((req, res, next) => {
359 res.setHeader('Access-Control-Allow-Origin', '*');
360 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
361 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
362 res.setHeader(
363 'Access-Control-Expose-Headers',
364 'Content-Length, Content-Disposition, X-AC-OS-Requested-Layout, X-AC-OS-Layout, X-AC-OS-Fallback, X-AC-OS-Fallback-Reason, X-Build, X-Patch',
365 );
366 if (req.method === 'OPTIONS') {
367 return res.sendStatus(200);
368 }
369 next();
370});
371
372// Serve font glyph JSONs locally for Puppeteer captures.
373// Font_1 glyph XHR requests from the disk.mjs worker are redirected here
374// by the request interceptor to avoid Puppeteer's broken concurrent XHR handling.
375const __serverDirname = dirname(fileURLToPath(import.meta.url));
376app.get('/local-glyph/*', (req, res) => {
377 const glyphPath = req.params[0]; // Express auto-decodes URI params
378 // Sanitize: only allow paths within ac-source/disks/drawings
379 if (glyphPath.includes('..') || glyphPath.includes('\0')) {
380 return res.status(400).send('Invalid path');
381 }
382 const filePath = join(__serverDirname, 'ac-source', 'disks', 'drawings', glyphPath);
383 res.sendFile(filePath, (err) => {
384 if (err) res.status(404).json({ error: 'glyph not found' });
385 });
386});
387
388// Oven TV dashboard — live-updating visual bake monitor
389app.get('/', (req, res) => {
390 res.setHeader('Content-Type', 'text/html');
391 res.send(OVEN_TV_HTML);
392});
393
394const OVEN_TV_HTML = `<!DOCTYPE html>
395<html>
396<head>
397 <meta charset="utf-8">
398 <title>oven</title>
399 <meta name="viewport" content="width=device-width, initial-scale=1">
400 <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png">
401 <style>
402 :root {
403 --bg: #f7f7f7;
404 --bg-deep: #ececec;
405 --bg-card: #fff;
406 --bg-hover: #f0f0f0;
407 --text: #111;
408 --text-secondary: #555;
409 --text-muted: #888;
410 --text-dim: #aaa;
411 --border: #ddd;
412 --border-subtle: #e8e8e8;
413 --accent: rgb(205, 92, 155);
414 --accent-hover: rgb(220, 110, 170);
415 --success: #2a9a2a;
416 --error: #c44;
417 --preview-bg: #e0e0e0;
418 --overlay-bg: rgba(255,255,255,0.92);
419 --scrollbar: transparent;
420 }
421 @media (prefers-color-scheme: dark) {
422 :root {
423 --bg: #1e1e1e;
424 --bg-deep: #161616;
425 --bg-card: #252526;
426 --bg-hover: #2a2a2a;
427 --text: #d4d4d4;
428 --text-secondary: #888;
429 --text-muted: #666;
430 --text-dim: #444;
431 --border: #3e3e42;
432 --border-subtle: #2e2e32;
433 --accent: rgb(205, 92, 155);
434 --accent-hover: rgb(225, 115, 175);
435 --success: #4caf50;
436 --error: #f44;
437 --preview-bg: #111;
438 --overlay-bg: rgba(0,0,0,0.88);
439 }
440 }
441
442 * { box-sizing: border-box; margin: 0; padding: 0; }
443 ::-webkit-scrollbar { display: none; }
444
445 body {
446 font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
447 font-size: 15px;
448 background: var(--bg);
449 color: var(--text);
450 height: 100vh;
451 display: flex;
452 flex-direction: column;
453 overflow: hidden;
454 }
455
456 .status-bar {
457 background: var(--bg-deep);
458 border-bottom: 2px solid var(--accent);
459 padding: 5px 12px;
460 display: flex;
461 align-items: center;
462 justify-content: space-between;
463 flex-shrink: 0;
464 gap: 8px;
465 }
466 .status-bar .title { color: var(--accent); font-weight: bold; font-size: 1.1em; }
467 .status-bar .stats { display: flex; gap: 12px; color: var(--text-muted); font-size: 0.95em; }
468 .status-bar .stats span { white-space: nowrap; }
469 .status-bar .stats .active { color: var(--success); }
470 .status-bar .stats .queued { color: var(--accent); }
471 .sb-btn {
472 background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border);
473 padding: 3px 8px; cursor: pointer; font-family: inherit; font-size: 0.85em;
474 border-radius: 3px; text-decoration: none; display: inline-block;
475 }
476 .sb-btn:hover { color: var(--text); border-color: var(--accent); }
477
478 .hero {
479 flex: 1;
480 display: flex;
481 align-items: center;
482 justify-content: center;
483 gap: 10px;
484 padding: 10px;
485 min-height: 0;
486 overflow: hidden;
487 }
488 .hero.idle { color: var(--text-dim); font-size: 1.2em; }
489 .hero-card {
490 background: var(--bg-card);
491 border: 2px solid var(--border);
492 border-radius: 6px;
493 display: flex;
494 flex-direction: row;
495 align-items: stretch;
496 height: 90px;
497 min-width: 200px;
498 max-width: 320px;
499 flex-shrink: 0;
500 overflow: hidden;
501 }
502 .hero-card.capturing { border-color: var(--accent); }
503 .hero-card .preview {
504 width: 86px;
505 min-width: 86px;
506 background: var(--preview-bg);
507 overflow: hidden;
508 display: flex;
509 align-items: center;
510 justify-content: center;
511 }
512 .hero-card .preview img {
513 width: 100%;
514 height: 100%;
515 object-fit: cover;
516 image-rendering: pixelated;
517 }
518 .hero-card .preview .placeholder { color: var(--text-dim); font-size: 1.2em; }
519 .hero-card .info {
520 flex: 1;
521 padding: 4px 6px;
522 display: flex;
523 flex-direction: column;
524 justify-content: center;
525 gap: 2px;
526 min-width: 0;
527 overflow: hidden;
528 }
529 .hero-card .piece-name { color: var(--accent); font-weight: bold; font-size: 0.95em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
530 .hero-card .meta { color: var(--text-dim); font-size: 0.75em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; }
531 .hero-card .meta .author { color: var(--text-secondary); }
532 .hero-card .stage { color: var(--text-muted); font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; }
533 .hero-card .progress-bar {
534 width: 100%;
535 height: 3px;
536 background: var(--border);
537 flex-shrink: 0;
538 margin-top: auto;
539 }
540 .hero-card .progress-bar .fill {
541 height: 100%;
542 background: var(--accent);
543 transition: width 0.3s ease;
544 }
545
546 .strip {
547 background: var(--bg-deep);
548 border-top: 1px solid var(--border);
549 padding: 5px 12px;
550 flex-shrink: 0;
551 overflow: hidden;
552 }
553 .strip-label {
554 color: var(--text-muted);
555 font-size: 0.75em;
556 text-transform: uppercase;
557 letter-spacing: 1px;
558 margin-bottom: 4px;
559 }
560 .strip-items {
561 display: flex;
562 gap: 6px;
563 padding-bottom: 2px;
564 white-space: nowrap;
565 }
566 .strip-items.train {
567 animation: train-scroll 30s linear infinite;
568 width: max-content;
569 }
570 @keyframes train-scroll {
571 0% { transform: translateX(0); }
572 100% { transform: translateX(-50%); }
573 }
574 .strip-item {
575 background: var(--bg-card);
576 border: 1px solid var(--border);
577 border-radius: 3px;
578 padding: 2px 8px;
579 white-space: nowrap;
580 font-size: 0.9em;
581 flex-shrink: 0;
582 }
583 .strip-item.queue { color: var(--accent); }
584 .strip-empty { color: var(--text-dim); font-size: 0.9em; padding: 2px 0; }
585
586 .history {
587 background: var(--bg-deep);
588 border-top: 1px solid var(--border);
589 flex: 1;
590 min-height: 0;
591 overflow-y: auto;
592 padding: 0;
593 }
594 .history .strip-label { padding: 5px 12px 3px; }
595 .history-row {
596 display: flex;
597 align-items: center;
598 gap: 10px;
599 padding: 5px 12px;
600 border-bottom: 1px solid var(--border-subtle);
601 }
602 .history-row:hover { background: var(--bg-hover); }
603 .history-row .h-thumb {
604 width: 44px;
605 height: 44px;
606 min-width: 44px;
607 border-radius: 3px;
608 background: var(--bg-card);
609 flex-shrink: 0;
610 overflow: hidden;
611 display: flex;
612 align-items: center;
613 justify-content: center;
614 }
615 .history-row .h-thumb img {
616 width: 100%;
617 height: 100%;
618 object-fit: cover;
619 image-rendering: pixelated;
620 }
621 .history-row .h-thumb .h-none { color: var(--text-dim); font-size: 1em; }
622 .history-row .h-main { flex: 1; min-width: 0; }
623 .history-row .h-piece { font-weight: bold; font-size: 0.9em; }
624 .history-row .h-piece a { color: inherit; text-decoration: none; }
625 .history-row .h-piece a:hover { text-decoration: underline; }
626 .history-row .h-meta { color: var(--text-muted); font-size: 0.78em; margin-top: 2px; display: flex; gap: 10px; flex-wrap: wrap; }
627 .history-row .h-meta span { white-space: nowrap; }
628 .history-row .h-error { color: var(--error); font-size: 0.78em; margin-top: 2px; opacity: 0.8; }
629 .history-row .h-links { margin-top: 2px; display: flex; gap: 8px; }
630 .history-row .h-links a { color: var(--accent); font-size: 0.78em; text-decoration: none; }
631 .history-row .h-links a:hover { text-decoration: underline; }
632 .history-row .h-right {
633 flex-shrink: 0;
634 text-align: right;
635 font-size: 0.8em;
636 }
637 .history-row .h-status-done { color: var(--success); }
638 .history-row .h-status-failed { color: var(--error); }
639 .history-row .h-status-other { color: var(--text-muted); }
640 .history-row .h-ago { color: var(--text-dim); font-size: 0.85em; }
641
642 @keyframes card-enter {
643 from { opacity: 0; transform: translateX(-120px) scale(0.9); }
644 to { opacity: 1; transform: translateX(0) scale(1); }
645 }
646 @keyframes card-exit {
647 from { opacity: 1; transform: translateX(0) scale(1); }
648 to { opacity: 0; transform: translateX(120px) scale(0.9); }
649 }
650 .hero-card {
651 animation: card-enter 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
652 }
653 .hero-card.exiting {
654 animation: card-exit 0.5s cubic-bezier(0.55, 0, 1, 0.45) forwards;
655 pointer-events: none;
656 }
657
658 .capture-bar { display: none; }
659
660 .log-overlay {
661 display: none;
662 position: fixed;
663 top: 0; left: 0; right: 0; bottom: 0;
664 background: var(--overlay-bg);
665 z-index: 200;
666 padding: 32px 16px 16px;
667 overflow-y: auto;
668 }
669 .log-overlay.open { display: block; }
670 .log-overlay .close {
671 position: fixed;
672 top: 6px;
673 right: 12px;
674 background: none;
675 border: none;
676 color: var(--accent);
677 font-size: 1.3em;
678 cursor: pointer;
679 font-family: inherit;
680 }
681 .log-entry {
682 padding: 1px 0;
683 font-size: 0.82em;
684 color: var(--text-secondary);
685 white-space: nowrap;
686 overflow: hidden;
687 text-overflow: ellipsis;
688 }
689 .log-entry .time { color: var(--text-muted); }
690 .log-entry.error { color: var(--error); }
691 .log-entry.success { color: var(--success); }
692 </style>
693</head>
694<body>
695
696 <div class="status-bar">
697 <span class="title">oven</span>
698 <div class="stats">
699 <span class="active" id="stat-active">0/6 active</span>
700 <span class="queued" id="stat-queued">0 queued</span>
701 <span id="stat-uptime">--</span>
702 <span id="stat-version">--</span>
703 </div>
704 <div style="display:flex;gap:4px">
705 <a href="/tools" class="sb-btn">Tools</a>
706 <button class="sb-btn" id="log-btn" onclick="document.getElementById('log-overlay').classList.toggle('open')">Log</button>
707 </div>
708 </div>
709
710 <div class="hero idle" id="hero">Waiting for grabs...</div>
711
712 <div class="strip" id="queue-strip">
713 <div class="strip-label">Up Next</div>
714 <div class="strip-items" id="queue-items">
715 <span class="strip-empty">No items queued</span>
716 </div>
717 </div>
718
719 <div class="history" id="history">
720 <div class="strip-label">Recent</div>
721 <div id="history-items">
722 <span class="strip-empty">No recent grabs</span>
723 </div>
724 </div>
725
726 <div class="log-overlay" id="log-overlay">
727 <button class="close" onclick="this.parentElement.classList.remove('open')">x</button>
728 <div id="log-entries"></div>
729 </div>
730
731 <script>
732 let serverVersion = null;
733 let ws = null;
734 let reconnectTimer = null;
735
736 function connect() {
737 const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
738 ws = new WebSocket(proto + '//' + location.host + '/ws');
739
740 ws.onopen = () => {
741 document.getElementById('stat-version').textContent = 'connected';
742 document.getElementById('stat-version').style.color = 'var(--success)';
743 if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
744 };
745
746 ws.onclose = () => {
747 document.getElementById('stat-version').textContent = 'disconnected';
748 document.getElementById('stat-version').style.color = 'var(--error)';
749 reconnectTimer = setTimeout(connect, 2000);
750 };
751
752 ws.onmessage = (event) => {
753 const data = JSON.parse(event.data);
754
755 if (data.logEntry) { addLog(data.logEntry); return; }
756 if (serverVersion && data.version && data.version !== serverVersion) {
757 location.reload();
758 return;
759 }
760 serverVersion = data.version;
761
762 if (data.recentLogs) {
763 data.recentLogs.forEach(addLog);
764 }
765
766 updateStatusBar(data);
767 renderHero(data.grabProgress || {});
768 renderQueue(data.grabs?.queue || []);
769 renderHistory(data.grabs?.recent || []);
770 };
771 }
772
773 function updateStatusBar(data) {
774 const c = data.concurrency || {};
775 document.getElementById('stat-active').textContent = (c.active || 0) + '/' + (c.max || 6) + ' active';
776 document.getElementById('stat-queued').textContent = (c.queueDepth || 0) + ' queued';
777
778 if (data.uptime) {
779 const s = Math.floor(data.uptime / 1000);
780 const m = Math.floor(s / 60);
781 const h = Math.floor(m / 60);
782 const d = Math.floor(h / 24);
783 let upStr;
784 if (d > 0) upStr = d + 'd ' + (h % 24) + 'h';
785 else if (h > 0) upStr = h + 'h ' + (m % 60) + 'm';
786 else upStr = m + 'm ' + (s % 60) + 's';
787 document.getElementById('stat-uptime').textContent = 'up ' + upStr;
788 }
789 if (data.version) {
790 document.getElementById('stat-version').textContent = data.version;
791 document.getElementById('stat-version').style.color = 'var(--text-muted)';
792 }
793 }
794
795 let heroCards = {}; // grabId → DOM element
796 let exitingCards = new Set(); // grabIds currently animating out
797
798 function ago(ms) {
799 const s = Math.floor(ms / 1000);
800 if (s < 60) return s + 's ago';
801 const m = Math.floor(s / 60);
802 return m + 'm ' + (s % 60) + 's ago';
803 }
804
805 function shortDate(iso) {
806 if (!iso) return '';
807 const d = new Date(iso);
808 if (isNaN(d)) return '';
809 const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
810 return months[d.getMonth()] + ' ' + d.getDate();
811 }
812
813 function renderHero(grabProgress) {
814 const hero = document.getElementById('hero');
815 const entries = Object.entries(grabProgress).filter(([, p]) => p.stage);
816
817 // Animate out cards no longer in progress
818 const activeIds = new Set(entries.map(([id]) => id));
819 for (const id of Object.keys(heroCards)) {
820 if (!activeIds.has(id) && !exitingCards.has(id)) {
821 exitingCards.add(id);
822 const card = heroCards[id];
823 card.classList.add('exiting');
824 card.addEventListener('animationend', () => {
825 card.remove();
826 delete heroCards[id];
827 exitingCards.delete(id);
828 // Check if hero should go idle after last card exits
829 if (Object.keys(heroCards).length === 0 && exitingCards.size === 0) {
830 hero.className = 'hero idle';
831 hero.textContent = 'Waiting for grabs\\u2026';
832 }
833 }, { once: true });
834 }
835 }
836
837 if (entries.length === 0 && Object.keys(heroCards).length === 0 && exitingCards.size === 0) {
838 if (!hero.classList.contains('idle')) {
839 hero.className = 'hero idle';
840 hero.textContent = 'Waiting for grabs\\u2026';
841 }
842 return;
843 }
844
845 // Clear idle text when transitioning to active
846 if (hero.classList.contains('idle')) {
847 hero.innerHTML = '';
848 }
849 hero.className = 'hero';
850
851 // Update or create cards
852 entries.forEach(([grabId, p]) => {
853 const previewSrc = p.previewFrame
854 ? 'data:image/jpeg;base64,' + p.previewFrame
855 : '';
856 const stageLabel = p.stage ? (p.stage.charAt(0).toUpperCase() + p.stage.slice(1)) : '';
857 const detail = p.stageDetail || '';
858 const pct = p.percent || 0;
859 const now = Date.now();
860 const reqAgo = p.requestedAt ? ago(now - p.requestedAt) : '';
861 const authorStr = p.author || '';
862 const createdStr = shortDate(p.pieceCreatedAt);
863 const sourceStr = p.source || '';
864 const originStr = p.requestOrigin ? p.requestOrigin.replace(/^https?:\\/\\//, '').split('/')[0] : '';
865
866 let metaParts = [];
867 if (authorStr) metaParts.push('<span class="author">' + esc(authorStr) + '</span>');
868 if (sourceStr) metaParts.push(sourceStr);
869 if (originStr) metaParts.push(originStr);
870 if (createdStr) metaParts.push('created ' + createdStr);
871 if (reqAgo) metaParts.push(reqAgo);
872 const metaHtml = metaParts.join(' · ');
873
874 let card = heroCards[grabId];
875 if (!card) {
876 card = document.createElement('div');
877 card.className = 'hero-card' + (p.stage === 'capturing' ? ' capturing' : '');
878 card.innerHTML =
879 '<div class="preview">' + (previewSrc ? '<img src="' + previewSrc + '" alt="">' : '<span class="placeholder">···</span>') + '</div>' +
880 '<div class="info">' +
881 '<div class="piece-name">' + esc(p.piece || grabId) + '</div>' +
882 '<div class="meta">' + metaHtml + '</div>' +
883 '<div class="stage">' + esc(stageLabel + (detail ? ' — ' + detail : '')) + '</div>' +
884 '<div class="progress-bar"><div class="fill" style="width:' + pct + '%"></div></div>' +
885 '</div>';
886 hero.appendChild(card);
887 heroCards[grabId] = card;
888 } else {
889 card.className = 'hero-card' + (p.stage === 'capturing' ? ' capturing' : '');
890 const img = card.querySelector('.preview img');
891 if (previewSrc && img) {
892 if (img.src !== previewSrc) img.src = previewSrc;
893 } else if (previewSrc && !img) {
894 card.querySelector('.preview').innerHTML = '<img src="' + previewSrc + '" alt="">';
895 }
896 card.querySelector('.piece-name').textContent = p.piece || grabId;
897 card.querySelector('.meta').innerHTML = metaHtml;
898 card.querySelector('.stage').textContent = stageLabel + (detail ? ' — ' + detail : '');
899 card.querySelector('.fill').style.width = pct + '%';
900 }
901 });
902 }
903
904 let lastQueueKey = '';
905 function renderQueue(queue) {
906 const el = document.getElementById('queue-items');
907 if (!queue || queue.length === 0) {
908 if (lastQueueKey !== 'empty') {
909 el.innerHTML = '<span class="strip-empty">No items queued</span>';
910 el.classList.remove('train');
911 lastQueueKey = 'empty';
912 }
913 return;
914 }
915 // Only re-render if queue contents changed (avoids restarting CSS animation)
916 const queueKey = queue.map(q => q.piece).join(',');
917 if (queueKey === lastQueueKey) return;
918 lastQueueKey = queueKey;
919
920 const items = queue.map((item, i) =>
921 '<div class="strip-item queue">' +
922 '#' + (i + 1) + ' ' + esc(item.piece || '?') +
923 ' <span style="color:var(--text-muted)">(' + esc(item.format || '?') + ')</span>' +
924 (item.estimatedWait ? ' <span style="color:var(--text-dim)">~' + Math.ceil(item.estimatedWait / 1000) + 's</span>' : '') +
925 '</div>'
926 ).join('');
927 // Duplicate items for seamless looping train effect
928 if (queue.length > 4) {
929 el.innerHTML = items + items;
930 el.classList.add('train');
931 el.style.animationDuration = Math.max(10, queue.length * 2) + 's';
932 } else {
933 el.innerHTML = items;
934 el.classList.remove('train');
935 }
936 }
937
938 let lastHistoryKey = '';
939 function renderHistory(recent) {
940 const el = document.getElementById('history-items');
941 if (!recent || recent.length === 0) {
942 if (lastHistoryKey !== 'empty') {
943 el.innerHTML = '<span class="strip-empty" style="padding:5px 12px">No recent grabs</span>';
944 lastHistoryKey = 'empty';
945 }
946 return;
947 }
948 const historyKey = recent.slice(0, 30).map(g => (g.id || g.piece) + ':' + g.status).join(',');
949 if (historyKey === lastHistoryKey) return;
950 lastHistoryKey = historyKey;
951 el.innerHTML = recent.slice(0, 30).map(grab => {
952 const thumbImg = grab.cdnUrl
953 ? '<img src="' + esc(grab.cdnUrl) + '" alt="">'
954 : '<span class="h-none">--</span>';
955
956 const pieceClass = grab.status === 'failed' ? 'h-status-failed' : '';
957
958 const dur = grab.duration ? Math.round(grab.duration / 1000) + 's' : '';
959 const dim = grab.dimensions ? grab.dimensions.width + 'x' + grab.dimensions.height : '';
960 const fmt = (grab.format || '').toUpperCase();
961 const size = grab.size ? (grab.size > 1024*1024 ? (grab.size/1024/1024).toFixed(1)+'MB' : Math.round(grab.size/1024)+'KB') : '';
962
963 const metaParts = [fmt, dim, dur, size].filter(Boolean);
964 const metaHTML = metaParts.map(m => '<span>' + esc(m) + '</span>').join('');
965
966 const errorHTML = grab.error
967 ? '<div class="h-error">' + esc(grab.error) + '</div>'
968 : '';
969
970 let linksHTML = '';
971 if (grab.cdnUrl) {
972 linksHTML = '<div class="h-links">' +
973 '<a href="' + esc(grab.cdnUrl) + '" target="_blank">Open</a>' +
974 '<a href="' + esc(grab.cdnUrl) + '" download>Download</a>' +
975 '</div>';
976 }
977
978 const statusClass = grab.status === 'complete' ? 'h-status-done' :
979 grab.status === 'failed' ? 'h-status-failed' : 'h-status-other';
980 const statusLabel = grab.status === 'complete' ? 'done' :
981 grab.status === 'failed' ? 'failed' :
982 esc(grab.status || '?');
983
984 const ago = grab.completedAt ? timeAgo(grab.completedAt) : '';
985
986 const pieceName = esc(grab.piece || grab.id || '?');
987 const pieceLink = grab.cdnUrl
988 ? '<a href="' + esc(grab.cdnUrl) + '" target="_blank">' + pieceName + '</a>'
989 : pieceName;
990
991 return '<div class="history-row">' +
992 '<div class="h-thumb">' + thumbImg + '</div>' +
993 '<div class="h-main">' +
994 '<div class="h-piece ' + pieceClass + '">' + pieceLink + '</div>' +
995 '<div class="h-meta">' + metaHTML + '</div>' +
996 errorHTML +
997 linksHTML +
998 '</div>' +
999 '<div class="h-right">' +
1000 '<div class="' + statusClass + '">' + statusLabel + '</div>' +
1001 '<div class="h-ago">' + esc(ago) + '</div>' +
1002 '</div>' +
1003 '</div>';
1004 }).join('');
1005 }
1006
1007 function timeAgo(ts) {
1008 const s = Math.floor((Date.now() - ts) / 1000);
1009 if (s < 60) return s + 's ago';
1010 const m = Math.floor(s / 60);
1011 if (m < 60) return m + 'm ago';
1012 const h = Math.floor(m / 60);
1013 if (h < 24) return h + 'h ago';
1014 return Math.floor(h / 24) + 'd ago';
1015 }
1016
1017 function addLog(entry) {
1018 if (!entry) return;
1019 const el = document.getElementById('log-entries');
1020 const div = document.createElement('div');
1021 div.className = 'log-entry' + (entry.type === 'error' ? ' error' : entry.type === 'success' ? ' success' : '');
1022 const time = entry.time ? new Date(entry.time).toLocaleTimeString() : '';
1023 div.innerHTML = '<span class="time">' + time + '</span> ' + esc((entry.icon || '') + ' ' + (entry.msg || ''));
1024 el.prepend(div);
1025 while (el.children.length > 200) el.removeChild(el.lastChild);
1026 }
1027
1028 function esc(s) {
1029 if (!s) return '';
1030 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
1031 }
1032
1033 connect();
1034 </script>
1035</body>
1036</html>`;
1037
1038// Tools submenu — links to OG images, app screenshots, bundles, status pages
1039app.get('/tools', (req, res) => {
1040 res.setHeader('Content-Type', 'text/html');
1041 res.send(`<!DOCTYPE html>
1042<html>
1043<head>
1044 <meta charset="utf-8">
1045 <title>oven / tools</title>
1046 <meta name="viewport" content="width=device-width, initial-scale=1">
1047 <style>
1048 :root {
1049 --bg: #f7f7f7; --text: #111; --text-muted: #888; --text-dim: #aaa;
1050 --accent: rgb(205, 92, 155); --border: #ddd;
1051 }
1052 @media (prefers-color-scheme: dark) {
1053 :root {
1054 --bg: #1e1e1e; --text: #d4d4d4; --text-muted: #666; --text-dim: #444;
1055 --accent: rgb(205, 92, 155); --border: #3e3e42;
1056 }
1057 }
1058 * { box-sizing: border-box; margin: 0; padding: 0; }
1059 body { font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; font-size: 12px; background: var(--bg); color: var(--text); padding: 20px; }
1060 a { color: var(--accent); text-decoration: none; }
1061 a:hover { text-decoration: underline; }
1062 h1 { color: var(--accent); margin-bottom: 20px; font-size: 1.1em; }
1063 h1 a { color: var(--text-muted); }
1064 h2 { color: var(--text-muted); margin: 16px 0 6px; font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; }
1065 .links { display: flex; flex-direction: column; gap: 4px; margin-left: 10px; }
1066 .links a { padding: 2px 0; }
1067 .desc { color: var(--text-dim); font-size: 0.85em; margin-left: 8px; }
1068 .panel {
1069 margin: 8px 0 0 10px;
1070 padding: 8px;
1071 border: 1px solid var(--border);
1072 max-width: 920px;
1073 border-radius: 4px;
1074 background: rgba(0,0,0,0.02);
1075 }
1076 .row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; }
1077 input, button {
1078 font: inherit;
1079 font-size: 0.9em;
1080 border: 1px solid var(--border);
1081 background: transparent;
1082 color: var(--text);
1083 padding: 4px 6px;
1084 border-radius: 3px;
1085 }
1086 input { min-width: 160px; }
1087 button { cursor: pointer; }
1088 button:hover { border-color: var(--accent); color: var(--accent); }
1089 pre {
1090 border: 1px solid var(--border);
1091 padding: 8px;
1092 overflow: auto;
1093 max-height: 240px;
1094 white-space: pre-wrap;
1095 word-break: break-word;
1096 }
1097 hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
1098 </style>
1099</head>
1100<body>
1101 <h1><a href="/">oven</a> / tools</h1>
1102
1103 <h2>OG Images</h2>
1104 <div class="links">
1105 <div><a href="/kidlisp-og.png">/kidlisp-og.png</a><span class="desc">KidLisp OG image</span></div>
1106 <div><a href="/kidlisp-og">/kidlisp-og</a><span class="desc">KidLisp OG HTML page</span></div>
1107 <div><a href="/kidlisp-og/status">/kidlisp-og/status</a><span class="desc">OG cache status</span></div>
1108 <div><a href="/kidlisp-og/preview">/kidlisp-og/preview</a><span class="desc">OG preview</span></div>
1109 <div><a href="/og-preview">/og-preview</a><span class="desc">OG preview (alt)</span></div>
1110 <div><a href="/notepat-og.png">/notepat-og.png</a><span class="desc">Notepat OG image</span></div>
1111 <div><a href="/news-og/ncd2.png">/news-og/:code.png</a><span class="desc">News article OG image</span></div>
1112 <div><a href="/kidlisp-backdrop.webp">/kidlisp-backdrop.webp</a><span class="desc">KidLisp backdrop animation</span></div>
1113 <div><a href="/kidlisp-backdrop">/kidlisp-backdrop</a><span class="desc">KidLisp backdrop page</span></div>
1114 </div>
1115
1116 <h2>App Screenshots</h2>
1117 <div class="links">
1118 <div><a href="/app-screenshots">/app-screenshots</a><span class="desc">Screenshot dashboard</span></div>
1119 </div>
1120
1121 <h2>Bundles</h2>
1122 <div class="links">
1123 <div><a href="/bundle-status">/bundle-status</a><span class="desc">Bundle cache status</span></div>
1124 <div><a href="/bundle-html?piece=prompt">/bundle-html?piece=...</a><span class="desc">Generate HTML bundle (SSE)</span></div>
1125 </div>
1126
1127 <h2>Grabs</h2>
1128 <div class="links">
1129 <div><a href="/grab-status">/grab-status</a><span class="desc">Active grabs + queue (JSON)</span></div>
1130 <div><a href="/api/frozen">/api/frozen</a><span class="desc">Frozen pieces list</span></div>
1131 <div><a href="/keeps/all">/keeps/all</a><span class="desc">All latest IPFS uploads</span></div>
1132 <div><a href="/keeps/latest">/keeps/latest</a><span class="desc">Latest keep thumbnail</span></div>
1133 </div>
1134
1135 <h2>Status</h2>
1136 <div class="links">
1137 <div><a href="/health">/health</a><span class="desc">Health check</span></div>
1138 <div><a href="/status">/status</a><span class="desc">Server status + recent bakes</span></div>
1139 </div>
1140
1141 <h2>OS Base Builds</h2>
1142 <div class="links">
1143 <div><a href="/os-base-build">/os-base-build</a><span class="desc">Background FedOS base-image jobs</span></div>
1144 </div>
1145 <div class="panel">
1146 <div class="row">
1147 <input id="os-admin-key" type="password" placeholder="admin key (x-oven-os-key)">
1148 <select id="os-flavor" style="width:100px"><option value="alpine" selected>Alpine</option><option value="fedora">Fedora</option><option value="native">Native</option></select>
1149 <input id="os-image-size" type="number" min="1" max="32" value="1" style="width:90px">
1150 <button id="os-start-btn" type="button">Start Base Build</button>
1151 <button id="os-refresh-btn" type="button">Refresh</button>
1152 </div>
1153 <div id="os-job-meta" class="desc">Loading base-build status...</div>
1154 <pre id="os-job-log">No active base-image job</pre>
1155 </div>
1156
1157 <script>
1158 const osMetaEl = document.getElementById('os-job-meta');
1159 const osLogEl = document.getElementById('os-job-log');
1160 const osKeyEl = document.getElementById('os-admin-key');
1161 const osSizeEl = document.getElementById('os-image-size');
1162 const osFlavorEl = document.getElementById('os-flavor');
1163 const osStartBtn = document.getElementById('os-start-btn');
1164 const osRefreshBtn = document.getElementById('os-refresh-btn');
1165 osFlavorEl.addEventListener('change', function() {
1166 osSizeEl.value = osFlavorEl.value === 'alpine' ? '1' : '4';
1167 });
1168 let osPollTimer = null;
1169
1170 function esc(str) {
1171 return String(str || '').replace(/[&<>"']/g, function (ch) {
1172 return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch];
1173 });
1174 }
1175
1176 async function fetchJSON(url, opts) {
1177 const res = await fetch(url, opts || {});
1178 const text = await res.text();
1179 let data = {};
1180 try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
1181 if (!res.ok) {
1182 throw new Error((data && data.error) || ('HTTP ' + res.status));
1183 }
1184 return data;
1185 }
1186
1187 function jobLabel(job) {
1188 if (!job) return 'none';
1189 const pct = Number.isFinite(job.percent) ? (' ' + job.percent + '%') : '';
1190 return job.id + ' | ' + job.status + pct + ' | ' + (job.stage || '--');
1191 }
1192
1193 async function refreshOSBaseBuild() {
1194 try {
1195 const summary = await fetchJSON('/os-base-build');
1196 const active = summary.active || null;
1197 const recent = Array.isArray(summary.recent) ? summary.recent : [];
1198 if (active) {
1199 osMetaEl.innerHTML = 'Active: <strong>' + esc(jobLabel(active)) + '</strong>';
1200 const detail = await fetchJSON('/os-base-build/' + encodeURIComponent(active.id) + '?logs=1&tail=100');
1201 const lines = Array.isArray(detail.logs) ? detail.logs : [];
1202 osLogEl.textContent = lines.map(function (l) {
1203 return '[' + (l.ts || '') + '][' + (l.stream || 'out') + '] ' + (l.line || '');
1204 }).join('\n') || 'No logs yet';
1205 return;
1206 }
1207
1208 const latest = recent.length > 0 ? recent[0] : null;
1209 osMetaEl.innerHTML = 'Active: none' + (latest ? ' | Latest: <strong>' + esc(jobLabel(latest)) + '</strong>' : '');
1210 osLogEl.textContent = latest
1211 ? ((latest.message || '(no message)') + '\n\nUse /os-base-build/' + latest.id + '?logs=1&tail=200 for full logs.')
1212 : 'No base-image jobs yet';
1213 } catch (error) {
1214 osMetaEl.textContent = 'Status error: ' + error.message;
1215 }
1216 }
1217
1218 async function startOSBaseBuild() {
1219 const key = osKeyEl.value.trim();
1220 const flavor = osFlavorEl.value || 'alpine';
1221 const defaultSize = flavor === 'alpine' ? 1 : 4;
1222 const imageSizeGB = Math.max(1, parseInt(osSizeEl.value || String(defaultSize), 10) || defaultSize);
1223 osStartBtn.disabled = true;
1224 try {
1225 const data = await fetchJSON('/os-base-build', {
1226 method: 'POST',
1227 headers: {
1228 'Content-Type': 'application/json',
1229 'x-oven-os-key': key,
1230 },
1231 body: JSON.stringify({ imageSizeGB, publish: true, flavor }),
1232 });
1233 osMetaEl.textContent = 'Started ' + flavor + ' base build job ' + data.id;
1234 } catch (error) {
1235 osMetaEl.textContent = 'Start failed: ' + error.message;
1236 } finally {
1237 osStartBtn.disabled = false;
1238 refreshOSBaseBuild();
1239 }
1240 }
1241
1242 osStartBtn.addEventListener('click', startOSBaseBuild);
1243 osRefreshBtn.addEventListener('click', refreshOSBaseBuild);
1244 refreshOSBaseBuild();
1245 osPollTimer = setInterval(refreshOSBaseBuild, 3000);
1246 window.addEventListener('beforeunload', function () {
1247 if (osPollTimer) clearInterval(osPollTimer);
1248 });
1249 </script>
1250</body>
1251</html>`);
1252});
1253
1254// API endpoints
1255app.get('/health', healthHandler);
1256
1257// Override status to include grabs
1258app.get('/status', async (req, res) => {
1259 await cleanupStaleBakes();
1260 res.json({
1261 version: GIT_VERSION,
1262 serverStartTime: SERVER_START_TIME,
1263 uptime: Date.now() - SERVER_START_TIME,
1264 incoming: Array.from(getIncomingBakes().values()),
1265 active: Array.from(getActiveBakes().values()),
1266 recent: getRecentBakes(),
1267 grabs: {
1268 active: getActiveGrabs(),
1269 recent: getRecentGrabs(),
1270 ipfsThumbs: getAllLatestIPFSUploads()
1271 },
1272 osBaseBuilds: getOSBaseBuildsSummary(),
1273 });
1274});
1275
1276app.post('/bake', bakeHandler);
1277app.post('/bake-complete', bakeCompleteHandler);
1278app.post('/bake-status', bakeStatusHandler);
1279
1280// Icon endpoint - small square thumbnails (compatible with grab.aesthetic.computer)
1281// GET /icon/{width}x{height}/{piece}.png
1282// Uses 24h Spaces cache to avoid regenerating on every request
1283app.get('/icon/:size/:piece.png', async (req, res) => {
1284 const { size, piece } = req.params;
1285 const [width, height] = size.split('x').map(n => parseInt(n) || 128);
1286 const w = Math.min(width, 512);
1287 const h = Math.min(height, 512);
1288
1289 try {
1290 const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('icons', piece, w, h, async () => {
1291 const result = await grabPiece(piece, {
1292 format: 'png',
1293 width: w,
1294 height: h,
1295 density: 1,
1296 });
1297 if (!result.success) throw new Error(result.error);
1298 // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer)
1299 if (result.cached && result.cdnUrl && !result.buffer) {
1300 // Fetch the buffer from the CDN URL
1301 const response = await fetch(result.cdnUrl);
1302 if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`);
1303 return Buffer.from(await response.arrayBuffer());
1304 }
1305 return result.buffer;
1306 });
1307
1308 if (fromCache && cdnUrl) {
1309 res.setHeader('X-Cache', 'HIT');
1310 res.setHeader('Cache-Control', 'public, max-age=86400');
1311 return res.redirect(302, cdnUrl);
1312 }
1313
1314 res.setHeader('Content-Type', 'image/png');
1315 res.setHeader('Content-Length', buffer.length);
1316 res.setHeader('Cache-Control', 'public, max-age=3600');
1317 res.setHeader('X-Cache', 'MISS');
1318 res.send(buffer);
1319 } catch (error) {
1320 console.error('Icon handler error:', error);
1321 res.status(500).json({ error: error.message });
1322 }
1323});
1324
1325// Animated WebP Icon endpoint - small animated square favicons
1326// GET /icon/{width}x{height}/{piece}.webp
1327// Uses 7-day Spaces cache since animated icons are expensive to generate
1328app.get('/icon/:size/:piece.webp', async (req, res) => {
1329 const { size, piece } = req.params;
1330 const [width, height] = size.split('x').map(n => parseInt(n) || 128);
1331 // Keep animated icons small for performance (max 128x128)
1332 const w = Math.min(width, 128);
1333 const h = Math.min(height, 128);
1334
1335 // Query params for customization
1336 const frames = Math.min(parseInt(req.query.frames) || 30, 60); // Default 30 frames, max 60
1337 const fps = Math.min(parseInt(req.query.fps) || 15, 30); // Default 15 fps, max 30
1338
1339 try {
1340 const cacheKey = `${piece}-${w}x${h}-f${frames}-fps${fps}`;
1341 const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('animated-icons', cacheKey, w, h, async () => {
1342 const result = await grabPiece(piece, {
1343 format: 'webp',
1344 width: w,
1345 height: h,
1346 density: 1,
1347 frames: frames,
1348 fps: fps,
1349 });
1350 if (!result.success) throw new Error(result.error);
1351 // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer)
1352 if (result.cached && result.cdnUrl && !result.buffer) {
1353 const response = await fetch(result.cdnUrl);
1354 if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`);
1355 return Buffer.from(await response.arrayBuffer());
1356 }
1357 return result.buffer;
1358 }, 'webp');
1359
1360 if (fromCache && cdnUrl) {
1361 res.setHeader('X-Cache', 'HIT');
1362 res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
1363 return res.redirect(302, cdnUrl);
1364 }
1365
1366 res.setHeader('Content-Type', 'image/webp');
1367 res.setHeader('Content-Length', buffer.length);
1368 res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day for fresh
1369 res.setHeader('X-Cache', 'MISS');
1370 res.send(buffer);
1371 } catch (error) {
1372 console.error('Animated icon handler error:', error);
1373 res.status(500).json({ error: error.message });
1374 }
1375});
1376
1377// Preview endpoint - larger social media images (compatible with grab.aesthetic.computer)
1378// GET /preview/{width}x{height}/{piece}.png
1379// Uses 24h Spaces cache to avoid regenerating on every request
1380app.get('/preview/:size/:piece.png', async (req, res) => {
1381 const { size, piece } = req.params;
1382 const [width, height] = size.split('x').map(n => parseInt(n) || 1200);
1383 const w = Math.min(width, 1920);
1384 const h = Math.min(height, 1080);
1385
1386 try {
1387 const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('previews', piece, w, h, async () => {
1388 const result = await grabPiece(piece, {
1389 format: 'png',
1390 width: w,
1391 height: h,
1392 density: 4,
1393 viewportScale: 1,
1394 });
1395 if (!result.success) throw new Error(result.error);
1396 // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer)
1397 if (result.cached && result.cdnUrl && !result.buffer) {
1398 const response = await fetch(result.cdnUrl);
1399 if (!response.ok) throw new Error(`Failed to fetch cached preview: ${response.status}`);
1400 return Buffer.from(await response.arrayBuffer());
1401 }
1402 return result.buffer;
1403 });
1404
1405 if (fromCache && cdnUrl) {
1406 res.setHeader('X-Cache', 'HIT');
1407 res.setHeader('Cache-Control', 'public, max-age=86400');
1408 return res.redirect(302, cdnUrl);
1409 }
1410
1411 res.setHeader('Content-Type', 'image/png');
1412 res.setHeader('Content-Length', buffer.length);
1413 res.setHeader('Cache-Control', 'public, max-age=3600');
1414 res.setHeader('X-Cache', 'MISS');
1415 res.send(buffer);
1416 } catch (error) {
1417 console.error('Preview handler error:', error);
1418 res.status(500).json({ error: error.message });
1419 }
1420});
1421
1422// Product images — static assets for AC hardware/products
1423// GET /product/{name}.png — redirects to Spaces CDN: products/{name}.png
1424const PRODUCT_CDN = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer';
1425app.get('/product/:name.png', (req, res) => {
1426 const { name } = req.params;
1427 if (!/^[a-z0-9-]+$/.test(name)) return res.status(400).json({ error: 'Invalid product name' });
1428 res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
1429 res.redirect(302, `${PRODUCT_CDN}/products/${name}.png`);
1430});
1431
1432// Grab endpoint - capture screenshots/GIFs from KidLisp pieces
1433app.post('/grab', grabHandler);
1434app.get('/grab/:format/:width/:height/:piece', grabGetHandler);
1435app.post('/grab-ipfs', grabIPFSHandler);
1436
1437// Grab status endpoint
1438app.get('/grab-status', (req, res) => {
1439 res.json({
1440 active: getActiveGrabs(),
1441 recent: getRecentGrabs(),
1442 queue: getQueueStatus(),
1443 progress: getCurrentProgress(),
1444 grabProgress: getAllProgress(),
1445 concurrency: getConcurrencyStatus(),
1446 osBaseBuilds: getOSBaseBuildsSummary(),
1447 });
1448});
1449
1450// Cleanup stale grabs (grabs stuck for > 5 minutes)
1451app.post('/grab-cleanup', (req, res) => {
1452 const result = cleanupStaleGrabs();
1453 addServerLog('cleanup', '🧹', `Manual cleanup: ${result.cleaned} stale grabs removed`);
1454 res.json({
1455 success: true,
1456 ...result
1457 });
1458});
1459
1460// Emergency clear all active grabs (admin only)
1461app.post('/grab-clear', (req, res) => {
1462 const result = clearAllActiveGrabs();
1463 addServerLog('cleanup', '🗑️', `Emergency clear: ${result.cleared} grabs force-cleared`);
1464 res.json({
1465 success: true,
1466 ...result
1467 });
1468});
1469
1470// Frozen pieces API - get list of frozen pieces
1471app.get('/api/frozen', (req, res) => {
1472 res.json({
1473 frozen: getFrozenPieces()
1474 });
1475});
1476
1477// Clear a piece from the frozen list
1478app.delete('/api/frozen/:piece', async (req, res) => {
1479 const piece = decodeURIComponent(req.params.piece);
1480 const result = await clearFrozenPiece(piece);
1481 addServerLog('cleanup', '✅', `Cleared frozen piece: ${piece}`);
1482 res.json(result);
1483});
1484
1485// Live collection thumbnail endpoint - redirects to most recent kept WebP
1486// Use this as the collection imageUri for a dynamic thumbnail
1487app.get('/keeps/latest', async (req, res) => {
1488 let latest = getLatestKeepThumbnail();
1489 if (!latest) {
1490 latest = await ensureLatestKeepThumbnail();
1491 }
1492 if (!latest) {
1493 return res.status(404).json({
1494 error: 'No keeps have been captured yet',
1495 hint: 'No minted keep thumbnail found in oven or kidlisp records yet'
1496 });
1497 }
1498
1499 // Redirect to IPFS gateway
1500 const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`;
1501 res.redirect(302, gatewayUrl);
1502});
1503
1504// Get latest thumbnail for a specific piece
1505app.get('/keeps/latest/:piece', (req, res) => {
1506 const latest = getLatestIPFSUpload(req.params.piece);
1507 if (!latest) {
1508 return res.status(404).json({
1509 error: `No keeps captured for piece: ${req.params.piece}`,
1510 hint: `Mint ${req.params.piece} with --thumbnail flag to populate this endpoint`
1511 });
1512 }
1513
1514 const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`;
1515 res.redirect(302, gatewayUrl);
1516});
1517
1518// Get all latest thumbnails as JSON (for debugging/monitoring)
1519app.get('/keeps/all', (req, res) => {
1520 res.json({
1521 latest: getLatestKeepThumbnail(),
1522 byPiece: getAllLatestIPFSUploads()
1523 });
1524});
1525
1526// =============================================================================
1527// KidLisp.com OG Preview Image Endpoint
1528// =============================================================================
1529
1530// Fast static PNG endpoint - redirects instantly to CDN (for social media crawlers)
1531// Use this URL in og:image and twitter:image meta tags
1532app.get('/kidlisp-og.png', async (req, res) => {
1533 try {
1534 const layout = req.query.layout || 'mosaic';
1535
1536 // Get cached URL without triggering generation (fast!)
1537 const url = await getLatestOGImageUrl(layout);
1538
1539 if (url) {
1540 // Redirect to CDN - instant response (302 so browsers don't permanently cache stale redirects)
1541 res.setHeader('Cache-Control', 'public, max-age=3600');
1542 res.setHeader('X-Cache', 'CDN');
1543 return res.redirect(302, url);
1544 }
1545
1546 // No cached image yet - trigger background regeneration and serve a recent fallback
1547 addServerLog('warn', '⚠️', `OG cache miss for ${layout}, triggering regen`);
1548
1549 // Trigger async regeneration (don't await)
1550 regenerateOGImagesBackground().catch(err => {
1551 addServerLog('error', '❌', `Async OG regen failed: ${err.message}`);
1552 });
1553
1554 // Use yesterday's image as fallback (likely exists)
1555 const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
1556 const fallbackUrl = `https://art.aesthetic.computer/og/kidlisp/${yesterday}-${layout}.png`;
1557
1558 res.setHeader('Cache-Control', 'public, max-age=300'); // Short cache for fallback
1559 return res.redirect(302, fallbackUrl);
1560
1561 } catch (error) {
1562 console.error('KidLisp OG PNG error:', error);
1563 // Ultimate fallback - yesterday's mosaic
1564 const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
1565 return res.redirect(302, `https://art.aesthetic.computer/og/kidlisp/${yesterday}-mosaic.png`);
1566 }
1567});
1568
1569// ─── TzKT dapp images ───────────────────────────────────────────────────────
1570app.get('/kidlisp-og/tzkt-cover.jpg', async (req, res) => {
1571 try {
1572 addServerLog('info', '🖼️', 'TzKT cover (640x360)');
1573 const result = await generateKidlispOGImage('mosaic', true, { noDotCom: true });
1574 const jpg = await sharp(result.buffer).resize(640, 360, { fit: 'cover' }).jpeg({ quality: 90 }).toBuffer();
1575 res.setHeader('Content-Type', 'image/jpeg');
1576 res.setHeader('Content-Length', jpg.length);
1577 res.setHeader('Cache-Control', 'public, max-age=3600');
1578 res.send(jpg);
1579 } catch (error) {
1580 console.error('TzKT cover error:', error);
1581 res.status(500).json({ error: error.message });
1582 }
1583});
1584
1585app.get('/kidlisp-og/tzkt-logo.jpg', async (req, res) => {
1586 try {
1587 addServerLog('info', '🖼️', 'TzKT logo (200x200)');
1588 // Render $ in Comic Relief via Puppeteer (lightweight, ~3s)
1589 const puppeteer = await import('puppeteer');
1590 const browser = await puppeteer.default.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] });
1591 const page = await browser.newPage();
1592 try {
1593 await page.setViewport({ width: 200, height: 200, deviceScaleFactor: 2 });
1594 await page.setContent(`<!DOCTYPE html><html><head>
1595 <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@700&display=swap" rel="stylesheet">
1596 <style>
1597 *{margin:0;padding:0}
1598 body{width:200px;height:200px;display:flex;align-items:center;justify-content:center;background:#9370DB}
1599 .d{font-family:'Comic Relief',cursive;font-size:170px;font-weight:700;color:limegreen;text-shadow:6px 6px 0 rgba(0,0,0,0.5);margin-top:-10px}
1600 </style></head>
1601 <body><div class="d">$</div></body></html>`, { waitUntil: 'networkidle0' });
1602 await page.evaluate(() => document.fonts.ready);
1603 await new Promise(r => setTimeout(r, 300));
1604 const png = await page.screenshot({ type: 'png' });
1605 const jpg = await sharp(png).resize(200, 200).jpeg({ quality: 90 }).toBuffer();
1606 res.setHeader('Content-Type', 'image/jpeg');
1607 res.setHeader('Content-Length', jpg.length);
1608 res.setHeader('Cache-Control', 'public, max-age=86400');
1609 res.send(jpg);
1610 } finally { await page.close(); await browser.close(); }
1611 } catch (error) {
1612 console.error('TzKT logo error:', error);
1613 res.status(500).json({ error: error.message });
1614 }
1615});
1616
1617// ─── Site-specific OG images ─────────────────────────────────────────────────
1618app.get('/kidlisp-og/site/:site.png', async (req, res) => {
1619 const site = req.params.site;
1620 if (!['keeps', 'buy'].includes(site)) return res.status(400).json({ error: 'Invalid site', valid: ['keeps', 'buy'] });
1621 try {
1622 addServerLog('info', '🖼️', `Site OG: ${site}.kidlisp.com`);
1623 // Use raw mosaic (no branding text) as background
1624 const rawUrl = await getLatestOGImageUrl('mosaic-raw');
1625 let mosaicBuffer;
1626 if (rawUrl) {
1627 const resp = await fetch(rawUrl);
1628 if (!resp.ok) throw new Error(`Failed to fetch raw mosaic: ${resp.status}`);
1629 mosaicBuffer = Buffer.from(await resp.arrayBuffer());
1630 } else {
1631 // Fallback: generate mosaic and use its buffer (will have branding but better than nothing)
1632 const result = await generateKidlispOGImage('mosaic', false);
1633 mosaicBuffer = result.buffer;
1634 if (!mosaicBuffer && result.url) {
1635 const resp = await fetch(result.url);
1636 if (!resp.ok) throw new Error(`Failed to fetch mosaic: ${resp.status}`);
1637 mosaicBuffer = Buffer.from(await resp.arrayBuffer());
1638 }
1639 }
1640 const bg = await sharp(mosaicBuffer).blur(6).toBuffer();
1641 const darkOverlay = Buffer.from(`<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="rgba(0,0,0,0.4)"/></svg>`);
1642 const prefixLetters = site === 'keeps'
1643 ? 'keeps'.split('').map(c => `<tspan fill="#00ff41">${c}</tspan>`).join('')
1644 : [['b','#FF6B6B'],['u','#4ECDC4'],['y','#FFE66D']].map(([c,col]) => `<tspan fill="${col}">${c}</tspan>`).join('');
1645 const kidlispLetters = [['K','#FF6B6B'],['i','#4ECDC4'],['d','#FFE66D'],['L','#A8E6CF'],['i','#FF8B94'],['s','#F7DC6F'],['p','#BB8FCE']].map(([c,col]) => `<tspan fill="${col}">${c}</tspan>`).join('');
1646 const tspans = `${prefixLetters}<tspan fill="#70D6FF">.</tspan>${kidlispLetters}<tspan fill="#70D6FF">.com</tspan>`;
1647 const brandingSvg = Buffer.from(`<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg"><text x="600" y="340" font-family="Comic Sans MS, cursive" font-size="90" font-weight="700" text-anchor="middle" style="paint-order: stroke; stroke: black; stroke-width: 6px;">${tspans}</text></svg>`);
1648 const composited = await sharp(bg).composite([{ input: darkOverlay }, { input: brandingSvg }]).png().toBuffer();
1649 res.setHeader('Content-Type', 'image/png');
1650 res.setHeader('Content-Length', composited.length);
1651 res.setHeader('Cache-Control', 'public, max-age=3600');
1652 res.send(composited);
1653 } catch (error) {
1654 console.error(`Site OG error (${site}):`, error);
1655 res.status(500).json({ error: error.message });
1656 }
1657});
1658
1659// Dynamic OG image for kidlisp.com - rotates daily based on top hits
1660// Supports multiple layout options: featured, mosaic, filmstrip, code-split
1661app.get('/kidlisp-og', async (req, res) => {
1662 try {
1663 const layout = req.query.layout || 'featured';
1664 const force = req.query.force === 'true';
1665
1666 // Validate layout
1667 const validLayouts = ['featured', 'mosaic', 'filmstrip', 'code-split'];
1668 if (!validLayouts.includes(layout)) {
1669 return res.status(400).json({
1670 error: 'Invalid layout',
1671 valid: validLayouts,
1672 });
1673 }
1674
1675 addServerLog('info', '🖼️', `KidLisp OG request: ${layout}${force ? ' (force)' : ''}`);
1676
1677 const result = await generateKidlispOGImage(layout, force);
1678
1679 if (result.cached && result.url) {
1680 // Redirect to CDN URL for cached images
1681 addServerLog('success', '📦', `OG cache hit → ${result.url.split('/').pop()}`);
1682 res.setHeader('X-Cache', 'HIT');
1683 res.setHeader('Cache-Control', 'public, max-age=3600');
1684 return res.redirect(302, result.url);
1685 }
1686
1687 // Fresh generation - return the buffer directly
1688 addServerLog('success', '🎨', `OG generated: ${layout} (${result.featuredPiece?.code || 'mosaic'})`);
1689 res.setHeader('Content-Type', 'image/png');
1690 res.setHeader('Content-Length', result.buffer.length);
1691 res.setHeader('Cache-Control', 'public, max-age=86400'); // 24hr cache
1692 res.setHeader('X-Cache', 'MISS');
1693 res.setHeader('X-OG-Layout', layout);
1694 res.setHeader('X-OG-Generated', result.generatedAt);
1695 if (result.featuredPiece) {
1696 res.setHeader('X-OG-Featured', result.featuredPiece.code);
1697 }
1698 res.send(result.buffer);
1699
1700 } catch (error) {
1701 console.error('KidLisp OG error:', error);
1702 addServerLog('error', '❌', `OG error: ${error.message}`);
1703 res.status(500).json({
1704 error: 'Failed to generate OG image',
1705 message: error.message
1706 });
1707 }
1708});
1709
1710// OG image cache status endpoint
1711app.get('/kidlisp-og/status', (req, res) => {
1712 res.json({
1713 ...getOGImageCacheStatus(),
1714 availableLayouts: ['featured', 'mosaic', 'filmstrip', 'code-split'],
1715 usage: {
1716 recommended: '/kidlisp-og.png (instant, for og:image tags)',
1717 withLayout: '/kidlisp-og.png?layout=mosaic',
1718 dynamic: '/kidlisp-og (may regenerate on-demand)',
1719 forceRegenerate: '/kidlisp-og?force=true',
1720 },
1721 note: 'Use /kidlisp-og.png for social media meta tags - it redirects instantly to cached CDN images'
1722 });
1723});
1724
1725// Preview all OG images (generalized for kidlisp, notepat, etc)
1726app.get('/og-preview', (req, res) => {
1727 const baseUrl = req.protocol + '://' + req.get('host');
1728
1729 const ogImages = [
1730 {
1731 name: 'KidLisp',
1732 slug: 'kidlisp-og',
1733 prodUrls: [
1734 'https://kidlisp.com',
1735 'https://aesthetic.computer/kidlisp'
1736 ],
1737 layouts: ['featured', 'mosaic', 'filmstrip', 'code-split'],
1738 description: 'Dynamic layouts featuring recent KidLisp pieces'
1739 },
1740 {
1741 name: 'Notepat',
1742 slug: 'notepat-og',
1743 prodUrls: [
1744 'https://notepat.com',
1745 'https://aesthetic.computer/notepat'
1746 ],
1747 layouts: null, // Single layout
1748 description: 'Split-layout chromatic piano interface'
1749 }
1750 ];
1751
1752 res.setHeader('Content-Type', 'text/html');
1753 res.send(`<!DOCTYPE html>
1754<html>
1755<head>
1756 <title>OG Image Preview</title>
1757 <style>
1758 body { font-family: monospace; background: #1a1a2e; color: white; padding: 20px; max-width: 1400px; margin: 0 auto; }
1759 h1 { color: #88ff88; }
1760 h2 { color: #ffaa00; margin-top: 40px; }
1761 h3 { color: #ff88aa; margin-top: 20px; }
1762 .note { background: #2a2a4e; padding: 16px; border-radius: 8px; margin: 20px 0; line-height: 1.6; }
1763 .note code { background: #3a3a5e; padding: 2px 6px; border-radius: 4px; color: #88ffaa; }
1764 .og-section { border: 2px solid #333; padding: 20px; border-radius: 8px; margin: 30px 0; background: #16162e; }
1765 .prod-urls { margin: 15px 0; }
1766 .prod-urls a {
1767 display: inline-block;
1768 color: #88ccff;
1769 text-decoration: none;
1770 background: #2a2a4e;
1771 padding: 6px 12px;
1772 border-radius: 4px;
1773 margin: 4px 4px 4px 0;
1774 }
1775 .prod-urls a:hover { background: #3a3a5e; }
1776 .layout { margin: 20px 0; padding: 15px; background: #0a0a1e; border-radius: 6px; }
1777 .layout h4 { color: #ffcc66; margin: 0 0 10px 0; }
1778 .layout img { max-width: 100%; border: 2px solid #444; border-radius: 4px; }
1779 .layout .actions { margin: 10px 0; }
1780 .layout .actions a {
1781 color: #88ccff;
1782 margin-right: 15px;
1783 text-decoration: none;
1784 }
1785 .layout .actions a:hover { text-decoration: underline; }
1786 .single-image { margin: 20px 0; }
1787 .single-image img { max-width: 100%; border: 2px solid #444; border-radius: 4px; }
1788 .back-link { display: inline-block; margin-top: 40px; color: #888; text-decoration: none; }
1789 .back-link:hover { color: #aaa; }
1790 </style>
1791</head>
1792<body>
1793 <h1>🖼️ OG Image Preview Dashboard</h1>
1794 <div class="note">
1795 <strong>About:</strong> This page shows all Open Graph (OG) images used for social media previews.<br>
1796 <strong>Usage:</strong> Use the <code>.png</code> endpoints in meta tags for instant CDN redirects (no timeouts).<br>
1797 <strong>Testing:</strong> Click production URLs below to verify OG tags are working correctly.
1798 </div>
1799
1800 ${ogImages.map(og => `
1801 <div class="og-section">
1802 <h2>${og.name}</h2>
1803 <p style="color: #aaa; margin: 10px 0;">${og.description}</p>
1804
1805 <div class="prod-urls">
1806 <strong style="color: #88ff88;">Production URLs:</strong><br>
1807 ${og.prodUrls.map(url => `<a href="${url}" target="_blank">${url} →</a>`).join(' ')}
1808 </div>
1809
1810 <div class="note">
1811 <strong>OG Endpoint:</strong> <code>${baseUrl}/${og.slug}.png</code>
1812 </div>
1813
1814 ${og.layouts ? `
1815 <h3>Layouts:</h3>
1816 ${og.layouts.map(layout => `
1817 <div class="layout">
1818 <h4>${layout.charAt(0).toUpperCase() + layout.slice(1)}</h4>
1819 <div class="actions">
1820 <a href="${baseUrl}/${og.slug}?layout=${layout}&force=true">Force Regenerate</a>
1821 <a href="${baseUrl}/${og.slug}/status">Cache Status</a>
1822 </div>
1823 <img src="${baseUrl}/${og.slug}?layout=${layout}" alt="${layout} layout" loading="lazy">
1824 </div>
1825 `).join('')}
1826 ` : `
1827 <div class="single-image">
1828 <div class="actions">
1829 <a href="${baseUrl}/${og.slug}.png?force=true">Force Regenerate</a>
1830 </div>
1831 <img src="${baseUrl}/${og.slug}.png" alt="${og.name} OG image" loading="lazy">
1832 </div>
1833 `}
1834 </div>
1835 `).join('')}
1836
1837 <a href="/" class="back-link">← Back to Oven Dashboard</a>
1838</body>
1839</html>`);
1840});
1841
1842// Legacy redirect for old kidlisp preview URL
1843app.get('/kidlisp-og/preview', (req, res) => {
1844 res.redirect(302, '/og-preview');
1845});
1846
1847// Notepat branded OG image for notepat.com
1848app.get('/notepat-og.png', async (req, res) => {
1849 try {
1850 const force = req.query.force === 'true';
1851
1852 addServerLog('info', '🎹', `Notepat OG request${force ? ' (force)' : ''}`);
1853
1854 const result = await generateNotepatOGImage(force);
1855
1856 if (result.cached && result.url) {
1857 // Proxy the image back instead of redirecting (iOS crawlers won't follow 301s on og:image)
1858 addServerLog('success', '📦', `Notepat OG cache hit → proxying`);
1859 try {
1860 const cdnResponse = await fetch(result.url);
1861 if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`);
1862 const buffer = Buffer.from(await cdnResponse.arrayBuffer());
1863 res.setHeader('Content-Type', 'image/png');
1864 res.setHeader('Content-Length', buffer.length);
1865 res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache
1866 res.setHeader('X-Cache', 'HIT');
1867 return res.send(buffer);
1868 } catch (fetchErr) {
1869 // Fall back to redirect if proxy fails
1870 addServerLog('warn', '⚠️', `Notepat OG proxy failed, falling back to redirect: ${fetchErr.message}`);
1871 return res.redirect(301, result.url);
1872 }
1873 }
1874
1875 // Fresh generation - return the buffer directly
1876 addServerLog('success', '🎨', `Notepat OG generated`);
1877 res.setHeader('Content-Type', 'image/png');
1878 res.setHeader('Content-Length', result.buffer.length);
1879 res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache
1880 res.setHeader('X-Cache', 'MISS');
1881 res.send(result.buffer);
1882
1883 } catch (error) {
1884 console.error('Notepat OG error:', error);
1885 addServerLog('error', '❌', `Notepat OG error: ${error.message}`);
1886 res.status(500).json({
1887 error: 'Failed to generate Notepat OG image',
1888 message: error.message
1889 });
1890 }
1891});
1892
1893// =============================================================================
1894// News OG Image - Dynamic social cards for news.aesthetic.computer articles
1895// =============================================================================
1896
1897app.get('/news-og/:code.png', async (req, res) => {
1898 try {
1899 const code = req.params.code;
1900 const force = req.query.force === 'true';
1901
1902 addServerLog('info', '📰', `News OG request: ${code}${force ? ' (force)' : ''}`);
1903
1904 // Fetch post from MongoDB.
1905 const mongoUri = process.env.MONGODB_CONNECTION_STRING;
1906 const dbName = process.env.MONGODB_NAME;
1907 if (!mongoUri || !dbName) {
1908 return res.status(500).json({ error: 'MongoDB not configured' });
1909 }
1910
1911 const client = new MongoClient(mongoUri);
1912 await client.connect();
1913 const db = client.db(dbName);
1914 const post = await db.collection('news-posts').findOne({ code, status: { $ne: 'dead' } });
1915
1916 if (!post) {
1917 await client.close();
1918 return res.status(404).json({ error: 'Post not found' });
1919 }
1920
1921 // Hydrate handle.
1922 if (post.user) {
1923 const handleDoc = await db.collection('@handles').findOne({ _id: post.user });
1924 post.handle = handleDoc ? `@${handleDoc.handle}` : '@anon';
1925 } else {
1926 post.handle = '@anon';
1927 }
1928 await client.close();
1929
1930 const result = await generateNewsOGImage(post, force);
1931
1932 if (result.cached && result.url) {
1933 addServerLog('success', '📦', `News OG cache hit: ${code} → proxying`);
1934 try {
1935 const cdnResponse = await fetch(result.url);
1936 if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`);
1937 const buffer = Buffer.from(await cdnResponse.arrayBuffer());
1938 res.setHeader('Content-Type', 'image/png');
1939 res.setHeader('Content-Length', buffer.length);
1940 res.setHeader('Cache-Control', 'public, max-age=604800');
1941 res.setHeader('X-Cache', 'HIT');
1942 return res.send(buffer);
1943 } catch (fetchErr) {
1944 addServerLog('warn', '⚠️', `News OG proxy failed: ${fetchErr.message}`);
1945 return res.redirect(301, result.url);
1946 }
1947 }
1948
1949 addServerLog('success', '🎨', `News OG generated: ${code}`);
1950 res.setHeader('Content-Type', 'image/png');
1951 res.setHeader('Content-Length', result.buffer.length);
1952 res.setHeader('Cache-Control', 'public, max-age=604800');
1953 res.setHeader('X-Cache', 'MISS');
1954 res.send(result.buffer);
1955 } catch (error) {
1956 console.error('News OG error:', error);
1957 addServerLog('error', '❌', `News OG error: ${error.message}`);
1958 res.status(500).json({ error: 'Failed to generate News OG image', message: error.message });
1959 }
1960});
1961
1962// =============================================================================
1963// KidLisp Backdrop - Animated WebP for login screens, Auth0, etc.
1964// =============================================================================
1965
1966// Fast redirect to CDN-cached 2048px animated webp
1967app.get('/kidlisp-backdrop.webp', async (req, res) => {
1968 try {
1969 // Get cached URL without triggering generation (fast!)
1970 const url = await getLatestBackdropUrl();
1971
1972 if (url) {
1973 res.setHeader('Cache-Control', 'public, max-age=3600');
1974 res.setHeader('X-Cache', 'CDN');
1975 return res.redirect(301, url);
1976 }
1977
1978 // No cached backdrop - generate synchronously (first request will be slow)
1979 addServerLog('warn', '⚠️', 'Backdrop cache miss, generating...');
1980
1981 const result = await generateKidlispBackdrop(false);
1982 if (result.url) {
1983 res.setHeader('Cache-Control', 'public, max-age=3600');
1984 res.setHeader('X-Cache', 'MISS');
1985 return res.redirect(302, result.url);
1986 }
1987
1988 res.status(503).json({ error: 'Backdrop generation in progress, try again shortly' });
1989
1990 } catch (error) {
1991 console.error('Backdrop error:', error);
1992 res.status(500).json({ error: 'Failed to get backdrop', message: error.message });
1993 }
1994});
1995
1996// Dynamic backdrop generation (may regenerate on-demand)
1997app.get('/kidlisp-backdrop', async (req, res) => {
1998 try {
1999 const force = req.query.force === 'true';
2000
2001 addServerLog('info', '🖼️', `Backdrop request${force ? ' (force)' : ''}`);
2002
2003 const result = await generateKidlispBackdrop(force);
2004
2005 if (result.url) {
2006 addServerLog('success', '🎨', `Backdrop: ${result.piece} → ${result.cached ? 'cached' : 'generated'}`);
2007 res.setHeader('Cache-Control', 'public, max-age=3600');
2008 res.setHeader('X-Cache', result.cached ? 'HIT' : 'MISS');
2009 res.setHeader('X-Backdrop-Piece', result.piece || 'unknown');
2010 return res.redirect(302, result.url);
2011 }
2012
2013 res.status(500).json({ error: 'Failed to generate backdrop' });
2014
2015 } catch (error) {
2016 console.error('Backdrop error:', error);
2017 addServerLog('error', '❌', `Backdrop error: ${error.message}`);
2018 res.status(500).json({ error: 'Failed to generate backdrop', message: error.message });
2019 }
2020});
2021
2022// =============================================================================
2023// App Store Screenshots - Generate screenshots for Google Play / App Store
2024// =============================================================================
2025
2026// App screenshots dashboard
2027app.get('/app-screenshots', (req, res) => {
2028 const piece = req.query.piece || 'prompt';
2029 const presets = Object.entries(APP_SCREENSHOT_PRESETS);
2030
2031 res.setHeader('Content-Type', 'text/html');
2032 res.send(`<!DOCTYPE html>
2033<html>
2034<head>
2035 <meta charset="utf-8">
2036 <title>📱 App Store Screenshots - Oven</title>
2037 <meta name="viewport" content="width=device-width, initial-scale=1">
2038 <style>
2039 * { box-sizing: border-box; margin: 0; padding: 0; }
2040 body {
2041 font-family: monospace;
2042 font-size: 14px;
2043 background: #0a0a12;
2044 color: #fff;
2045 min-height: 100vh;
2046 padding: 20px;
2047 }
2048 header {
2049 display: flex;
2050 align-items: center;
2051 justify-content: space-between;
2052 flex-wrap: wrap;
2053 gap: 1em;
2054 padding-bottom: 20px;
2055 border-bottom: 2px solid #333;
2056 margin-bottom: 20px;
2057 }
2058 h1 { color: #88ff88; font-size: 1.5em; }
2059 .controls {
2060 display: flex;
2061 gap: 1em;
2062 align-items: center;
2063 flex-wrap: wrap;
2064 }
2065 input, select, button {
2066 font-family: monospace;
2067 font-size: 14px;
2068 padding: 8px 12px;
2069 border: 1px solid #444;
2070 background: #1a1a2e;
2071 color: #fff;
2072 border-radius: 4px;
2073 }
2074 button {
2075 cursor: pointer;
2076 background: #2a2a4e;
2077 }
2078 button:hover { background: #3a3a5e; border-color: #88ff88; }
2079 button:disabled { opacity: 0.5; cursor: not-allowed; }
2080 .btn-primary { background: #226622; border-color: #88ff88; }
2081 .btn-primary:hover { background: #338833; }
2082
2083 .requirements {
2084 background: #1a1a2e;
2085 padding: 15px;
2086 border-radius: 8px;
2087 margin-bottom: 20px;
2088 border: 1px solid #333;
2089 }
2090 .requirements h3 { color: #ffaa00; margin-bottom: 10px; }
2091 .requirements ul { list-style: none; }
2092 .requirements li { margin: 5px 0; padding-left: 20px; position: relative; }
2093 .requirements li::before { content: '✓'; position: absolute; left: 0; color: #88ff88; }
2094
2095 .category { margin-bottom: 30px; }
2096 .category h2 {
2097 color: #ffaa00;
2098 margin-bottom: 15px;
2099 padding-bottom: 10px;
2100 border-bottom: 1px solid #333;
2101 }
2102 .screenshots {
2103 display: grid;
2104 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
2105 gap: 20px;
2106 }
2107 .screenshot {
2108 background: #1a1a2e;
2109 border: 1px solid #333;
2110 border-radius: 8px;
2111 overflow: hidden;
2112 }
2113 .screenshot:hover { border-color: #88ff88; }
2114 .screenshot-preview {
2115 background: #000;
2116 display: flex;
2117 align-items: center;
2118 justify-content: center;
2119 min-height: 200px;
2120 position: relative;
2121 }
2122 .screenshot-preview img {
2123 max-width: 100%;
2124 max-height: 300px;
2125 object-fit: contain;
2126 }
2127 .screenshot-preview .loading {
2128 position: absolute;
2129 color: #888;
2130 text-align: center;
2131 padding: 10px;
2132 display: flex;
2133 flex-direction: column;
2134 align-items: center;
2135 justify-content: center;
2136 }
2137 .screenshot-preview .loading .preview-img {
2138 width: 80px;
2139 height: 80px;
2140 image-rendering: pixelated;
2141 border: 1px solid #333;
2142 margin-bottom: 8px;
2143 display: none;
2144 }
2145 .screenshot-preview .loading .progress-text {
2146 font-size: 11px;
2147 margin-top: 8px;
2148 color: #88ff88;
2149 font-family: monospace;
2150 max-width: 150px;
2151 word-break: break-word;
2152 }
2153 .screenshot-preview .loading .progress-bar {
2154 width: 80%;
2155 max-width: 150px;
2156 height: 4px;
2157 background: #333;
2158 border-radius: 2px;
2159 margin: 8px auto 0;
2160 overflow: hidden;
2161 }
2162 .screenshot-preview .loading .progress-bar-fill {
2163 height: 100%;
2164 background: #88ff88;
2165 width: 0%;
2166 transition: width 0.3s ease;
2167 }
2168 .screenshot-preview .error {
2169 color: #ff4444;
2170 padding: 20px;
2171 text-align: center;
2172 }
2173 .screenshot-info {
2174 padding: 15px;
2175 }
2176 .screenshot-info h4 { margin-bottom: 8px; }
2177 .screenshot-info .dims {
2178 color: #888;
2179 font-size: 12px;
2180 margin-bottom: 10px;
2181 }
2182 .screenshot-info .actions {
2183 display: flex;
2184 gap: 8px;
2185 flex-wrap: wrap;
2186 }
2187 .screenshot-info .actions a, .screenshot-info .actions button {
2188 font-size: 12px;
2189 padding: 6px 10px;
2190 text-decoration: none;
2191 color: #88ccff;
2192 }
2193 .status {
2194 position: fixed;
2195 bottom: 20px;
2196 right: 20px;
2197 background: #2a2a4e;
2198 padding: 15px 20px;
2199 border-radius: 8px;
2200 border: 1px solid #444;
2201 display: none;
2202 }
2203 .status.show { display: block; }
2204 .status.success { border-color: #88ff88; }
2205 .status.error { border-color: #ff4444; }
2206
2207 .back-link {
2208 color: #88ccff;
2209 text-decoration: none;
2210 margin-bottom: 20px;
2211 display: inline-block;
2212 }
2213 .back-link:hover { text-decoration: underline; }
2214 </style>
2215</head>
2216<body>
2217 <a href="/" class="back-link">← Back to Oven Dashboard</a>
2218
2219 <header>
2220 <h1>📱 App Store Screenshots</h1>
2221 <div class="controls">
2222 <label>Piece: <input type="text" id="piece-input" value="${piece}" placeholder="prompt"></label>
2223 <button onclick="changePiece()">Load</button>
2224 <button onclick="regenerateAll()" class="btn-primary">🔄 Regenerate All</button>
2225 <button onclick="downloadZip()" class="btn-primary">📦 Download ZIP</button>
2226 </div>
2227 </header>
2228
2229 <div class="requirements">
2230 <h3>📋 Google Play Requirements</h3>
2231 <ul>
2232 <li>PNG or JPEG, max 8MB each</li>
2233 <li>16:9 or 9:16 aspect ratio</li>
2234 <li>Phone: 320-3840px per side, 1080px min for promotion</li>
2235 <li>7" Tablet: 320-3840px per side</li>
2236 <li>10" Tablet: 1080-7680px per side</li>
2237 <li>2-8 screenshots per category required</li>
2238 </ul>
2239 </div>
2240
2241 <div class="category">
2242 <h2>📱 Phone Screenshots</h2>
2243 <div class="screenshots">
2244 ${presets.filter(([k, v]) => v.category === 'phone').map(([key, preset]) => `
2245 <div class="screenshot" data-preset="${key}">
2246 <div class="screenshot-preview">
2247 <span class="loading" data-loading="${key}">
2248 <img class="preview-img" alt="preview">
2249 <span class="loading-text">🔥 Loading...</span>
2250 <div class="progress-text" data-progress-text="${key}"></div>
2251 <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div>
2252 </span>
2253 <img src="/app-screenshots/${key}/${piece}.png"
2254 alt="${preset.label}"
2255 data-img="${key}"
2256 onload="this.previousElementSibling.style.display='none'"
2257 onerror="this.style.display='none'; this.previousElementSibling.innerHTML='❌ Failed to load'">
2258 </div>
2259 <div class="screenshot-info">
2260 <h4>${preset.label}</h4>
2261 <div class="dims">${preset.width} × ${preset.height}px</div>
2262 <div class="actions">
2263 <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">⬇️ Download</a>
2264 <button onclick="regenerate('${key}')">🔄 Regenerate</button>
2265 </div>
2266 </div>
2267 </div>
2268 `).join('')}
2269 </div>
2270 </div>
2271
2272 <div class="category">
2273 <h2>📱 7-inch Tablet Screenshots</h2>
2274 <div class="screenshots">
2275 ${presets.filter(([k, v]) => v.category === 'tablet7').map(([key, preset]) => `
2276 <div class="screenshot" data-preset="${key}">
2277 <div class="screenshot-preview">
2278 <span class="loading" data-loading="${key}">
2279 <img class="preview-img" alt="preview">
2280 <span class="loading-text">🔥 Loading...</span>
2281 <div class="progress-text" data-progress-text="${key}"></div>
2282 <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div>
2283 </span>
2284 <img src="/app-screenshots/${key}/${piece}.png"
2285 alt="${preset.label}"
2286 data-img="${key}"
2287 onload="this.previousElementSibling.style.display='none'"
2288 onerror="this.style.display='none'; this.previousElementSibling.innerHTML='❌ Failed to load'">
2289 </div>
2290 <div class="screenshot-info">
2291 <h4>${preset.label}</h4>
2292 <div class="dims">${preset.width} × ${preset.height}px</div>
2293 <div class="actions">
2294 <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">⬇️ Download</a>
2295 <button onclick="regenerate('${key}')">🔄 Regenerate</button>
2296 </div>
2297 </div>
2298 </div>
2299 `).join('')}
2300 </div>
2301 </div>
2302
2303 <div class="category">
2304 <h2>📱 10-inch Tablet Screenshots</h2>
2305 <div class="screenshots">
2306 ${presets.filter(([k, v]) => v.category === 'tablet10').map(([key, preset]) => `
2307 <div class="screenshot" data-preset="${key}">
2308 <div class="screenshot-preview">
2309 <span class="loading" data-loading="${key}">
2310 <img class="preview-img" alt="preview">
2311 <span class="loading-text">🔥 Loading...</span>
2312 <div class="progress-text" data-progress-text="${key}"></div>
2313 <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div>
2314 </span>
2315 <img src="/app-screenshots/${key}/${piece}.png"
2316 alt="${preset.label}"
2317 data-img="${key}"
2318 onload="this.previousElementSibling.style.display='none'"
2319 onerror="this.style.display='none'; this.previousElementSibling.innerHTML='❌ Failed to load'">
2320 </div>
2321 <div class="screenshot-info">
2322 <h4>${preset.label}</h4>
2323 <div class="dims">${preset.width} × ${preset.height}px</div>
2324 <div class="actions">
2325 <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">⬇️ Download</a>
2326 <button onclick="regenerate('${key}')">🔄 Regenerate</button>
2327 </div>
2328 </div>
2329 </div>
2330 `).join('')}
2331 </div>
2332 </div>
2333
2334 <div id="status" class="status"></div>
2335
2336 <script>
2337 const currentPiece = '${piece}';
2338
2339 function showStatus(msg, type = 'info') {
2340 const el = document.getElementById('status');
2341 el.textContent = msg;
2342 el.className = 'status show ' + type;
2343 setTimeout(() => el.className = 'status', 3000);
2344 }
2345
2346 function changePiece() {
2347 const piece = document.getElementById('piece-input').value.trim() || 'prompt';
2348 window.location.href = '/app-screenshots?piece=' + encodeURIComponent(piece);
2349 }
2350
2351 document.getElementById('piece-input').addEventListener('keydown', (e) => {
2352 if (e.key === 'Enter') changePiece();
2353 });
2354
2355 async function regenerate(preset) {
2356 showStatus('Regenerating ' + preset + '... (this takes ~30s)');
2357 console.log('🔄 Starting regeneration for:', preset);
2358
2359 // Show loading indicator and hide current image
2360 const card = document.querySelector('[data-preset="' + preset + '"]');
2361 const img = card.querySelector('[data-img]');
2362 const loading = card.querySelector('.loading');
2363
2364 img.style.display = 'none';
2365 loading.style.display = 'flex';
2366 loading.innerHTML = '<img class="preview-img" alt="preview"><span class="loading-text">🔄 Regenerating...</span><div class="progress-text"></div><div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>';
2367
2368 const startTime = Date.now();
2369
2370 try {
2371 const controller = new AbortController();
2372 const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 min timeout
2373
2374 console.log('📡 Fetching with force=true...');
2375 const res = await fetch('/app-screenshots/' + preset + '/' + currentPiece + '.png?force=true&t=' + Date.now(), {
2376 signal: controller.signal,
2377 cache: 'no-store',
2378 headers: { 'Cache-Control': 'no-cache' }
2379 });
2380 clearTimeout(timeoutId);
2381
2382 const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
2383 console.log('📡 Response received after ' + elapsed + 's, status:', res.status);
2384
2385 if (res.ok) {
2386 // Force reload the image with cache-busting
2387 const newSrc = '/app-screenshots/' + preset + '/' + currentPiece + '.png?t=' + Date.now();
2388 console.log('🖼️ Setting new image src:', newSrc);
2389 img.src = newSrc;
2390 img.style.display = 'block';
2391 loading.style.display = 'none';
2392 showStatus('✅ ' + preset + ' regenerated in ' + elapsed + 's!', 'success');
2393 } else {
2394 const error = await res.text();
2395 console.error('❌ Regeneration failed:', res.status, error);
2396 loading.innerHTML = '❌ Failed: ' + (error || res.status);
2397 showStatus('❌ Failed to regenerate: ' + res.status, 'error');
2398 }
2399 } catch (err) {
2400 const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
2401 console.error('❌ Regeneration error after ' + elapsed + 's:', err);
2402 if (err.name === 'AbortError') {
2403 loading.innerHTML = '⏱️ Timeout - still processing?';
2404 showStatus('⏱️ Request timed out - try refreshing', 'error');
2405 } else {
2406 loading.innerHTML = '❌ ' + err.message;
2407 showStatus('❌ ' + err.message, 'error');
2408 }
2409 }
2410 }
2411
2412 async function regenerateAll() {
2413 const presets = ${JSON.stringify(Object.keys(APP_SCREENSHOT_PRESETS))};
2414 showStatus('Regenerating all screenshots...');
2415
2416 for (const preset of presets) {
2417 showStatus('Regenerating ' + preset + '...');
2418 try {
2419 await fetch('/app-screenshots/' + preset + '/' + currentPiece + '.png?force=true');
2420 } catch (err) {
2421 console.error('Failed:', preset, err);
2422 }
2423 }
2424
2425 showStatus('✅ All screenshots regenerated! Reloading...', 'success');
2426 setTimeout(() => window.location.reload(), 1000);
2427 }
2428
2429 function downloadZip() {
2430 showStatus('Preparing ZIP download...');
2431 window.location.href = '/app-screenshots/download/' + currentPiece;
2432 }
2433
2434 // WebSocket for real-time progress updates
2435 const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
2436 let ws = null;
2437 let reconnectAttempts = 0;
2438
2439 function connectWebSocket() {
2440 ws = new WebSocket(protocol + '//' + location.host + '/ws');
2441
2442 ws.onopen = () => {
2443 console.log('📡 WebSocket connected');
2444 reconnectAttempts = 0;
2445 };
2446
2447 ws.onclose = () => {
2448 console.log('📡 WebSocket disconnected, reconnecting...');
2449 const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000);
2450 reconnectAttempts++;
2451 setTimeout(connectWebSocket, delay);
2452 };
2453
2454 ws.onerror = () => ws.close();
2455
2456 ws.onmessage = (event) => {
2457 try {
2458 const data = JSON.parse(event.data);
2459
2460 // Check if there's active grab progress for our piece
2461 if (data.grabs && data.grabs.active) {
2462 const activeGrab = data.grabs.active.find(g =>
2463 g.piece === currentPiece || g.piece === '$' + currentPiece
2464 );
2465
2466 if (activeGrab) {
2467 // Find which preset this matches (by dimensions)
2468 for (const [preset, config] of Object.entries(${JSON.stringify(APP_SCREENSHOT_PRESETS)})) {
2469 if (activeGrab.dimensions &&
2470 activeGrab.dimensions.width === config.width &&
2471 activeGrab.dimensions.height === config.height) {
2472 updateProgressUI(preset, activeGrab.status, null);
2473 }
2474 }
2475 }
2476 }
2477 } catch (err) {
2478 console.error('WebSocket parse error:', err);
2479 }
2480 };
2481 }
2482
2483 // Poll for detailed progress since grabs report to /grab-status
2484 async function pollProgress() {
2485 try {
2486 const res = await fetch('/grab-status');
2487 const data = await res.json();
2488
2489 if (data.progress && data.progress.piece) {
2490 const piece = data.progress.piece;
2491 if (piece === currentPiece || piece === '$' + currentPiece) {
2492 // Check queue position for this piece
2493 let queuePosition = null;
2494 if (data.queue && data.queue.length > 0) {
2495 const queueItem = data.queue.find(q => q.piece === piece);
2496 if (queueItem) queuePosition = queueItem.position;
2497 }
2498
2499 // Find matching preset by checking dimensions in active grabs
2500 if (data.active && data.active.length > 0) {
2501 const activeGrab = data.active.find(g =>
2502 g.piece === currentPiece || g.piece === '$' + currentPiece
2503 );
2504 if (activeGrab && activeGrab.dimensions) {
2505 for (const [preset, config] of Object.entries(${JSON.stringify(APP_SCREENSHOT_PRESETS)})) {
2506 if (activeGrab.dimensions.width === config.width &&
2507 activeGrab.dimensions.height === config.height) {
2508 updateProgressUI(preset, data.progress.stage, data.progress.percent, data.progress.stageDetail, null, queuePosition);
2509 break;
2510 }
2511 }
2512 }
2513 }
2514
2515 // Fallback: update all visible loading indicators with generic progress
2516 document.querySelectorAll('.loading[data-loading]').forEach(el => {
2517 if (el.style.display !== 'none') {
2518 const progressText = el.querySelector('.progress-text');
2519 const progressBar = el.querySelector('.progress-bar-fill');
2520 const previewImg = el.querySelector('.preview-img');
2521
2522 if (progressText && data.progress.stageDetail) {
2523 progressText.textContent = data.progress.stageDetail;
2524 }
2525 if (progressBar && data.progress.percent) {
2526 progressBar.style.width = data.progress.percent + '%';
2527 }
2528 // Display streaming preview if available
2529 if (previewImg && data.progress.previewFrame) {
2530 previewImg.src = 'data:image/jpeg;base64,' + data.progress.previewFrame;
2531 previewImg.style.display = 'block';
2532 }
2533 }
2534 });
2535 }
2536 }
2537 } catch (err) {
2538 // Ignore polling errors
2539 }
2540 }
2541
2542 function updateProgressUI(preset, stage, percent, detail, previewFrame, queuePosition) {
2543 const loading = document.querySelector('[data-loading="' + preset + '"]');
2544 if (!loading || loading.style.display === 'none') return;
2545
2546 const progressText = loading.querySelector('.progress-text');
2547 const progressBar = loading.querySelector('.progress-bar-fill');
2548 const previewImg = loading.querySelector('.preview-img');
2549
2550 // Map stage to friendly text
2551 const stageText = {
2552 'loading': '🚀 Loading piece...',
2553 'waiting-content': '⏳ Waiting for render...',
2554 'settling': '⏸️ Settling...',
2555 'capturing': '📸 Capturing...',
2556 'encoding': '🔄 Processing...',
2557 'uploading': '☁️ Uploading...',
2558 'queued': queuePosition ? '⏳ In queue (#' + queuePosition + ')...' : '⏳ In queue...',
2559 };
2560
2561 if (progressText) {
2562 progressText.textContent = detail || stageText[stage] || stage || '';
2563 }
2564 if (progressBar && percent != null) {
2565 progressBar.style.width = percent + '%';
2566 }
2567 // Show streaming preview
2568 if (previewImg && previewFrame) {
2569 previewImg.src = 'data:image/jpeg;base64,' + previewFrame;
2570 previewImg.style.display = 'block';
2571 }
2572 }
2573
2574 // Start WebSocket and polling
2575 connectWebSocket();
2576 const pollInterval = setInterval(pollProgress, 150); // Poll fast for smooth previews
2577
2578 // Cleanup on page unload
2579 window.addEventListener('beforeunload', () => {
2580 clearInterval(pollInterval);
2581 if (ws) ws.close();
2582 });
2583 </script>
2584</body>
2585</html>`);
2586});
2587
2588// Individual app screenshot endpoint
2589app.get('/app-screenshots/:preset/:piece.png', async (req, res) => {
2590 const { preset, piece } = req.params;
2591 const force = req.query.force === 'true';
2592
2593 const presetConfig = APP_SCREENSHOT_PRESETS[preset];
2594 if (!presetConfig) {
2595 return res.status(400).json({
2596 error: 'Invalid preset',
2597 valid: Object.keys(APP_SCREENSHOT_PRESETS)
2598 });
2599 }
2600
2601 const { width, height } = presetConfig;
2602
2603 try {
2604 addServerLog('capture', '📱', `App screenshot: ${piece} (${preset} ${width}×${height}${force ? ' FORCE' : ''})`);
2605
2606 const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate(
2607 'app-screenshots',
2608 `${piece}-${preset}`,
2609 width,
2610 height,
2611 async () => {
2612 const result = await grabPiece(piece, {
2613 format: 'png',
2614 width,
2615 height,
2616 density: 4, // Pixel art - larger art pixels (4x)
2617 viewportScale: 1, // Capture at exact output size
2618 skipCache: force,
2619 });
2620
2621 if (!result.success) throw new Error(result.error);
2622
2623 // Handle cached result (cdnUrl but no buffer)
2624 if (result.cached && result.cdnUrl && !result.buffer) {
2625 const response = await fetch(result.cdnUrl);
2626 if (!response.ok) throw new Error(`Failed to fetch cached screenshot: ${response.status}`);
2627 return Buffer.from(await response.arrayBuffer());
2628 }
2629
2630 return result.buffer;
2631 },
2632 'png', // ext
2633 force // skipCache - pass force flag to skip CDN cache
2634 );
2635
2636 if (fromCache && cdnUrl && !force) {
2637 res.setHeader('X-Cache', 'HIT');
2638 res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
2639 return res.redirect(302, cdnUrl);
2640 }
2641
2642 res.setHeader('Content-Type', 'image/png');
2643 res.setHeader('Content-Length', buffer.length);
2644 // When force=true, prevent caching
2645 res.setHeader('Cache-Control', force ? 'no-store, no-cache, must-revalidate' : 'public, max-age=86400');
2646 res.setHeader('X-Cache', force ? 'REGENERATED' : 'MISS');
2647 res.setHeader('X-Screenshot-Preset', preset);
2648 res.setHeader('X-Screenshot-Dimensions', `${width}x${height}`);
2649 res.send(buffer);
2650
2651 } catch (error) {
2652 console.error('App screenshot error:', error);
2653 addServerLog('error', '❌', `App screenshot failed: ${piece} ${preset} - ${error.message}`);
2654 res.status(500).json({ error: error.message });
2655 }
2656});
2657
2658// ─── News screenshot endpoint ──────────────────────────────────────────────
2659// Captures a piece at 16:9 (1200×675) for embedding in news.aesthetic.computer
2660// posts. Returns JSON with the CDN URL so the CLI can emit markdown.
2661// Usage: GET /news-screenshot/notepat.png
2662// GET /news-screenshot/notepat.png?force=true
2663app.get('/news-screenshot/:piece.png', async (req, res) => {
2664 const { piece } = req.params;
2665 const force = req.query.force === 'true';
2666 const width = 1200, height = 675; // 16:9
2667
2668 try {
2669 addServerLog('capture', '📰', `News screenshot: ${piece} (${width}×${height}${force ? ' FORCE' : ''})`);
2670
2671 const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate(
2672 'news-screenshots',
2673 piece,
2674 width,
2675 height,
2676 async () => {
2677 const result = await grabPiece(piece, {
2678 format: 'png',
2679 width,
2680 height,
2681 density: 2,
2682 viewportScale: 1,
2683 skipCache: force,
2684 source: 'news',
2685 });
2686
2687 if (!result.success) throw new Error(result.error);
2688
2689 if (result.cached && result.cdnUrl && !result.buffer) {
2690 const response = await fetch(result.cdnUrl);
2691 if (!response.ok) throw new Error(`Failed to fetch cached screenshot: ${response.status}`);
2692 return Buffer.from(await response.arrayBuffer());
2693 }
2694
2695 return result.buffer;
2696 },
2697 'png',
2698 force,
2699 );
2700
2701 // Return JSON only when explicitly requested; default to serving the image.
2702 if (req.query.json === 'true') {
2703 return res.json({
2704 piece,
2705 url: cdnUrl || `https://oven.aesthetic.computer/news-screenshot/${piece}.png`,
2706 cached: fromCache,
2707 width,
2708 height,
2709 });
2710 }
2711
2712 if (fromCache && cdnUrl && !force) {
2713 res.setHeader('X-Cache', 'HIT');
2714 res.setHeader('Cache-Control', 'public, max-age=604800');
2715 return res.redirect(302, cdnUrl);
2716 }
2717
2718 res.setHeader('Content-Type', 'image/png');
2719 res.setHeader('Content-Length', buffer.length);
2720 res.setHeader('Cache-Control', force ? 'no-store' : 'public, max-age=86400');
2721 res.setHeader('X-Cache', force ? 'REGENERATED' : 'MISS');
2722 res.send(buffer);
2723
2724 } catch (error) {
2725 console.error('News screenshot error:', error);
2726 addServerLog('error', '❌', `News screenshot failed: ${piece} - ${error.message}`);
2727 res.status(500).json({ error: error.message });
2728 }
2729});
2730
2731// Bulk ZIP download endpoint
2732app.get('/app-screenshots/download/:piece', async (req, res) => {
2733 const { piece } = req.params;
2734 const presets = Object.entries(APP_SCREENSHOT_PRESETS);
2735
2736 addServerLog('info', '📦', `Generating ZIP for ${piece} (${presets.length} screenshots)`);
2737
2738 res.setHeader('Content-Type', 'application/zip');
2739 res.setHeader('Content-Disposition', `attachment; filename="${piece}-app-screenshots.zip"`);
2740
2741 const archive = archiver('zip', { zlib: { level: 9 } });
2742 archive.pipe(res);
2743
2744 for (const [presetKey, preset] of presets) {
2745 try {
2746 const { cdnUrl, buffer } = await getCachedOrGenerate(
2747 'app-screenshots',
2748 `${piece}-${presetKey}`,
2749 preset.width,
2750 preset.height,
2751 async () => {
2752 const result = await grabPiece(piece, {
2753 format: 'png',
2754 width: preset.width,
2755 height: preset.height,
2756 density: 4, // Pixel art - larger art pixels (4x)
2757 viewportScale: 1, // Capture at exact output size
2758 });
2759
2760 if (!result.success) throw new Error(result.error);
2761
2762 if (result.cached && result.cdnUrl && !result.buffer) {
2763 const response = await fetch(result.cdnUrl);
2764 if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`);
2765 return Buffer.from(await response.arrayBuffer());
2766 }
2767
2768 return result.buffer;
2769 }
2770 );
2771
2772 // Get buffer from CDN if we only have URL
2773 let imageBuffer = buffer;
2774 if (!imageBuffer && cdnUrl) {
2775 const response = await fetch(cdnUrl);
2776 if (response.ok) {
2777 imageBuffer = Buffer.from(await response.arrayBuffer());
2778 }
2779 }
2780
2781 if (imageBuffer) {
2782 const filename = `${preset.category}/${piece}-${presetKey}.png`;
2783 archive.append(imageBuffer, { name: filename });
2784 addServerLog('success', '✅', `Added to ZIP: ${filename}`);
2785 }
2786 } catch (err) {
2787 console.error(`Failed to add ${presetKey} to ZIP:`, err);
2788 addServerLog('error', '❌', `ZIP: Failed ${presetKey} - ${err.message}`);
2789 }
2790 }
2791
2792 archive.finalize();
2793});
2794
2795// JSON API for app screenshots status
2796app.get('/api/app-screenshots/:piece', async (req, res) => {
2797 const { piece } = req.params;
2798 const screenshots = {};
2799
2800 for (const [key, preset] of Object.entries(APP_SCREENSHOT_PRESETS)) {
2801 screenshots[key] = {
2802 ...preset,
2803 url: `/app-screenshots/${key}/${piece}.png`,
2804 downloadUrl: `/app-screenshots/${key}/${piece}.png?download=true`,
2805 };
2806 }
2807
2808 res.json({
2809 piece,
2810 presets: screenshots,
2811 zipUrl: `/app-screenshots/download/${piece}`,
2812 dashboardUrl: `/app-screenshots?piece=${piece}`,
2813 });
2814});
2815
2816// ─── Pack HTML endpoint (alias: /bundle-html) ──────────────────────
2817
2818app.get(['/pack-html', '/bundle-html'], async (req, res) => {
2819 const code = req.query.code;
2820 const piece = req.query.piece;
2821 const format = req.query.format || 'html';
2822 const nocache = req.query.nocache === '1' || req.query.nocache === 'true';
2823 const nocompress = req.query.nocompress === '1' || req.query.nocompress === 'true';
2824 const nominify = req.query.nominify === '1' || req.query.nominify === 'true';
2825 const brotli = req.query.brotli === '1' || req.query.brotli === 'true';
2826 const inline = req.query.inline === '1' || req.query.inline === 'true';
2827 const noboxart = req.query.noboxart === '1' || req.query.noboxart === 'true';
2828 const keeplabel = req.query.keeplabel === '1' || req.query.keeplabel === 'true';
2829 const density = parseInt(req.query.density) || null;
2830 const mode = req.query.mode;
2831
2832 // Device mode: simple iframe wrapper (fast path)
2833 if (mode === 'device') {
2834 const pieceCode = code || piece;
2835 if (!pieceCode) return res.status(400).send('Missing code or piece parameter');
2836 return res.set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=60' }).send(generateDeviceHTML(pieceCode, density));
2837 }
2838
2839 setSkipMinification(nominify);
2840
2841 const isJSPiece = !!piece;
2842 const bundleTarget = piece || code;
2843 if (!bundleTarget) {
2844 return res.status(400).json({ error: "Missing 'code' or 'piece' parameter.", usage: { kidlisp: "/pack-html?code=39j", javascript: "/pack-html?piece=notepat" } });
2845 }
2846
2847 // M4D mode: .amxd binary
2848 if (format === 'm4d') {
2849 try {
2850 const onProgress = (p) => console.log(`[bundler] m4d ${p.stage}: ${p.message}`);
2851 const { binary, filename } = await createM4DBundle(bundleTarget, isJSPiece, onProgress, density);
2852 res.set({ 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-cache' });
2853 return res.send(binary);
2854 } catch (error) {
2855 console.error('M4D bundle failed:', error);
2856 return res.status(500).json({ error: error.message });
2857 }
2858 }
2859
2860 // SSE streaming mode
2861 if (format === 'stream') {
2862 res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
2863 res.flushHeaders();
2864
2865 const sendEvent = (type, data) => {
2866 res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
2867 if (typeof res.flush === 'function') res.flush();
2868 };
2869
2870 try {
2871 const onProgress = (p) => sendEvent('progress', p);
2872 const { html, filename, sizeKB } = isJSPiece
2873 ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel)
2874 : await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel);
2875 sendEvent('complete', { filename, content: Buffer.from(html).toString('base64'), sizeKB });
2876 } catch (error) {
2877 console.error('Bundle failed:', error);
2878 sendEvent('error', { error: error.message });
2879 }
2880 return res.end();
2881 }
2882
2883 // Non-streaming modes (json, html download, inline)
2884 try {
2885 const progressLog = [];
2886 const onProgress = (p) => { progressLog.push(p.message); console.log(`[bundler] ${p.stage}: ${p.message}`); };
2887 const result = isJSPiece
2888 ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel)
2889 : await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel);
2890 const { html, filename, sizeKB, mainSource, authorHandle, userCode, packDate, depCount } = result;
2891
2892 if (format === 'json' || format === 'base64') {
2893 return res.json({ filename, content: Buffer.from(html).toString('base64'), sizeKB, progress: progressLog, sourceCode: mainSource, authorHandle, userCode, packDate, depCount });
2894 }
2895
2896 const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' };
2897 if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`;
2898 return res.set(headers).send(html);
2899 } catch (error) {
2900 console.error('Bundle failed:', error);
2901 return res.status(500).json({ error: error.message });
2902 }
2903});
2904
2905// Prewarm the core bundle cache (called by deploy.sh after restart)
2906app.post(['/pack-prewarm', '/bundle-prewarm'], async (req, res) => {
2907 try {
2908 addServerLog('info', '📦', 'Bundle prewarm started...');
2909 const result = await prewarmCache();
2910 addServerLog('success', '📦', `Bundle cache ready: ${result.fileCount} files in ${result.elapsed}ms (${result.commit})`);
2911 res.json(result);
2912 } catch (error) {
2913 addServerLog('error', '❌', `Bundle prewarm failed: ${error.message}`);
2914 res.status(500).json({ error: error.message });
2915 }
2916});
2917
2918// Cache status
2919app.get(['/pack-status', '/bundle-status'], (req, res) => {
2920 res.json(getCacheStatus());
2921});
2922
2923// ===== OS IMAGE BUILDER =====
2924// Assembles bootable FedAC OS artifacts with a piece injected into the FEDAC-PIECE partition.
2925// Requires: pre-baked base image on CDN + e2fsprogs (debugfs) on server.
2926
2927app.get('/os', async (req, res) => {
2928 const code = req.query.code;
2929 const piece = req.query.piece;
2930 const format = req.query.format || 'download';
2931 const density = parseInt(req.query.density) || 8;
2932 const flavor = (req.query.flavor || 'alpine').toLowerCase();
2933 const nocache = req.query.nocache === '1' || req.query.nocache === 'true';
2934
2935 if (!['alpine', 'fedora', 'native'].includes(flavor)) {
2936 return res.status(400).json({ error: "Invalid flavor. Use 'alpine', 'fedora', or 'native'." });
2937 }
2938
2939 // Native flavor: pre-built bare-metal kernel images on CDN (no dynamic assembly)
2940 if (flavor === 'native') {
2941 const nativePiece = piece || code || 'notepat';
2942 const cdnUrl = `https://releases.aesthetic.computer/os/native-${nativePiece}-latest.img.gz`;
2943 const filename = `${nativePiece}-native.img.gz`;
2944 addServerLog('info', '💿', `OS native redirect: ${nativePiece}`);
2945
2946 if (format === 'stream') {
2947 res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
2948 res.flushHeaders();
2949 const sendEvent = (type, data) => { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); if (typeof res.flush === 'function') res.flush(); };
2950 sendEvent('progress', { stage: 'native', message: 'Native image ready on CDN', percent: 100 });
2951 sendEvent('complete', { message: 'Native OS image ready', downloadUrl: cdnUrl, filename, cached: true, flavor: 'native', elapsed: 0 });
2952 return res.end();
2953 }
2954 return res.redirect(cdnUrl);
2955 }
2956
2957 const isJSPiece = !!piece;
2958 const target = piece || code;
2959 if (!target) {
2960 return res.status(400).json({
2961 error: "Missing 'code' or 'piece' parameter.",
2962 usage: { kidlisp: "/os?code=39j", javascript: "/os?piece=notepat" },
2963 });
2964 }
2965
2966 addServerLog('info', '💿', `OS ISO build started: ${target} (${flavor})${nocache ? ' [nocache]' : ''}`);
2967
2968 // SSE streaming progress mode (for UI)
2969 if (format === 'stream') {
2970 res.set({
2971 'Content-Type': 'text/event-stream',
2972 'Cache-Control': 'no-cache',
2973 'Connection': 'keep-alive',
2974 'X-Accel-Buffering': 'no',
2975 });
2976 res.flushHeaders();
2977
2978 const sendEvent = (type, data) => {
2979 res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
2980 if (typeof res.flush === 'function') res.flush();
2981 };
2982
2983 try {
2984 const result = await streamOSImage(null, target, isJSPiece, density, (p) => sendEvent('progress', p), flavor, { nocache });
2985 const downloadParam = isJSPiece ? `piece=${encodeURIComponent(target)}` : `code=${encodeURIComponent(target)}`;
2986 // Prefer CDN URL for fast download; fall back to oven direct.
2987 const downloadUrl = result.cdnUrl || `/os?${downloadParam}&density=${density}&flavor=${flavor}`;
2988 sendEvent('complete', {
2989 message: result.cached ? 'OS ISO ready (CDN cached)' : 'OS ISO ready',
2990 downloadUrl,
2991 elapsed: result.elapsed,
2992 filename: result.filename,
2993 timings: result.timings,
2994 cached: result.cached || false,
2995 flavor,
2996 });
2997 } catch (err) {
2998 console.error('[os] SSE build failed:', err);
2999 sendEvent('error', { error: err.message });
3000 }
3001 return res.end();
3002 }
3003
3004 // Direct download mode
3005 try {
3006 const result = await streamOSImage(res, target, isJSPiece, density, (p) => {
3007 console.log(`[os] ${p.stage}: ${p.message}`);
3008 }, flavor, { nocache });
3009 addServerLog('success', '💿', `OS ISO build complete: ${target}/${flavor} (${Math.round(result.elapsed / 1000)}s)`);
3010 } catch (err) {
3011 console.error('[os] Build failed:', err);
3012 addServerLog('error', '❌', `OS build failed: ${err.message}`);
3013 if (!res.headersSent) {
3014 res.status(500).json({ error: err.message });
3015 }
3016 }
3017});
3018
3019app.get('/os-status', (req, res) => {
3020 res.json(getOSBuildStatus());
3021});
3022
3023// Proxy releases.json with CORS for the web os.mjs piece.
3024app.get('/os-releases', async (req, res) => {
3025 try {
3026 const r = await fetch(`${RELEASES_BASE}/releases.json`);
3027 if (!r.ok) return res.status(r.status).json({ error: 'Failed to fetch releases' });
3028 const data = await r.json();
3029 const releases = Array.isArray(data?.releases) ? data.releases : [];
3030
3031 const { byName, failedAttempts } = await getNativeBuildStatusData();
3032 const releaseNames = new Set();
3033 for (const rel of releases) {
3034 const name = String(rel?.name || '').trim();
3035 if (!name) continue;
3036 releaseNames.add(name);
3037 const statusMeta = byName.get(name);
3038 if (statusMeta?.status) rel.status = statusMeta.status;
3039 else if (!rel.status) rel.status = 'success';
3040 if (statusMeta?.error && !rel.error) rel.error = statusMeta.error;
3041 if (statusMeta?.buildTs && !rel.last_attempt_ts) rel.last_attempt_ts = statusMeta.buildTs;
3042 }
3043
3044 const failedRows = failedAttempts
3045 .filter((it) => !releaseNames.has(it.name))
3046 .map((it) => ({
3047 name: it.name,
3048 git_hash: it.ref ? it.ref.slice(0, 40) : null,
3049 build_ts: it.buildTs || new Date().toISOString(),
3050 commit_msg: it.commitMsg || it.error || `build ${it.status}`,
3051 status: it.status,
3052 error: it.error || null,
3053 deprecated: true,
3054 attempt_only: true,
3055 size: 0,
3056 }));
3057
3058 data.releases = releases.concat(failedRows);
3059 res.json(data);
3060 } catch (err) {
3061 res.status(502).json({ error: err.message });
3062 }
3063});
3064
3065// Flush the cached OS template so the next download gets the fresh one.
3066app.post('/os-cache-flush', (req, res) => {
3067 templateCache = null;
3068 templateCacheTime = 0;
3069 console.log('[os-image] Template cache flushed');
3070 res.json({ flushed: true });
3071});
3072
3073// Personalized FedAC OS .img download for authenticated AC users.
3074// Downloads the template .img from DO Spaces, patches config.json in-place,
3075// and streams back. Compatible with Fedora Media Writer, Balena Etcher, dd.
3076const RELEASES_BASE = 'https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os';
3077const TEMPLATE_IMG_URL = `${RELEASES_BASE}/native-notepat-latest.img`;
3078const TEMPLATE_GZ_URL = `${RELEASES_BASE}/native-notepat-latest.img.gz`; // legacy fallback
3079const TEMPLATE_VMLINUZ_URL = `${RELEASES_BASE}/native-notepat-latest.vmlinuz`;
3080const TEMPLATE_CL_VMLINUZ_URL = `${RELEASES_BASE}/cl-native-notepat-latest.vmlinuz`;
3081const CONFIG_MARKER_LEGACY = '{"handle":"","piece":"notepat","sub":"","email":""}';
3082const CONFIG_PAD_SIZE_LEGACY = 4096;
3083const IDENTITY_MARKER = 'AC_IDENTITY_BLOCK_V1';
3084const IDENTITY_BLOCK_SIZE = 32768;
3085
3086// Cache the decompressed template in memory
3087let templateCache = null;
3088let templateCacheTime = 0;
3089const TEMPLATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
3090
3091async function getTemplate() {
3092 if (templateCache && Date.now() - templateCacheTime < TEMPLATE_CACHE_TTL) {
3093 return templateCache;
3094 }
3095 // Try the raw .img first, fall back to the older compressed image if needed.
3096 let raw;
3097 const imgRes = await fetch(TEMPLATE_IMG_URL);
3098 if (imgRes.ok) {
3099 console.log('[os-image] Downloading template .img...');
3100 raw = Buffer.from(await imgRes.arrayBuffer());
3101 } else {
3102 console.log('[os-image] No .img found, trying legacy .img.gz fallback...');
3103 const gzRes = await fetch(TEMPLATE_GZ_URL);
3104 if (gzRes.ok) {
3105 const compressed = Buffer.from(await gzRes.arrayBuffer());
3106 console.log(`[os-image] Decompressing ${(compressed.length / 1048576).toFixed(1)}MB...`);
3107 raw = gunzipSync(compressed);
3108 } else {
3109 throw new Error(`Template download failed (no .img or .img.gz available)`);
3110 }
3111 }
3112 templateCache = raw;
3113 templateCacheTime = Date.now();
3114 console.log(`[os-image] Template cached: ${(templateCache.length / 1048576).toFixed(1)}MB`);
3115 return templateCache;
3116}
3117
3118function kernelUrlForVariant(variant) {
3119 return variant === 'cl' ? TEMPLATE_CL_VMLINUZ_URL : TEMPLATE_VMLINUZ_URL;
3120}
3121
3122async function buildPersonalizedEfiImage({ kernelUrl, configJson }) {
3123 const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
3124 const tmpBase = `/tmp/os-image-${id}`;
3125 const kernelPath = `${tmpBase}-BOOTX64.EFI`;
3126 const configPath = `${tmpBase}-config.json`;
3127 const imagePath = `${tmpBase}.img`;
3128 const efiOffsetSectors = 2048;
3129 const efiOffsetBytes = efiOffsetSectors * 512;
3130 let kernelData = null;
3131
3132 try {
3133 const kRes = await fetch(kernelUrl);
3134 if (!kRes.ok) {
3135 throw new Error(`Kernel download failed (${kRes.status})`);
3136 }
3137 kernelData = Buffer.from(await kRes.arrayBuffer());
3138 await fs.promises.writeFile(kernelPath, kernelData);
3139 await fs.promises.writeFile(configPath, configJson);
3140
3141 // Keep headroom for FAT metadata and future kernel size growth.
3142 const minBytes = kernelData.length + Buffer.byteLength(configJson) + (32 * 1024 * 1024);
3143 const imageSizeMiB = Math.max(384, Math.ceil(minBytes / 1048576) + 32);
3144
3145 execSync(`dd if=/dev/zero of="${imagePath}" bs=1M count=${imageSizeMiB} status=none`);
3146 execSync(
3147 `printf 'label: gpt\nstart=${efiOffsetSectors}, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B\n' | ` +
3148 `sfdisk --force --no-reread "${imagePath}" >/dev/null`,
3149 );
3150 execSync(`mkfs.vfat -F 32 --offset=${efiOffsetSectors} "${imagePath}" >/dev/null`);
3151 execSync(`mmd -i "${imagePath}@@${efiOffsetBytes}" ::EFI ::EFI/BOOT`);
3152 execSync(`mcopy -o -i "${imagePath}@@${efiOffsetBytes}" "${kernelPath}" ::EFI/BOOT/BOOTX64.EFI`);
3153 execSync(`mcopy -o -i "${imagePath}@@${efiOffsetBytes}" "${configPath}" ::config.json`);
3154
3155 return await fs.promises.readFile(imagePath);
3156 } finally {
3157 await Promise.allSettled([
3158 fs.promises.unlink(kernelPath),
3159 fs.promises.unlink(configPath),
3160 fs.promises.unlink(imagePath),
3161 ]);
3162 }
3163}
3164
3165// User config endpoint for edge worker image patching
3166app.get('/api/user-config', async (req, res) => {
3167 const authHeader = req.headers.authorization || '';
3168 const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
3169 if (!token) return res.status(401).json({ error: 'Authorization required' });
3170
3171 let userInfo;
3172 try {
3173 const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', {
3174 headers: { Authorization: `Bearer ${token}` },
3175 });
3176 if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`);
3177 userInfo = await uiRes.json();
3178 } catch (err) {
3179 return res.status(401).json({ error: `Authentication failed: ${err.message}` });
3180 }
3181
3182 const sub = userInfo.sub || '';
3183 let handle = '';
3184 try {
3185 const handleRes = await fetch(`https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}`);
3186 if (handleRes.ok) {
3187 const data = await handleRes.json();
3188 handle = data.handle || '';
3189 }
3190 } catch (_) {}
3191
3192 if (!handle) return res.status(403).json({ error: 'No handle found' });
3193
3194 let claudeToken = '', githubPat = '';
3195 try {
3196 const mongoUri = process.env.MONGODB_CONNECTION_STRING;
3197 const dbName = process.env.MONGODB_NAME;
3198 if (mongoUri) {
3199 const { MongoClient } = await import('mongodb');
3200 const client = new MongoClient(mongoUri);
3201 await client.connect();
3202 const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub });
3203 if (doc) {
3204 claudeToken = doc.claudeCodeToken || '';
3205 githubPat = doc.githubPat || '';
3206 }
3207 await client.close();
3208 }
3209 } catch (err) {
3210 console.warn(`[user-config] Token lookup failed: ${err.message}`);
3211 }
3212
3213 const reqPiece = req.query.piece || 'notepat';
3214 const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken'];
3215 const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat';
3216 const wifiParam = req.query.wifi;
3217 const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false';
3218
3219 const config = { handle, piece: bootPiece, sub, email: userInfo.email || '', token };
3220 if (claudeToken) config.claudeToken = claudeToken;
3221 if (githubPat) config.githubPat = githubPat;
3222 if (!wifiEnabled) config.wifi = false;
3223
3224 res.json(config);
3225});
3226
3227app.get('/os-image', async (req, res) => {
3228 const authHeader = req.headers.authorization || '';
3229 const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
3230 if (!token) {
3231 return res.status(401).json({ error: 'Authorization required. Log in at aesthetic.computer first.' });
3232 }
3233
3234 let userInfo;
3235 try {
3236 const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', {
3237 headers: { Authorization: `Bearer ${token}` },
3238 });
3239 if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`);
3240 userInfo = await uiRes.json();
3241 } catch (err) {
3242 return res.status(401).json({ error: `Authentication failed: ${err.message}` });
3243 }
3244
3245 let handle = '';
3246 const sub = userInfo.sub || '';
3247 try {
3248 const handleRes = await fetch(
3249 `https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}`
3250 );
3251 if (handleRes.ok) {
3252 const data = await handleRes.json();
3253 handle = data.handle || '';
3254 }
3255 } catch (_) {}
3256
3257 if (!handle) {
3258 return res.status(403).json({ error: 'You need a handle first. Visit aesthetic.computer/handle to claim one.' });
3259 }
3260
3261 const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken'];
3262 const reqPiece = req.query.piece || 'notepat';
3263 const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat';
3264 const wifiParam = req.query.wifi;
3265 const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false';
3266 const requestedLayout = String(req.query.layout || 'img').toLowerCase();
3267 const variant = String(req.query.variant || '').toLowerCase() === 'cl' ? 'cl' : 'c';
3268
3269 let claudeToken = '', githubPat = '';
3270 try {
3271 const mongoUri = process.env.MONGODB_CONNECTION_STRING;
3272 const dbName = process.env.MONGODB_NAME;
3273 if (mongoUri) {
3274 const { MongoClient } = await import('mongodb');
3275 const client = new MongoClient(mongoUri);
3276 await client.connect();
3277 const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub });
3278 if (doc) {
3279 claudeToken = doc.claudeCodeToken || '';
3280 githubPat = doc.githubPat || '';
3281 }
3282 await client.close();
3283 }
3284 } catch (err) {
3285 console.warn(`[os-image] Token lookup failed: ${err.message}`);
3286 }
3287
3288 console.log(`[os-image] Building personalized image for @${handle} (boot: ${bootPiece}, wifi: ${wifiEnabled}, variant: ${variant}, claude: ${!!claudeToken}, git: ${!!githubPat})`);
3289
3290 const configObj = {
3291 handle,
3292 piece: bootPiece,
3293 sub: userInfo.sub || '',
3294 email: userInfo.email || '',
3295 token: token,
3296 };
3297 if (claudeToken) configObj.claudeToken = claudeToken;
3298 if (githubPat) configObj.githubPat = githubPat;
3299 if (!wifiEnabled) configObj.wifi = false;
3300 const configJson = JSON.stringify(configObj);
3301
3302 let imgData;
3303 let fallbackImage = false;
3304 let fallbackReason = '';
3305 const buildKernelFallback = async (reason) => {
3306 fallbackReason = reason;
3307 console.warn(
3308 `[os-image] ${reason}; generating ${variant} EFI image for @${handle}`,
3309 );
3310 imgData = await buildPersonalizedEfiImage({
3311 kernelUrl: kernelUrlForVariant(variant),
3312 configJson,
3313 });
3314 fallbackImage = true;
3315 };
3316 try {
3317 const template = await getTemplate();
3318 imgData = Buffer.from(template);
3319 } catch (err) {
3320 try {
3321 await buildKernelFallback('template-unavailable');
3322 } catch (fallbackErr) {
3323 return res.status(503).json({
3324 error: `Template not available (${err.message}) and fallback image build failed: ${fallbackErr.message}`,
3325 });
3326 }
3327 }
3328
3329 let identityPatchCount = 0;
3330 let configPatchCount = 0;
3331
3332 if (!fallbackImage) {
3333 const identityMarkerBuf = Buffer.from(IDENTITY_MARKER + '\n');
3334 let idx = imgData.indexOf(identityMarkerBuf);
3335 while (idx !== -1) {
3336 const block = Buffer.alloc(IDENTITY_BLOCK_SIZE, 0);
3337 const header = Buffer.from(IDENTITY_MARKER + '\n' + configJson);
3338 header.copy(block);
3339 block.copy(imgData, idx);
3340 identityPatchCount++;
3341 idx = imgData.indexOf(identityMarkerBuf, idx + IDENTITY_BLOCK_SIZE);
3342 }
3343
3344 const padded = configJson.length >= CONFIG_PAD_SIZE_LEGACY
3345 ? configJson.slice(0, CONFIG_PAD_SIZE_LEGACY)
3346 : configJson + ' '.repeat(CONFIG_PAD_SIZE_LEGACY - configJson.length);
3347 const configBytes = Buffer.from(padded);
3348 const legacyMarkerBuf = Buffer.from(CONFIG_MARKER_LEGACY);
3349 idx = imgData.indexOf(legacyMarkerBuf);
3350 while (idx !== -1) {
3351 configBytes.copy(imgData, idx);
3352 configPatchCount++;
3353 idx = imgData.indexOf(legacyMarkerBuf, idx + CONFIG_PAD_SIZE_LEGACY);
3354 }
3355
3356 if (identityPatchCount === 0 && configPatchCount === 0) {
3357 try {
3358 await buildKernelFallback('template-missing-config-placeholder');
3359 } catch (err) {
3360 return res.status(500).json({
3361 error: `Template image missing config placeholder and fallback image build failed: ${err.message}`,
3362 });
3363 }
3364 }
3365 }
3366
3367 if (!fallbackImage) {
3368 console.log(
3369 `[os-image] Patched ${identityPatchCount} identity block(s) and ${configPatchCount} config location(s) for @${handle}`,
3370 );
3371 }
3372
3373 addServerLog('success', '💿', `OS image for @${handle} (${(imgData.length / 1048576).toFixed(1)}MB)`);
3374 res.setHeader('Content-Type', 'application/octet-stream');
3375 res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
3376 res.setHeader('Pragma', 'no-cache');
3377 res.setHeader('Expires', '0');
3378 res.setHeader('X-AC-OS-Requested-Layout', requestedLayout || 'img');
3379 res.setHeader('X-AC-OS-Layout', 'img');
3380 if (fallbackImage) {
3381 res.setHeader('X-AC-OS-Fallback', 'kernel-efi-image');
3382 res.setHeader('X-AC-OS-Fallback-Reason', fallbackReason);
3383 }
3384
3385 let releaseName = 'native';
3386 try {
3387 const relRes = await fetch(`${RELEASES_BASE}/releases.json`);
3388 if (relRes.ok) {
3389 const relData = await relRes.json();
3390 releaseName = relData?.releases?.[0]?.name || releaseName;
3391 }
3392 } catch (_) {}
3393 const coreName = 'AC-' + releaseName;
3394 const d = new Date();
3395 const p = (n) => String(n).padStart(2, '0');
3396 const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`;
3397 res.setHeader('Content-Disposition', `attachment; filename="@${handle}-os-${bootPiece}-${coreName}-${ts}.img"`);
3398 res.setHeader('Content-Length', imgData.length);
3399 res.end(imgData);
3400});
3401
3402// Background base image jobs (build + upload) for FedOS pipeline.
3403app.get('/os-base-build', (req, res) => {
3404 res.json(getOSBaseBuildsSummary());
3405});
3406
3407app.get('/os-base-build/:jobId', (req, res) => {
3408 const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200));
3409 const includeLogs = req.query.logs === '1' || req.query.logs === 'true';
3410 const job = getOSBaseBuild(req.params.jobId, { includeLogs, tail });
3411 if (!job) return res.status(404).json({ error: 'Job not found' });
3412 return res.json(job);
3413});
3414
3415app.get('/os-base-build/:jobId/stream', (req, res) => {
3416 const jobId = req.params.jobId;
3417 const initial = getOSBaseBuild(jobId, { includeLogs: true, tail: 500 });
3418 if (!initial) return res.status(404).json({ error: 'Job not found' });
3419
3420 res.set({
3421 'Content-Type': 'text/event-stream',
3422 'Cache-Control': 'no-cache',
3423 'Connection': 'keep-alive',
3424 'X-Accel-Buffering': 'no',
3425 });
3426 res.flushHeaders();
3427
3428 let sentLogs = 0;
3429 const sendEvent = (type, data) => {
3430 res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
3431 if (typeof res.flush === 'function') res.flush();
3432 };
3433
3434 const sendSnapshot = () => {
3435 const job = getOSBaseBuild(jobId, { includeLogs: true, tail: 2000 });
3436 if (!job) {
3437 sendEvent('error', { error: 'Job not found' });
3438 return false;
3439 }
3440
3441 const logs = Array.isArray(job.logs) ? job.logs : [];
3442 if (logs.length > sentLogs) {
3443 sendEvent('logs', { logs: logs.slice(sentLogs) });
3444 sentLogs = logs.length;
3445 }
3446 sendEvent('status', {
3447 id: job.id,
3448 status: job.status,
3449 stage: job.stage,
3450 message: job.message,
3451 percent: job.percent,
3452 updatedAt: job.updatedAt,
3453 finishedAt: job.finishedAt,
3454 error: job.error,
3455 upload: job.upload,
3456 });
3457 if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') {
3458 sendEvent('complete', {
3459 status: job.status,
3460 error: job.error,
3461 upload: job.upload,
3462 });
3463 return false;
3464 }
3465 return true;
3466 };
3467
3468 sendEvent('status', {
3469 id: initial.id,
3470 status: initial.status,
3471 stage: initial.stage,
3472 message: initial.message,
3473 percent: initial.percent,
3474 updatedAt: initial.updatedAt,
3475 });
3476 if (Array.isArray(initial.logs) && initial.logs.length > 0) {
3477 sendEvent('logs', { logs: initial.logs });
3478 sentLogs = initial.logs.length;
3479 }
3480
3481 const timer = setInterval(() => {
3482 if (!sendSnapshot()) {
3483 clearInterval(timer);
3484 res.end();
3485 }
3486 }, 1000);
3487
3488 req.on('close', () => {
3489 clearInterval(timer);
3490 });
3491});
3492
3493app.post('/os-base-build', requireOSBuildAdmin, async (req, res) => {
3494 const flavor = (req.body?.flavor || 'alpine').toLowerCase();
3495 if (!['alpine', 'fedora'].includes(flavor)) {
3496 return res.status(400).json({ error: "Invalid flavor. Use 'alpine' or 'fedora'." });
3497 }
3498 const defaultSize = flavor === 'alpine' ? 1 : 4;
3499 const imageSizeGB = Math.max(1, Math.min(32, parseInt(req.body?.imageSizeGB, 10) || defaultSize));
3500 const publish = req.body?.publish !== false;
3501 const requestedWorkBase = typeof req.body?.workBase === 'string' ? req.body.workBase.trim() : '';
3502 const workBase = requestedWorkBase || undefined;
3503
3504 try {
3505 const job = await startOSBaseBuild(
3506 { imageSizeGB, publish, flavor, workBase },
3507 {
3508 onStart: (j) => addServerLog('info', '💿', `OS base build started: ${j.id} (${flavor}, ${imageSizeGB}GiB${workBase ? `, workBase=${workBase}` : ''})`),
3509 onUploadComplete: async (j) => {
3510 addServerLog('success', '☁️', `OS base upload complete: ${j.upload.imageKey}`);
3511 invalidateManifest(flavor);
3512 addServerLog('info', '💿', `OS manifest cache invalidated (${flavor}) after base upload`);
3513 // Purge all cached per-piece builds for this flavor — the new base image
3514 // changes the image layout, so old cached builds are stale.
3515 const purgeResult = await purgeOSBuildCache(flavor);
3516 addServerLog('info', '🗑️', `Purged ${purgeResult.deleted} cached ${flavor} build(s) from CDN`);
3517 },
3518 onSuccess: (j) => addServerLog('success', '💿', `OS base build complete: ${j.id} (${flavor})`),
3519 onError: (j) => addServerLog('error', '❌', `OS base build failed: ${j.id} (${j.error})`),
3520 },
3521 );
3522 return res.status(202).json(job);
3523 } catch (error) {
3524 if (error.code === 'OS_BASE_BUSY') {
3525 return res.status(409).json({ error: error.message, activeJobId: error.activeJobId });
3526 }
3527 return res.status(500).json({ error: error.message });
3528 }
3529});
3530
3531app.post('/os-base-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => {
3532 const result = cancelOSBaseBuild(req.params.jobId);
3533 if (!result.ok) {
3534 return res.status(400).json(result);
3535 }
3536 addServerLog('info', '🛑', `OS base build cancel requested: ${req.params.jobId}`);
3537 return res.json(result);
3538});
3539
3540// ── Native OTA Build ──────────────────────────────────────────────────────
3541// Builds fedac/native kernel + uploads vmlinuz to DO Spaces CDN.
3542// Auth: same OS_BUILD_ADMIN_KEY used for /os-base-build.
3543// Auto-triggered by native-git-poller.mjs (polls origin/main every 60s).
3544// Can also be triggered manually via POST with admin key.
3545
3546app.get('/native-build', (req, res) => {
3547 res.json({ ...getNativeBuildsSummary(), poller: getNativePollerStatus() });
3548});
3549
3550app.get('/native-build/:jobId', (req, res) => {
3551 const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200));
3552 const includeLogs = req.query.logs === '1' || req.query.logs === 'true';
3553 const job = getNativeBuild(req.params.jobId, { includeLogs, tail });
3554 if (!job) return res.status(404).json({ error: 'Job not found' });
3555 return res.json(job);
3556});
3557
3558app.get('/native-build/:jobId/stream', (req, res) => {
3559 const jobId = req.params.jobId;
3560 const initial = getNativeBuild(jobId, { includeLogs: true, tail: 500 });
3561 if (!initial) return res.status(404).json({ error: 'Job not found' });
3562
3563 res.set({
3564 'Content-Type': 'text/event-stream',
3565 'Cache-Control': 'no-cache',
3566 'Connection': 'keep-alive',
3567 'X-Accel-Buffering': 'no',
3568 });
3569 res.flushHeaders();
3570
3571 let sentLogs = 0;
3572 const sendEvent = (type, data) => {
3573 res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
3574 if (typeof res.flush === 'function') res.flush();
3575 };
3576
3577 if (Array.isArray(initial.logs) && initial.logs.length > 0) {
3578 sendEvent('logs', { logs: initial.logs });
3579 sentLogs = initial.logs.length;
3580 }
3581 sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent });
3582
3583 const timer = setInterval(() => {
3584 const job = getNativeBuild(jobId, { includeLogs: true, tail: 2000 });
3585 if (!job) { clearInterval(timer); res.end(); return; }
3586 const logs = Array.isArray(job.logs) ? job.logs : [];
3587 if (logs.length > sentLogs) {
3588 sendEvent('logs', { logs: logs.slice(sentLogs) });
3589 sentLogs = logs.length;
3590 }
3591 sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error });
3592 if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') {
3593 sendEvent('complete', { status: job.status, error: job.error });
3594 clearInterval(timer);
3595 res.end();
3596 }
3597 }, 1000);
3598
3599 req.on('close', () => clearInterval(timer));
3600});
3601
3602app.post('/native-build', requireOSBuildAdmin, async (req, res) => {
3603 try {
3604 const job = await startNativeBuild({
3605 ref: req.body?.ref || 'unknown',
3606 changed_paths: req.body?.changed_paths || '',
3607 variant: req.body?.variant || 'c', // "c", "cl", "nix", "both", or "all"
3608 });
3609 addServerLog('info', '🔨', `Native OTA build started: ${job.id} (ref=${job.ref}, flags=${job.flags.join(' ') || 'none'})`);
3610 return res.status(202).json(job);
3611 } catch (err) {
3612 if (err.code === 'NATIVE_BUILD_BUSY') {
3613 return res.status(409).json({ error: err.message, activeJobId: err.activeJobId });
3614 }
3615 return res.status(500).json({ error: err.message });
3616 }
3617});
3618
3619app.post('/native-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => {
3620 const result = cancelNativeBuild(req.params.jobId);
3621 if (!result.ok) return res.status(400).json(result);
3622 addServerLog('info', '🛑', `Native build cancel requested: ${req.params.jobId}`);
3623 return res.json(result);
3624});
3625
3626// ── Papers PDF Build ──────────────────────────────────────────────────────
3627// Builds all AC paper PDFs from LaTeX sources using xelatex.
3628// Auth: same OS_BUILD_ADMIN_KEY used for /native-build.
3629// Auto-triggered by papers-git-poller.mjs (polls origin/main every 60s).
3630// Can also be triggered manually via POST with admin key.
3631
3632app.get('/papers-build', (req, res) => {
3633 res.json({ ...getPapersBuildsSummary(), poller: getPapersPollerStatus() });
3634});
3635
3636app.get('/papers-build/:jobId', (req, res) => {
3637 const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200));
3638 const includeLogs = req.query.logs === '1' || req.query.logs === 'true';
3639 const job = getPapersBuild(req.params.jobId, { includeLogs, tail });
3640 if (!job) return res.status(404).json({ error: 'Job not found' });
3641 return res.json(job);
3642});
3643
3644app.get('/papers-build/:jobId/stream', (req, res) => {
3645 const jobId = req.params.jobId;
3646 const initial = getPapersBuild(jobId, { includeLogs: true, tail: 500 });
3647 if (!initial) return res.status(404).json({ error: 'Job not found' });
3648
3649 res.set({
3650 'Content-Type': 'text/event-stream',
3651 'Cache-Control': 'no-cache',
3652 'Connection': 'keep-alive',
3653 'X-Accel-Buffering': 'no',
3654 });
3655 res.flushHeaders();
3656
3657 let sentLogs = 0;
3658 const sendEvent = (type, data) => {
3659 res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
3660 if (typeof res.flush === 'function') res.flush();
3661 };
3662
3663 if (Array.isArray(initial.logs) && initial.logs.length > 0) {
3664 sendEvent('logs', { logs: initial.logs });
3665 sentLogs = initial.logs.length;
3666 }
3667 sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent });
3668
3669 const timer = setInterval(() => {
3670 const job = getPapersBuild(jobId, { includeLogs: true, tail: 2000 });
3671 if (!job) { clearInterval(timer); res.end(); return; }
3672 const logs = Array.isArray(job.logs) ? job.logs : [];
3673 if (logs.length > sentLogs) {
3674 sendEvent('logs', { logs: logs.slice(sentLogs) });
3675 sentLogs = logs.length;
3676 }
3677 sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error });
3678 if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') {
3679 sendEvent('complete', { status: job.status, error: job.error });
3680 clearInterval(timer);
3681 res.end();
3682 }
3683 }, 1000);
3684
3685 req.on('close', () => clearInterval(timer));
3686});
3687
3688app.post('/papers-build', requireOSBuildAdmin, async (req, res) => {
3689 try {
3690 const job = await startPapersBuild({
3691 ref: req.body?.ref || 'unknown',
3692 changed_paths: req.body?.changed_paths || '',
3693 });
3694 addServerLog('info', '📄', `Papers PDF build started: ${job.id} (ref=${job.ref})`);
3695 return res.status(202).json(job);
3696 } catch (err) {
3697 if (err.code === 'PAPERS_BUILD_BUSY') {
3698 return res.status(409).json({ error: err.message, activeJobId: err.activeJobId });
3699 }
3700 return res.status(500).json({ error: err.message });
3701 }
3702});
3703
3704app.post('/papers-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => {
3705 const result = cancelPapersBuild(req.params.jobId);
3706 if (!result.ok) return res.status(400).json(result);
3707 addServerLog('info', '🛑', `Papers build cancel requested: ${req.params.jobId}`);
3708 return res.json(result);
3709});
3710
3711// ── OS Release Upload ──────────────────────────────────────────────────────
3712// Accepts a vmlinuz binary + metadata, uploads to DO Spaces as OTA release.
3713// Auth: AC token (Bearer) verified against Auth0 userinfo.
3714// Usage: curl -X POST /os-release-upload \
3715// -H "Authorization: Bearer <ac_token>" \
3716// -H "X-Build-Name: swift-egret" \
3717// -H "X-Git-Hash: abc1234" \
3718// -H "X-Build-Ts: 2026-03-11T12:00" \
3719// --data-binary @build/vmlinuz
3720app.post('/os-release-upload', async (req, res) => {
3721 // Auth: verify AC token
3722 const authHeader = req.headers.authorization || '';
3723 const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
3724 if (!token) {
3725 return res.status(401).json({ error: 'Missing Authorization: Bearer <ac_token>' });
3726 }
3727
3728 // Verify token against Auth0
3729 const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'hi.aesthetic.computer';
3730 let user;
3731 try {
3732 const uiRes = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
3733 headers: { Authorization: `Bearer ${token}` },
3734 });
3735 if (!uiRes.ok) throw new Error(`Auth0 returned ${uiRes.status}`);
3736 user = await uiRes.json();
3737 } catch (err) {
3738 addServerLog('error', '🔒', `OS release upload auth failed: ${err.message}`);
3739 return res.status(401).json({ error: 'Invalid or expired token. Run: ac-login' });
3740 }
3741
3742 const userSub = user.sub || 'unknown';
3743 const userName = user.name || user.nickname || user.email || userSub;
3744 addServerLog('info', '📦', `OS release upload from ${userName} (${userSub})`);
3745
3746 // Collect binary body
3747 const chunks = [];
3748 for await (const chunk of req) chunks.push(chunk);
3749 const vmlinuz = Buffer.concat(chunks);
3750
3751 if (vmlinuz.length < 1_000_000) {
3752 return res.status(400).json({ error: `File too small (${vmlinuz.length} bytes). Expected vmlinuz ~35-45MB.` });
3753 }
3754
3755 // Metadata from headers
3756 const buildName = req.headers['x-build-name'] || `upload-${Date.now()}`;
3757 const gitHash = req.headers['x-git-hash'] || 'unknown';
3758 const buildTs = req.headers['x-build-ts'] || new Date().toISOString().slice(0, 16);
3759 const commitMsg = req.headers['x-commit-msg'] || '';
3760 const version = `${buildName} ${gitHash}-${buildTs}`;
3761
3762 // SHA256
3763 const crypto = await import('crypto');
3764 const sha256 = crypto.createHash('sha256').update(vmlinuz).digest('hex');
3765
3766 // Upload to DO Spaces
3767 const { S3Client, PutObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3');
3768 const accessKeyId = process.env.OS_SPACES_KEY || process.env.ART_SPACES_KEY;
3769 const secretAccessKey = process.env.OS_SPACES_SECRET || process.env.ART_SPACES_SECRET;
3770 if (!accessKeyId || !secretAccessKey) {
3771 return res.status(503).json({ error: 'OS Spaces credentials not configured on server.' });
3772 }
3773
3774 const spacesEndpoint = process.env.OS_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com';
3775 const spacesBucket = process.env.OS_SPACES_BUCKET || 'releases-aesthetic-computer';
3776 const cdnBase = process.env.OS_SPACES_CDN_BASE || `https://${spacesBucket}.sfo3.digitaloceanspaces.com`;
3777
3778 const s3 = new S3Client({
3779 region: process.env.OS_SPACES_REGION || 'us-east-1',
3780 endpoint: spacesEndpoint,
3781 credentials: { accessKeyId, secretAccessKey },
3782 });
3783
3784 const upload = async (key, body, contentType) => {
3785 await s3.send(new PutObjectCommand({
3786 Bucket: spacesBucket,
3787 Key: key,
3788 Body: body,
3789 ContentType: contentType,
3790 ACL: 'public-read',
3791 }));
3792 };
3793
3794 try {
3795 addServerLog('info', '☁️', `Uploading ${buildName}: ${(vmlinuz.length / 1048576).toFixed(1)}MB, sha=${sha256.slice(0, 12)}...`);
3796
3797 // Upload version + sha256 first (canary), then vmlinuz
3798 // Version file: line 1 = version string, line 2 = kernel size in bytes
3799 const versionWithSize = `${version}\n${vmlinuz.length}`;
3800 await upload('os/native-notepat-latest.version', versionWithSize, 'text/plain');
3801 await upload('os/native-notepat-latest.sha256', sha256, 'text/plain');
3802 await upload('os/native-notepat-latest.vmlinuz', vmlinuz, 'application/octet-stream');
3803
3804 // Update releases.json
3805 let releases = { releases: [] };
3806 try {
3807 const existing = await s3.send(new GetObjectCommand({
3808 Bucket: spacesBucket, Key: 'os/releases.json',
3809 }));
3810 const body = await existing.Body.transformToString();
3811 releases = JSON.parse(body);
3812 } catch { /* first release or missing */ }
3813
3814 releases.releases = releases.releases || [];
3815 const userHandle = req.headers['x-handle'] || user.nickname || user.name || userName;
3816 // Mark all existing builds as deprecated
3817 for (const r of releases.releases) r.deprecated = true;
3818
3819 releases.releases.unshift({
3820 version, name: buildName, sha256, size: vmlinuz.length,
3821 git_hash: gitHash, build_ts: buildTs, commit_msg: commitMsg,
3822 user: userSub, handle: userHandle,
3823 url: `${cdnBase}/os/native-notepat-latest.vmlinuz`,
3824 });
3825 releases.releases = releases.releases.slice(0, 50);
3826 releases.latest = version;
3827 releases.latest_name = buildName;
3828
3829 await upload('os/releases.json', JSON.stringify(releases, null, 2), 'application/json');
3830
3831 // Broadcast new build to all connected WebSocket clients (os.mjs pieces)
3832 if (wss && wss.clients) {
3833 const buildMsg = JSON.stringify({ type: 'os:new-build', releases });
3834 wss.clients.forEach(client => {
3835 if (client.readyState === 1) client.send(buildMsg);
3836 });
3837 }
3838
3839 addServerLog('success', '🚀', `OS release published: ${buildName} (${gitHash}) by ${userName}`);
3840 return res.json({
3841 ok: true,
3842 name: buildName,
3843 version,
3844 sha256,
3845 size: vmlinuz.length,
3846 url: `${cdnBase}/os/native-notepat-latest.vmlinuz`,
3847 user: userSub,
3848 });
3849 } catch (err) {
3850 addServerLog('error', '❌', `OS release upload failed: ${err.message}`);
3851 return res.status(500).json({ error: `Upload failed: ${err.message}` });
3852 }
3853});
3854
3855app.post('/os-invalidate', async (req, res) => {
3856 const purge = req.body?.purge === true;
3857 const clearLocal = req.body?.local === true || req.body?.clearLocal === true;
3858 const flavor = req.body?.flavor;
3859 invalidateManifest(flavor);
3860 addServerLog('info', '💿', `OS base image manifest cache invalidated${flavor ? ` (${flavor})` : ''}`);
3861
3862 let localResult = null;
3863 if (clearLocal) {
3864 localResult = await clearOSBuildLocalCache(flavor);
3865 addServerLog('info', '🧹', `Cleared ${localResult.deleted} local base-image cache file(s)${flavor ? ` (${flavor})` : ''}`);
3866 }
3867
3868 if (purge) {
3869 const purgeResult = await purgeOSBuildCache(flavor);
3870 addServerLog('info', '🗑️', `Purged ${purgeResult.deleted} cached build(s) from CDN${flavor ? ` (${flavor})` : ''}`);
3871 return res.json({
3872 ok: true,
3873 message: clearLocal
3874 ? 'Manifest invalidated, local base cache cleared, and CDN build cache purged.'
3875 : 'Manifest + CDN build cache purged.',
3876 purged: purgeResult.deleted,
3877 localCleared: localResult?.deleted || 0,
3878 localDirs: localResult?.dirs || [],
3879 });
3880 }
3881
3882 if (clearLocal) {
3883 return res.json({
3884 ok: true,
3885 message: 'Manifest invalidated and local base-image cache cleared.',
3886 localCleared: localResult.deleted,
3887 localDirs: localResult.dirs,
3888 });
3889 }
3890
3891 res.json({ ok: true, message: 'Manifest cache invalidated — next build will re-fetch.' });
3892});
3893
3894// 404 handler
3895app.use((req, res) => {
3896 res.status(404).json({ error: 'Not found' });
3897});
3898
3899// Error handler
3900app.use((err, req, res, next) => {
3901 console.error('❌ Server error:', err);
3902 res.status(500).json({ error: 'Internal server error', message: err.message });
3903});
3904
3905// Create server and WebSocket
3906let server;
3907if (dev) {
3908 // Load local SSL certs in development mode
3909 const httpsOptions = {
3910 key: fs.readFileSync('../ssl-dev/localhost-key.pem'),
3911 cert: fs.readFileSync('../ssl-dev/localhost.pem'),
3912 };
3913
3914 server = https.createServer(httpsOptions, app);
3915 server.listen(PORT, () => {
3916 console.log(`🔥 Oven server running on https://localhost:${PORT} (dev mode)`);
3917 });
3918} else {
3919 // Production - plain HTTP (Caddy handles SSL)
3920 server = http.createServer(app);
3921 server.listen(PORT, () => {
3922 console.log(`🔥 Oven server running on http://localhost:${PORT}`);
3923 addServerLog('success', '🔥', `Oven server ready (v${GIT_VERSION.slice(0,8)})`);
3924
3925 // Pre-warm Puppeteer browser so first keep thumbnail bake is fast
3926 setTimeout(() => {
3927 addServerLog('info', '🌐', 'Pre-warming grab browser...');
3928 prewarmGrabBrowser().then(() => {
3929 addServerLog('success', '🌐', 'Browser pre-warm complete');
3930 }).catch(err => {
3931 addServerLog('error', '⚠️', `Browser pre-warm failed: ${err.message}`);
3932 });
3933 }, 5000); // Give server 5s to settle first
3934
3935 // Start background OG image regeneration after a short delay
3936 setTimeout(() => {
3937 addServerLog('info', '🖼️', 'Starting background OG regeneration...');
3938 regenerateOGImagesBackground().then(() => {
3939 addServerLog('success', '🖼️', 'OG images ready for social sharing');
3940 }).catch(err => {
3941 addServerLog('error', '❌', `OG regen failed: ${err.message}`);
3942 });
3943 }, 10000); // Wait 10s for server to fully initialize
3944
3945 // Schedule periodic regeneration (every 6 hours)
3946 setInterval(() => {
3947 addServerLog('info', '🖼️', 'Scheduled OG regeneration starting...');
3948 regenerateOGImagesBackground().catch(err => {
3949 addServerLog('error', '❌', `Scheduled OG regen failed: ${err.message}`);
3950 });
3951 }, 6 * 60 * 60 * 1000); // 6 hours
3952
3953 // Start native OTA git poller — watches for fedac/native/ changes
3954 startNativeGitPoller({ startNativeBuild, addServerLog });
3955
3956 // Start papers PDF git poller — watches for papers/ changes
3957 startPapersGitPoller({ startPapersBuild, addServerLog });
3958 });
3959}
3960
3961// WebSocket server
3962wss = new WebSocketServer({ server, path: '/ws' });
3963
3964// Wire up grabber notifications to broadcast to all WebSocket clients
3965setNotifyCallback(() => {
3966 wss.clients.forEach((client) => {
3967 if (client.readyState === 1) { // OPEN
3968 client.send(JSON.stringify({
3969 version: GIT_VERSION,
3970 serverStartTime: SERVER_START_TIME,
3971 uptime: Date.now() - SERVER_START_TIME,
3972 incoming: Array.from(getIncomingBakes().values()),
3973 active: Array.from(getActiveBakes().values()),
3974 recent: getRecentBakes(),
3975 grabs: {
3976 active: getActiveGrabs(),
3977 recent: getRecentGrabs(),
3978 queue: getQueueStatus(),
3979 ipfsThumbs: getAllLatestIPFSUploads()
3980 },
3981 grabProgress: getAllProgress(),
3982 concurrency: getConcurrencyStatus(),
3983 osBaseBuilds: getOSBaseBuildsSummary(),
3984 }));
3985 }
3986 });
3987});
3988
3989// Wire up grabber log messages to broadcast to clients
3990setLogCallback((type, icon, msg) => {
3991 addServerLog(type, icon, msg);
3992});
3993
3994// Wire up native build progress to broadcast to all WebSocket clients
3995onNativeBuildProgress((snapshot) => {
3996 if (wss && wss.clients) {
3997 const msg = JSON.stringify({ type: 'os:build-progress', build: snapshot });
3998 wss.clients.forEach(client => {
3999 if (client.readyState === 1) client.send(msg);
4000 });
4001 }
4002});
4003
4004wss.on('connection', async (ws) => {
4005 console.log('📡 WebSocket client connected');
4006 addServerLog('info', '📡', 'Dashboard client connected');
4007
4008 // Clean up stale bakes before sending initial state
4009 await cleanupStaleBakes();
4010
4011 // Send initial state with recent logs
4012 ws.send(JSON.stringify({
4013 version: GIT_VERSION,
4014 serverStartTime: SERVER_START_TIME,
4015 uptime: Date.now() - SERVER_START_TIME,
4016 incoming: Array.from(getIncomingBakes().values()),
4017 active: Array.from(getActiveBakes().values()),
4018 recent: getRecentBakes(),
4019 grabs: {
4020 active: getActiveGrabs(),
4021 recent: getRecentGrabs(),
4022 queue: getQueueStatus(),
4023 ipfsThumbs: getAllLatestIPFSUploads()
4024 },
4025 grabProgress: getAllProgress(),
4026 concurrency: getConcurrencyStatus(),
4027 osBaseBuilds: getOSBaseBuildsSummary(),
4028 frozen: getFrozenPieces(),
4029 recentLogs: activityLogBuffer.slice(0, 50) // Send last 50 log entries
4030 }));
4031
4032 // Subscribe to updates
4033 const unsubscribe = subscribeToUpdates((update) => {
4034 if (ws.readyState === 1) { // OPEN
4035 ws.send(JSON.stringify({
4036 version: GIT_VERSION,
4037 serverStartTime: SERVER_START_TIME,
4038 uptime: Date.now() - SERVER_START_TIME,
4039 incoming: Array.from(getIncomingBakes().values()),
4040 active: Array.from(getActiveBakes().values()),
4041 recent: getRecentBakes(),
4042 grabs: {
4043 active: getActiveGrabs(),
4044 recent: getRecentGrabs(),
4045 queue: getQueueStatus(),
4046 ipfsThumbs: getAllLatestIPFSUploads()
4047 },
4048 grabProgress: getAllProgress(),
4049 concurrency: getConcurrencyStatus(),
4050 osBaseBuilds: getOSBaseBuildsSummary(),
4051 frozen: getFrozenPieces()
4052 }));
4053 }
4054 });
4055
4056 ws.on('close', () => {
4057 console.log('📡 WebSocket client disconnected');
4058 unsubscribe();
4059 });
4060});
4061
4062// Graceful shutdown handling
4063async function shutdown(signal) {
4064 console.log(`\n🛑 Received ${signal}, shutting down gracefully...`);
4065
4066 // Close WebSocket connections
4067 wss.clients.forEach(ws => ws.close());
4068
4069 // Close HTTP server
4070 server.close(() => {
4071 console.log('✅ HTTP server closed');
4072 });
4073
4074 // Close browser if open
4075 try {
4076 const { closeBrowser } = await import('./grabber.mjs');
4077 await closeBrowser?.();
4078 console.log('✅ Browser closed');
4079 } catch (e) {
4080 // Browser close is optional
4081 }
4082
4083 // Exit after a short delay
4084 setTimeout(() => {
4085 console.log('👋 Goodbye!');
4086 process.exit(0);
4087 }, 500);
4088}
4089
4090process.on('SIGTERM', () => shutdown('SIGTERM'));
4091process.on('SIGINT', () => shutdown('SIGINT'));