2026-08-07 06:10:29 +09:00
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 , "'\\''" ) } '` ;
}
2026-03-31 18:47:00 +03:00
export const registerOpenChamberRoutes = ( app , dependencies ) => {
const {
fs ,
path ,
process ,
server ,
__dirname ,
openchamberDataDir ,
modelsDevApiUrl ,
modelsMetadataCacheTtl ,
readSettingsFromDiskMigrated ,
fetchFreeZenModels ,
getCachedZenModels ,
2026-09-08 01:26:59 +08:00
desktopUpdater ,
2026-03-31 18:47:00 +03:00
} = dependencies ;
2026-09-07 20:38:00 +03:00
let desktopRestartError = null ;
2026-03-31 18:47:00 +03:00
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' ] : '' ;
2026-09-08 01:26:59 +08:00
const updateRequest = {
2026-03-31 18:47:00 +03:00
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 ),
2026-07-15 14:02:12 +03:00
installId : parseString ( req . query . installId ),
2026-03-31 18:47:00 +03:00
reportUsage : parseReportUsage ( parseString ( req . query . reportUsage )),
2026-09-08 01:26:59 +08:00
};
let updateInfo ;
if ( process . env . OPENCHAMBER_RUNTIME === 'desktop' && updateRequest . appType === 'web' ) {
2026-09-07 20:38:00 +03:00
if ( desktopRestartError && req . query . updateStatus === 'true' ) {
return res . status ( 503 ). json ({
code : 'DESKTOP_UPDATE_RESTART_FAILED' ,
error : desktopRestartError ,
});
}
2026-09-08 01:26:59 +08:00
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 );
}
2026-03-31 18:47:00 +03:00
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 {
2026-09-08 01:26:59 +08:00
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.' ,
});
}
2026-09-07 20:38:00 +03:00
desktopRestartError = null ;
2026-09-08 01:26:59 +08:00
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 ())
2026-09-07 20:38:00 +03:00
. catch (( error ) => {
desktopRestartError = error instanceof Error ? error . message : 'Failed to restart after desktop update' ;
console . error ( 'Failed to restart after desktop update:' , error );
});
2026-09-08 01:26:59 +08:00
});
return ;
}
2026-08-07 06:10:29 +09:00
const { spawn : spawnChild , spawnSync } = await import ( 'child_process' );
2026-03-31 18:47:00 +03:00
const {
checkForUpdates ,
getUpdateCommand ,
2026-04-07 13:02:30 +03:00
detectPackageManagerDetails ,
2026-03-31 18:47:00 +03:00
} = await import ( '../package-manager.js' );
const updateInfo = await checkForUpdates ();
if ( ! updateInfo . available ) {
return res . status ( 400 ). json ({ error : 'No update available' });
}
2026-04-07 13:02:30 +03:00
const pmDetails = detectPackageManagerDetails ();
const pm = pmDetails . packageManager ;
2026-03-31 18:47:00 +03:00
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 ;
2026-05-26 01:36:11 +03:00
const instanceFilePath = path . join ( openchamberDataDir , 'run' , `openchamber- ${ currentPort } .json` );
2026-03-31 18:47:00 +03:00
let storedOptions = { port : currentPort , daemon : true };
try {
const content = await fs . promises . readFile ( instanceFilePath , 'utf8' );
storedOptions = JSON . parse ( content );
} catch {
}
2026-05-26 01:36:11 +03:00
const launchMode = storedOptions . launchMode === 'foreground' ? 'foreground' : 'daemon' ;
const isForegroundService = launchMode === 'foreground' ;
2026-08-07 06:10:29 +09:00
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 ,
});
}
2026-03-31 18:47:00 +03:00
const isWindows = process . platform === 'win32' ;
const quotePosix = ( value ) => `' ${ String ( value ). replace ( /'/g , "'\\''" ) } '` ;
const quoteCmd = ( value ) => {
const stringValue = String ( value );
return `" ${ stringValue . replace ( /"/g , '""' ) } "` ;
};
2026-04-07 13:02:30 +03:00
const cliPath = path . resolve ( __dirname , '..' , 'bin' , 'cli.js' );
2026-03-31 18:47:00 +03:00
const restartParts = [
isWindows ? quoteCmd ( process . execPath ) : quotePosix ( process . execPath ),
isWindows ? quoteCmd ( cliPath ) : quotePosix ( cliPath ),
'serve' ,
'--port' ,
String ( storedOptions . port ),
];
let restartCmdPrimary = restartParts . join ( ' ' );
2026-04-07 13:02:30 +03:00
let restartCmdFallback = `openchamber serve --port ${ storedOptions . port } ` ;
2026-04-22 00:02:44 +09:00
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 } '` ;
}
}
2026-03-31 18:47:00 +03:00
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 } '` ;
}
}
2026-06-02 00:43:05 +03:00
if ( storedOptions . apiOnly === true ) {
restartCmdPrimary += ' --api-only' ;
restartCmdFallback += ' --api-only' ;
}
2026-05-26 01:36:11 +03:00
const restartCmd = isForegroundService ? '' : `( ${ restartCmdPrimary } ) || ( ${ restartCmdFallback } )` ;
2026-04-07 13:02:30 +03:00
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' } ` ,
2026-05-26 01:36:11 +03:00
`launchMode= ${ launchMode } ` ,
2026-04-07 13:02:30 +03:00
`updateCommand= ${ updateCmd } ` ,
2026-05-26 01:36:11 +03:00
`restartCommand= ${ restartCmd || 'service-manager' } ` ,
2026-04-07 13:02:30 +03:00
`logPath= ${ updateLogPath } ` ,
]. join ( '\n' );
2026-03-31 18:47:00 +03:00
res . json ({
success : true ,
message : 'Update starting, server will restart shortly' ,
version : updateInfo . version ,
packageManager : pm ,
autoRestart : true ,
2026-05-26 01:36:11 +03:00
restartManager : isForegroundService ? 'service' : 'cli' ,
2026-03-31 18:47:00 +03:00
});
2026-04-07 13:02:30 +03:00
setTimeout (() => {
console . log ( `\nInstalling update using ${ pm } ...` );
console . log ( `Running: ${ updateCmd } ` );
console . log ( logPreamble );
2026-03-31 18:47:00 +03:00
2026-04-07 13:02:30 +03:00
const shell = isWindows ? ( process . env . ComSpec || 'cmd.exe' ) : 'sh' ;
const shellFlag = isWindows ? '/c' : '-c' ;
const script = isWindows
? `
echo ${ quoteCmd ( logPreamble ) }
2026-03-31 18:47:00 +03:00
timeout /t 2 /nobreak >nul
${ updateCmd }
if %ERRORLEVEL% EQU 0 (
echo Update successful, restarting OpenChamber...
2026-05-26 01:36:11 +03:00
${ restartCmd || 'echo Service manager will restart OpenChamber.' }
2026-03-31 18:47:00 +03:00
) else (
echo Update failed
exit /b 1
)
2026-04-07 13:02:30 +03:00
`
2026-03-31 18:47:00 +03:00
: `
2026-04-07 13:02:30 +03:00
printf '%s\n' ${ quotePosix ( logPreamble ) }
2026-03-31 18:47:00 +03:00
sleep 2
${ updateCmd }
if [ $? -eq 0 ]; then
echo "Update successful, restarting OpenChamber..."
2026-05-26 01:36:11 +03:00
${ restartCmd || 'echo "Service manager will restart OpenChamber."' }
2026-03-31 18:47:00 +03:00
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 {
2026-07-05 23:19:10 +03:00
const { getModelsMetadata } = await import ( './models-metadata.js' );
const { metadata , fromCache , stale } = await getModelsMetadata ({
url : modelsDevApiUrl ,
ttlMs : modelsMetadataCacheTtl ,
2026-03-31 18:47:00 +03:00
});
2026-07-05 23:19:10 +03:00
res . setHeader ( 'Cache-Control' , fromCache && ! stale ? 'public, max-age=60' : 'public, max-age=300' );
2026-03-31 18:47:00 +03:00
res . json ( metadata );
} catch ( error ) {
console . warn ( 'Failed to fetch models.dev metadata via server:' , error );
2026-07-05 23:19:10 +03:00
const statusCode = error ? . name === 'TimeoutError' || error ? . name === 'AbortError' ? 504 : 502 ;
res . status ( statusCode ). json ({ error : 'Failed to retrieve model metadata' });
2026-03-31 18:47:00 +03:00
}
});
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' });
}
}
});
};