Monorepo for Aesthetic.Computer aesthetic.computer
at main 435 lines 14 kB view raw
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);