diff --git a/packages/ui/src/components/ui/UpdateDialog.tsx b/packages/ui/src/components/ui/UpdateDialog.tsx index 37962588..b259fba9 100644 --- a/packages/ui/src/components/ui/UpdateDialog.tsx +++ b/packages/ui/src/components/ui/UpdateDialog.tsx @@ -108,7 +108,13 @@ function parseChangelogSections(body: string): ChangelogSection[] { } -async function installWebUpdate(): Promise<{ success: boolean; error?: string }> { +type InstallWebUpdateResult = { + success: boolean; + error?: string; + autoRestart?: boolean; +}; + +async function installWebUpdate(): Promise { try { const response = await fetch('/api/openchamber/update-install', { method: 'POST', @@ -120,21 +126,31 @@ async function installWebUpdate(): Promise<{ success: boolean; error?: string }> return { success: false, error: data.error || `Server error: ${response.status}` }; } - return { success: true }; + const data = await response.json().catch(() => ({})); + return { + success: true, + autoRestart: data.autoRestart !== false, + }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to install update' }; } } -async function waitForServerRestart(maxAttempts = 30, intervalMs = 2000): Promise { +async function waitForUpdateApplied(maxAttempts = 40, intervalMs = 2000): Promise { for (let i = 0; i < maxAttempts; i++) { try { - const response = await fetch('/health', { method: 'GET' }); + const response = await fetch('/api/openchamber/update-check', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); if (response.ok) { - return true; + const data = await response.json().catch(() => null); + if (data && data.available === false) { + return true; + } } } catch { - // Server not ready yet + // Server may be restarting } await new Promise(resolve => setTimeout(resolve, intervalMs)); } @@ -217,22 +233,20 @@ export const UpdateDialog: React.FC = ({ return; } - // Server will restart, wait for it to come back - setWebUpdateState('restarting'); - - // Wait a bit for server to shut down - await new Promise(resolve => setTimeout(resolve, 2000)); + if (result.autoRestart) { + setWebUpdateState('restarting'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } setWebUpdateState('reconnecting'); - const serverBack = await waitForServerRestart(); + const applied = await waitForUpdateApplied(); - if (serverBack) { - // Reload the page to get the new version + if (applied) { window.location.reload(); } else { setWebUpdateState('error'); - setWebError('Server did not restart. Please refresh manually or run: openchamber restart'); + setWebError('Update did not apply. Refresh and try again, or run: openchamber update'); } }, []); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index bec3b697..52e45d60 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -6255,6 +6255,36 @@ async function main(options = {}) { const pm = detectPackageManager(); 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; + } // Get current server port for restart const currentPort = server.address()?.port || 3000; @@ -6292,6 +6322,7 @@ async function main(options = {}) { message: 'Update starting, server will restart shortly', version: updateInfo.version, packageManager: pm, + autoRestart: true, }); // Give time for response to be sent diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 1c506287..f25f1098 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -21,29 +21,46 @@ const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber export function detectPackageManager() { // Strategy 1: Check user agent (most reliable during install) const userAgent = process.env.npm_config_user_agent || ''; - if (userAgent.startsWith('pnpm')) return 'pnpm'; - if (userAgent.startsWith('yarn')) return 'yarn'; - if (userAgent.startsWith('bun')) return 'bun'; - if (userAgent.startsWith('npm')) return 'npm'; + let hintedPm = null; + if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm'; + else if (userAgent.startsWith('yarn')) hintedPm = 'yarn'; + else if (userAgent.startsWith('bun')) hintedPm = 'bun'; + else if (userAgent.startsWith('npm')) hintedPm = 'npm'; // Strategy 2: Check execpath const execPath = process.env.npm_execpath || ''; - if (execPath.includes('pnpm')) return 'pnpm'; - if (execPath.includes('yarn')) return 'yarn'; - if (execPath.includes('bun')) return 'bun'; + if (!hintedPm) { + if (execPath.includes('pnpm')) hintedPm = 'pnpm'; + else if (execPath.includes('yarn')) hintedPm = 'yarn'; + else if (execPath.includes('bun')) hintedPm = 'bun'; + else if (execPath.includes('npm')) hintedPm = 'npm'; + } // Strategy 3: Analyze package location for PM-specific patterns - try { - const pkgPath = path.resolve(__dirname, '..', '..'); - if (pkgPath.includes('.pnpm')) return 'pnpm'; - if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) return 'yarn'; - if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) return 'bun'; - } catch { - // Ignore path resolution errors + if (!hintedPm) { + try { + const pkgPath = path.resolve(__dirname, '..', '..'); + if (pkgPath.includes('.pnpm')) hintedPm = 'pnpm'; + else if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) hintedPm = 'yarn'; + else if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) hintedPm = 'bun'; + } catch { + // Ignore path resolution errors + } + } + + // Validate the hinted PM actually owns the global install. + // This avoids false positives (for example running via bunx while installed with npm). + if (hintedPm && isCommandAvailable(hintedPm) && isPackageInstalledWith(hintedPm)) { + return hintedPm; + } + + if (isCommandAvailable('npm') && isPackageInstalledWith('npm')) { + return 'npm'; } // Strategy 4: Check which PM binaries are available and preferred const pmChecks = [ + { name: 'npm', check: () => isCommandAvailable('npm') }, { name: 'pnpm', check: () => isCommandAvailable('pnpm') }, { name: 'yarn', check: () => isCommandAvailable('yarn') }, { name: 'bun', check: () => isCommandAvailable('bun') },