Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2
3// ac-unpack.mjs - Extract and test OBJKT packages locally
4// Usage: node ac-unpack.mjs <zip-file> [port]
5
6import fs from 'fs/promises';
7import path from 'path';
8import { spawn } from 'child_process';
9import { fileURLToPath } from 'url';
10import { createReadStream } from 'fs';
11import { createWriteStream } from 'fs';
12import { pipeline } from 'stream/promises';
13
14const __filename = fileURLToPath(import.meta.url);
15const __dirname = path.dirname(__filename);
16
17class TeiaUnpacker {
18 constructor() {
19 this.outputDir = path.join(__dirname, 'output');
20 this.testDir = null; // Will be set based on zip file name
21 this.server = null; // Keep reference to Caddy server
22 }
23
24 async findLatestZip(searchDir = null) {
25 try {
26 // Look in provided search directory first, then current working directory, then fallback to output directory
27 const searchDirs = searchDir ? [searchDir, this.outputDir] : [process.cwd(), this.outputDir];
28 let allZipFiles = [];
29
30 for (const currentDir of searchDirs) {
31 try {
32 const files = await fs.readdir(currentDir);
33 const zipFiles = files
34 .filter(file => file.endsWith('.zip'))
35 .map(file => ({
36 name: file,
37 path: path.join(currentDir, file),
38 stats: null,
39 source: currentDir === (searchDir || process.cwd()) ? 'current' : 'output'
40 }));
41 allZipFiles.push(...zipFiles);
42 } catch (error) {
43 console.log(`📁 Could not read directory ${currentDir}: ${error.message}`);
44 }
45 }
46
47 // Get file stats to sort by modification time
48 for (const file of allZipFiles) {
49 try {
50 file.stats = await fs.stat(file.path);
51 } catch (error) {
52 console.warn(`⚠️ Could not stat ${file.name}:`, error.message);
53 }
54 }
55
56 // Sort by modification time (newest first), preferring current directory
57 const validFiles = allZipFiles.filter(f => f.stats);
58 if (validFiles.length === 0) {
59 throw new Error('No valid zip files found in current directory or output directory');
60 }
61
62 validFiles.sort((a, b) => {
63 // Prefer files from current directory
64 if (a.source === 'current' && b.source !== 'current') return -1;
65 if (b.source === 'current' && a.source !== 'current') return 1;
66 // Then sort by modification time (newest first)
67 return b.stats.mtime - a.stats.mtime;
68 });
69
70 const selectedFile = validFiles[0];
71 console.log(`📍 Found in ${selectedFile.source} directory: ${selectedFile.name}`);
72 return selectedFile.path;
73 } catch (error) {
74 throw new Error(`Failed to find zip files: ${error.message}`);
75 }
76 }
77
78 async extractZip(zipPath) {
79 console.log(`📦 Extracting ${path.basename(zipPath)}...`);
80
81 // Set test directory based on zip file name (without .zip extension)
82 const zipBaseName = path.basename(zipPath, '.zip');
83 this.testDir = path.join(this.outputDir, zipBaseName);
84
85 console.log(`📁 Extract directory: ${this.testDir}`);
86
87 // Clean up existing test directory
88 try {
89 await fs.rm(this.testDir, { recursive: true, force: true });
90 } catch (error) {
91 // Directory might not exist, that's okay
92 }
93
94 // Create test directory
95 await fs.mkdir(this.testDir, { recursive: true });
96
97 // Extract using unzip command
98 return new Promise((resolve, reject) => {
99 const unzip = spawn('unzip', ['-q', zipPath, '-d', this.testDir], {
100 stdio: 'pipe'
101 });
102
103 unzip.on('close', (code) => {
104 if (code === 0) {
105 console.log(`✅ Extracted to ${this.testDir}`);
106 resolve();
107 } else {
108 reject(new Error(`Unzip failed with code ${code}`));
109 }
110 });
111
112 unzip.on('error', (error) => {
113 reject(new Error(`Unzip command failed: ${error.message}`));
114 });
115 });
116 }
117
118 async startServer(port = 8002) {
119 console.log(`🚀 Starting HTTP server on port ${port}...`);
120
121 // Kill any existing server on this port
122 try {
123 await this.killServer(port);
124 // Wait a moment for the port to be freed
125 await new Promise(resolve => setTimeout(resolve, 1000));
126 } catch (error) {
127 // No existing server, that's fine
128 }
129
130 return new Promise((resolve, reject) => {
131 // Create a simple Caddyfile for this port
132 const caddyConfig = `:${port} {
133 root * .
134 file_server
135 header Cache-Control no-cache
136 header Access-Control-Allow-Origin *
137 header Access-Control-Allow-Methods *
138 header Access-Control-Allow-Headers *
139
140 # Ensure proper MIME types for .mjs files
141 @mjs {
142 path *.mjs
143 }
144 header @mjs Content-Type application/javascript
145
146 # Log requests to see what's happening
147 log {
148 output stdout
149 format console
150 }
151}`;
152
153 // Write Caddyfile to test directory
154 const caddyfilePath = path.join(this.testDir, 'Caddyfile');
155
156 // Write Caddyfile and start server
157 fs.writeFile(caddyfilePath, caddyConfig)
158 .then(() => {
159 const server = spawn('caddy', ['run', '--config', 'Caddyfile', '--adapter', 'caddyfile'], {
160 cwd: this.testDir,
161 stdio: ['ignore', 'pipe', 'pipe']
162 });
163
164 // Store server reference for cleanup
165 this.server = server;
166
167 let started = false;
168
169 server.stdout.on('data', (data) => {
170 const output = data.toString();
171 console.log('📡 Caddy:', output.trim());
172
173 if ((output.includes('serving initial configuration') || output.includes('autosaved config')) && !started) {
174 started = true;
175 console.log(`✅ Caddy server running at http://localhost:${port}`);
176 resolve(server);
177 }
178 });
179
180 server.stderr.on('data', (data) => {
181 const output = data.toString();
182 if (output.includes('address already in use') || output.includes('bind: address already in use')) {
183 reject(new Error(`Port ${port} is already in use`));
184 } else if (output.includes('serving initial configuration') && !started) {
185 started = true;
186 console.log(`✅ Caddy server running at http://localhost:${port}`);
187 resolve(server);
188 } else {
189 console.log('� Caddy info:', output.trim());
190 }
191 });
192
193 server.on('close', (code) => {
194 if (!started) {
195 reject(new Error(`Caddy failed to start (exit code ${code})`));
196 }
197 });
198
199 server.on('error', (error) => {
200 if (error.code === 'ENOENT') {
201 reject(new Error('Caddy not found - please install Caddy (https://caddyserver.com/docs/install)'));
202 } else {
203 reject(new Error(`Failed to start Caddy: ${error.message}`));
204 }
205 });
206
207 // Shorter timeout for Caddy startup since it usually starts quickly
208 setTimeout(() => {
209 if (!started) {
210 // Give Caddy a chance - if it's gotten this far, it's probably working
211 console.log(`✅ Caddy server assumed running at http://localhost:${port}`);
212 started = true;
213 resolve(server);
214 }
215 }, 3000);
216 })
217 .catch(error => {
218 reject(new Error(`Failed to write Caddyfile: ${error.message}`));
219 });
220 });
221 }
222
223 async cleanup(port) {
224 console.log('\n🧹 Cleaning up...');
225
226 try {
227 // Kill Caddy server if it exists
228 if (this.server && this.server.kill) {
229 console.log('🛑 Stopping Caddy server...');
230 this.server.kill('SIGTERM');
231 this.server = null;
232 }
233
234 // Also kill any other Caddy processes on this port
235 await this.killServer(port);
236
237 // Delete the extracted directory
238 if (this.testDir) {
239 console.log(`🗑️ Removing extracted directory: ${this.testDir}`);
240 try {
241 await fs.rm(this.testDir, { recursive: true, force: true });
242 console.log('✅ Extracted directory removed');
243 } catch (error) {
244 console.warn('⚠️ Could not remove extracted directory:', error.message);
245 }
246 }
247
248 console.log('✅ Cleanup completed');
249 } catch (error) {
250 console.warn('⚠️ Error during cleanup:', error.message);
251 }
252 }
253
254 async killServer(port = 8080) {
255 console.log(`🔄 Cleaning up any existing servers on port ${port}...`);
256
257 return new Promise((resolve) => {
258 // First try to kill any existing Caddy processes on this port
259 const kill = spawn('pkill', ['-f', `caddy.*${port}`], {
260 stdio: 'pipe'
261 });
262
263 kill.on('close', () => {
264 // Also try to kill any processes using the port directly
265 const killPort = spawn('pkill', ['-f', `:${port}`], {
266 stdio: 'pipe'
267 });
268
269 killPort.on('close', () => {
270 // Give a moment for processes to clean up
271 setTimeout(() => {
272 resolve();
273 }, 1000);
274 });
275
276 killPort.on('error', () => {
277 resolve(); // pkill not found or no process to kill
278 });
279 });
280
281 kill.on('error', () => {
282 resolve(); // pkill not found or no process to kill
283 });
284 });
285 }
286
287 async openBrowser(port = 8080) {
288 const url = `http://localhost:${port}`;
289 console.log(`🌐 Opening browser at ${url}...`);
290
291 // Try to open browser
292 try {
293 if (process.env.BROWSER) {
294 spawn(process.env.BROWSER, [url], { detached: true });
295 } else {
296 // Try common browser commands
297 const browsers = ['xdg-open', 'open', 'start'];
298 for (const browser of browsers) {
299 try {
300 spawn(browser, [url], { detached: true, stdio: 'ignore' });
301 break;
302 } catch (error) {
303 // Try next browser
304 }
305 }
306 }
307 } catch (error) {
308 console.log(`⚠️ Could not auto-open browser: ${error.message}`);
309 console.log(`📋 Manual URL: ${url}`);
310 }
311 }
312
313 async listContents() {
314 try {
315 const files = await fs.readdir(this.testDir, { recursive: true });
316 console.log('\n📁 Package contents:');
317 files.slice(0, 20).forEach(file => {
318 console.log(` ${file}`);
319 });
320 if (files.length > 20) {
321 console.log(` ... and ${files.length - 20} more files`);
322 }
323 } catch (error) {
324 console.warn('⚠️ Could not list contents:', error.message);
325 }
326 }
327
328 async checkAssets() {
329 try {
330 const assetDir = path.join(this.testDir, 'assets', 'type', 'MatrixChunky8');
331 const assets = await fs.readdir(assetDir);
332 console.log(`\n🔤 Found ${assets.length} MatrixChunky8 font assets`);
333
334 // Check for common characters
335 const commonChars = ['0030.json', '0041.json', '0061.json']; // 0, A, a
336 const found = commonChars.filter(char => assets.includes(char));
337 console.log(`✅ Common characters found: ${found.join(', ')}`);
338
339 if (found.length < commonChars.length) {
340 const missing = commonChars.filter(char => !assets.includes(char));
341 console.log(`⚠️ Missing characters: ${missing.join(', ')}`);
342 }
343 } catch (error) {
344 console.warn('⚠️ Could not check font assets:', error.message);
345 }
346 }
347
348 async run(zipPath, port = 8080, searchDir = null) {
349 try {
350 console.log('🎭 OBJKT Package Unpacker\n');
351
352 // Find zip file if not provided
353 if (!zipPath) {
354 console.log('🔍 Finding latest zip file...');
355 if (searchDir) {
356 console.log(`📁 Looking in: ${searchDir}`);
357 }
358 zipPath = await this.findLatestZip(searchDir);
359 console.log(`📦 Using: ${path.basename(zipPath)}`);
360 }
361
362 // Check if zip file exists
363 try {
364 await fs.access(zipPath);
365 } catch (error) {
366 throw new Error(`Zip file not found: ${zipPath}`);
367 }
368
369 // Extract package
370 await this.extractZip(zipPath);
371
372 // List contents
373 await this.listContents();
374
375 // Check assets
376 await this.checkAssets();
377
378 // Start server
379 const server = await this.startServer(port);
380
381 // Open browser
382 await this.openBrowser(port);
383
384 console.log('\n🎯 Testing Instructions:');
385 console.log('1. Check browser console for any errors');
386 console.log('2. Look for MatrixChunky8 glyphs in the QR code corner');
387 console.log('3. Verify no API calls to /api/bdf-glyph');
388 console.log('4. Test KidLisp functionality');
389 console.log('\n⌨️ Press Ctrl+C to stop server and exit');
390
391 // Keep the process alive
392 process.on('SIGINT', async () => {
393 console.log('\n🛑 Shutting down...');
394 await this.cleanup(port);
395 process.exit(0);
396 });
397
398 process.on('SIGTERM', async () => {
399 console.log('\n🛑 Received SIGTERM, shutting down...');
400 await this.cleanup(port);
401 process.exit(0);
402 });
403
404 // Also handle uncaught exceptions to ensure cleanup
405 process.on('uncaughtException', async (error) => {
406 console.error('\n❌ Uncaught exception:', error.message);
407 await this.cleanup(port);
408 process.exit(1);
409 });
410
411 process.on('unhandledRejection', async (reason, promise) => {
412 console.error('\n❌ Unhandled rejection at:', promise, 'reason:', reason);
413 await this.cleanup(port);
414 process.exit(1);
415 });
416
417 // Keep alive
418 await new Promise(() => {});
419
420 } catch (error) {
421 console.error('❌ Error:', error.message);
422 process.exit(1);
423 }
424 }
425}
426
427// Parse command line arguments
428const args = process.argv.slice(2);
429const zipPath = args[0];
430const port = args[1] ? parseInt(args[1]) : 8080;
431const searchDir = args[2]; // Optional search directory
432
433// Run the unpacker
434const unpacker = new TeiaUnpacker();
435unpacker.run(zipPath, port, searchDir);