288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
const {
|
|
fs,
|
|
path,
|
|
process,
|
|
server,
|
|
__dirname,
|
|
openchamberDataDir,
|
|
modelsDevApiUrl,
|
|
modelsMetadataCacheTtl,
|
|
readSettingsFromDiskMigrated,
|
|
fetchFreeZenModels,
|
|
getCachedZenModels,
|
|
} = dependencies;
|
|
|
|
app.get('/api/openchamber/update-check', async (req, res) => {
|
|
try {
|
|
const { checkForUpdates } = await import('../package-manager.js');
|
|
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 updateInfo = await checkForUpdates({
|
|
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)),
|
|
});
|
|
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 {
|
|
const { spawn: spawnChild } = 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 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' });
|
|
}
|
|
}
|
|
});
|
|
};
|