const SYSTEMD_SERVICE_UNIT_PATTERN = /^[A-Za-z0-9:_.@-]+\.service$/; function resolveSystemdServiceUnit(environment) { if (!environment.INVOCATION_ID) { return null; } const configuredUnit = typeof environment.OPENCHAMBER_SYSTEMD_UNIT === 'string' ? environment.OPENCHAMBER_SYSTEMD_UNIT.trim() : ''; const unit = configuredUnit || 'openchamber.service'; return SYSTEMD_SERVICE_UNIT_PATTERN.test(unit) ? unit : null; } function quotePosixShell(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; } export const registerOpenChamberRoutes = (app, dependencies) => { const { fs, path, process, server, __dirname, openchamberDataDir, modelsDevApiUrl, modelsMetadataCacheTtl, readSettingsFromDiskMigrated, fetchFreeZenModels, getCachedZenModels, desktopUpdater, } = dependencies; let desktopRestartError = null; app.get('/api/openchamber/update-check', async (req, res) => { try { const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); const parseReportUsage = (value) => { if (typeof value !== 'string') return true; const normalized = value.trim().toLowerCase(); if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; return true; }; const inferDeviceClass = (ua) => { const value = (ua || '').toLowerCase(); if (!value) return 'unknown'; if (value.includes('ipad') || value.includes('tablet')) return 'tablet'; if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile'; return 'desktop'; }; const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; const updateRequest = { appType: parseString(req.query.appType), deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), platform: parseString(req.query.platform), arch: parseString(req.query.arch), instanceMode: parseString(req.query.instanceMode), currentVersion: parseString(req.query.currentVersion), installId: parseString(req.query.installId), reportUsage: parseReportUsage(parseString(req.query.reportUsage)), }; let updateInfo; if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') { if (desktopRestartError && req.query.updateStatus === 'true') { return res.status(503).json({ code: 'DESKTOP_UPDATE_RESTART_FAILED', error: desktopRestartError, }); } if (typeof desktopUpdater?.check !== 'function') { return res.status(503).json({ available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.', }); } updateInfo = { ...await desktopUpdater.check(), packageManager: 'electron', updateOwner: 'electron-updater', }; } else { const { checkForUpdates } = await import('../package-manager.js'); updateInfo = await checkForUpdates(updateRequest); } res.json(updateInfo); } catch (error) { console.error('Failed to check for updates:', error); res.status(500).json({ available: false, error: error instanceof Error ? error.message : 'Failed to check for updates', }); } }); app.post('/api/openchamber/update-install', async (_req, res) => { try { if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { if (typeof desktopUpdater?.install !== 'function' || typeof desktopUpdater?.restart !== 'function') { return res.status(503).json({ code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.', }); } desktopRestartError = null; const updateInfo = await desktopUpdater.install(); if (!updateInfo?.available) { return res.status(400).json({ error: 'No update available' }); } res.json({ success: true, message: 'Desktop update downloaded, host will restart shortly', version: updateInfo.version, packageManager: 'electron', updateOwner: 'electron-updater', autoRestart: true, restartManager: 'electron-updater', }); setImmediate(() => { Promise.resolve() .then(() => desktopUpdater.restart()) .catch((error) => { desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update'; console.error('Failed to restart after desktop update:', error); }); }); return; } const { spawn: spawnChild, spawnSync } = await import('child_process'); const { checkForUpdates, getUpdateCommand, detectPackageManagerDetails, } = await import('../package-manager.js'); const updateInfo = await checkForUpdates(); if (!updateInfo.available) { return res.status(400).json({ error: 'No update available' }); } const pmDetails = detectPackageManagerDetails(); const pm = pmDetails.packageManager; const updateCmd = getUpdateCommand(pm); const isContainer = fs.existsSync('/.dockerenv') || Boolean(process.env.CONTAINER) || process.env.container === 'docker'; if (isContainer) { res.json({ success: true, message: 'Update starting, server will stay online', version: updateInfo.version, packageManager: pm, autoRestart: false, }); setTimeout(() => { console.log(`\nInstalling update using ${pm} (container mode)...`); console.log(`Running: ${updateCmd}`); const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; const shellFlag = process.platform === 'win32' ? '/c' : '-c'; const child = spawnChild(shell, [shellFlag, updateCmd], { detached: true, stdio: 'ignore', env: process.env, }); child.unref(); }, 500); return; } const currentPort = server.address()?.port || 3000; const instanceFilePath = path.join(openchamberDataDir, 'run', `openchamber-${currentPort}.json`); let storedOptions = { port: currentPort, daemon: true }; try { const content = await fs.promises.readFile(instanceFilePath, 'utf8'); storedOptions = JSON.parse(content); } catch { } const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon'; const isForegroundService = launchMode === 'foreground'; const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null; if (isForegroundService) { if (!systemdServiceUnit) { return res.status(409).json({ error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.', }); } const updateJobName = `openchamber-update-${Date.now()}`; const updateLogPath = `journalctl --user-unit ${updateJobName}.service`; const updateScript = [ 'set -eu', updateCmd, `systemctl --user restart ${quotePosixShell(systemdServiceUnit)}`, ].join('\n'); const systemdRun = spawnSync('systemd-run', [ '--user', `--unit=${updateJobName}`, '--collect', '--service-type=exec', `--setenv=PATH=${process.env.PATH || ''}`, '/bin/sh', '-c', updateScript, ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, }); if (systemdRun.status !== 0) { const detail = (systemdRun.stderr || systemdRun.stdout || '').trim(); return res.status(409).json({ error: detail || `Could not queue update job for ${systemdServiceUnit}`, }); } return res.json({ success: true, message: 'Update queued; OpenChamber will restart after installation completes', version: updateInfo.version, packageManager: pm, autoRestart: true, restartManager: 'systemd', jobId: updateJobName, logPath: updateLogPath, }); } const isWindows = process.platform === 'win32'; const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; const quoteCmd = (value) => { const stringValue = String(value); return `"${stringValue.replace(/"/g, '""')}"`; }; const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js'); const restartParts = [ isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), 'serve', '--port', String(storedOptions.port), ]; let restartCmdPrimary = restartParts.join(' '); let restartCmdFallback = `openchamber serve --port ${storedOptions.port}`; if (storedOptions.host) { if (isWindows) { const escapedHost = storedOptions.host.replace(/"/g, '""'); restartCmdPrimary += ` --host "${escapedHost}"`; restartCmdFallback += ` --host "${escapedHost}"`; } else { const escapedHost = storedOptions.host.replace(/'/g, "'\\''"); restartCmdPrimary += ` --host '${escapedHost}'`; restartCmdFallback += ` --host '${escapedHost}'`; } } if (storedOptions.uiPassword) { if (isWindows) { const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); restartCmdPrimary += ` --ui-password "${escapedPw}"`; restartCmdFallback += ` --ui-password "${escapedPw}"`; } else { const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); restartCmdPrimary += ` --ui-password '${escapedPw}'`; restartCmdFallback += ` --ui-password '${escapedPw}'`; } } if (storedOptions.apiOnly === true) { restartCmdPrimary += ' --api-only'; restartCmdFallback += ' --api-only'; } const restartCmd = isForegroundService ? '' : `(${restartCmdPrimary}) || (${restartCmdFallback})`; const updateLogPath = path.join(openchamberDataDir, 'update-install.log'); const logPreamble = [ '', `=== OpenChamber update ${new Date().toISOString()} ===`, `currentVersion=${updateInfo.currentVersion || 'unknown'}`, `targetVersion=${updateInfo.version || 'unknown'}`, `packageManager=${pm}`, `packageManagerReason=${pmDetails.reason || 'unknown'}`, `packageManagerCommand=${pmDetails.packageManagerCommand || 'unknown'}`, `packagePath=${pmDetails.packagePath || 'unknown'}`, `globalNodeModulesRoot=${pmDetails.globalNodeModulesRoot || 'unknown'}`, `mode=${isContainer ? 'container' : 'restart'}`, `launchMode=${launchMode}`, `updateCommand=${updateCmd}`, `restartCommand=${restartCmd || 'service-manager'}`, `logPath=${updateLogPath}`, ].join('\n'); res.json({ success: true, message: 'Update starting, server will restart shortly', version: updateInfo.version, packageManager: pm, autoRestart: true, restartManager: isForegroundService ? 'service' : 'cli', }); setTimeout(() => { console.log(`\nInstalling update using ${pm}...`); console.log(`Running: ${updateCmd}`); console.log(logPreamble); const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh'; const shellFlag = isWindows ? '/c' : '-c'; const script = isWindows ? ` echo ${quoteCmd(logPreamble)} timeout /t 2 /nobreak >nul ${updateCmd} if %ERRORLEVEL% EQU 0 ( echo Update successful, restarting OpenChamber... ${restartCmd || 'echo Service manager will restart OpenChamber.'} ) else ( echo Update failed exit /b 1 ) ` : ` printf '%s\n' ${quotePosix(logPreamble)} sleep 2 ${updateCmd} if [ $? -eq 0 ]; then echo "Update successful, restarting OpenChamber..." ${restartCmd || 'echo "Service manager will restart OpenChamber."'} else echo "Update failed" exit 1 fi `; let logFd = null; try { fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); logFd = fs.openSync(updateLogPath, 'a'); } catch (logError) { console.warn('Failed to open update log file, continuing without log capture:', logError); } const child = spawnChild(shell, [shellFlag, script], { detached: true, stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore', env: process.env, }); child.unref(); if (logFd !== null) { try { fs.closeSync(logFd); } catch { } } console.log('Update process spawned, shutting down server...'); setTimeout(() => { process.exit(0); }, 500); }, 500); } catch (error) { console.error('Failed to install update:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to install update', }); } }); app.get('/api/openchamber/models-metadata', async (_req, res) => { try { const { getModelsMetadata } = await import('./models-metadata.js'); const { metadata, fromCache, stale } = await getModelsMetadata({ url: modelsDevApiUrl, ttlMs: modelsMetadataCacheTtl, }); res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300'); res.json(metadata); } catch (error) { console.warn('Failed to fetch models.dev metadata via server:', error); const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502; res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); } }); app.get('/api/zen/models', async (_req, res) => { try { const models = await fetchFreeZenModels(); res.setHeader('Cache-Control', 'public, max-age=300'); res.json({ models }); } catch (error) { console.warn('Failed to fetch zen models:', error); const cachedZenModels = getCachedZenModels(); if (cachedZenModels) { res.setHeader('Cache-Control', 'public, max-age=60'); res.json(cachedZenModels); } else { const statusCode = error?.name === 'AbortError' ? 504 : 502; res.status(statusCode).json({ error: 'Failed to retrieve zen models' }); } } }); };