From e9c7c9a436ec8cbad7d101417851ef9b06735118 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 7 Apr 2026 13:02:30 +0300 Subject: [PATCH] fix: improve web self-update package manager detection - Detect update package manager from install ownership across npm, pnpm, yarn, and bun - Fix web self-update restart command so the server comes back cleanly after install - Add clearer update logs with package manager choice, commands, and restart context --- .../server/lib/opencode/openchamber-routes.js | 46 ++- packages/web/server/lib/package-manager.js | 321 +++++++++++++++--- 2 files changed, 311 insertions(+), 56 deletions(-) diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js index faba7cb6..0156058b 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.js +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -61,7 +61,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => { const { checkForUpdates, getUpdateCommand, - detectPackageManager, + detectPackageManagerDetails, } = await import('../package-manager.js'); const updateInfo = await checkForUpdates(); @@ -69,7 +69,8 @@ export const registerOpenChamberRoutes = (app, dependencies) => { return res.status(400).json({ error: 'No update available' }); } - const pm = detectPackageManager(); + const pmDetails = detectPackageManagerDetails(); + const pm = pmDetails.packageManager; const updateCmd = getUpdateCommand(pm); const isContainer = fs.existsSync('/.dockerenv') || @@ -119,17 +120,16 @@ export const registerOpenChamberRoutes = (app, dependencies) => { return `"${stringValue.replace(/"/g, '""')}"`; }; - const cliPath = path.resolve(__dirname, '..', '..', 'bin', 'cli.js'); + 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), - '--daemon', ]; let restartCmdPrimary = restartParts.join(' '); - let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; + let restartCmdFallback = `openchamber serve --port ${storedOptions.port}`; if (storedOptions.uiPassword) { if (isWindows) { const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); @@ -142,6 +142,22 @@ export const registerOpenChamberRoutes = (app, dependencies) => { } } const restartCmd = `(${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'}`, + `updateCommand=${updateCmd}`, + `restartCommand=${restartCmd}`, + `logPath=${updateLogPath}`, + ].join('\n'); res.json({ success: true, @@ -151,14 +167,16 @@ export const registerOpenChamberRoutes = (app, dependencies) => { autoRestart: true, }); - setTimeout(() => { - console.log(`\nInstalling update using ${pm}...`); - console.log(`Running: ${updateCmd}`); + 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 - ? ` + 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 ( @@ -168,8 +186,9 @@ export const registerOpenChamberRoutes = (app, dependencies) => { echo Update failed exit /b 1 ) - ` + ` : ` + printf '%s\n' ${quotePosix(logPreamble)} sleep 2 ${updateCmd} if [ $? -eq 0 ]; then @@ -181,7 +200,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => { fi `; - const updateLogPath = path.join(openchamberDataDir, 'update-install.log'); let logFd = null; try { fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 1624a051..a2c737eb 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -9,6 +9,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PACKAGE_NAME = '@openchamber/web'; +const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/'); const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; let cachedDetectedPm = null; @@ -131,17 +132,209 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) { } } -/** - * Detect which package manager was used to install this package. - * Strategy: - * 1. Check npm_config_user_agent (set during npm/pnpm/yarn/bun install) - * 2. Check npm_execpath for PM binary path - * 3. Analyze package location path for PM-specific patterns - * 4. Fall back to npm - */ -export function detectPackageManager() { +function normalizePathForComparison(filePath) { + if (!filePath || typeof filePath !== 'string') return null; + const normalized = path.normalize(path.resolve(filePath)); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function getComparablePaths(filePath) { + const paths = new Set(); + const normalized = normalizePathForComparison(filePath); + if (normalized) { + paths.add(normalized); + } + + try { + const realPath = fs.realpathSync.native ? fs.realpathSync.native(filePath) : fs.realpathSync(filePath); + const normalizedRealPath = normalizePathForComparison(realPath); + if (normalizedRealPath) { + paths.add(normalizedRealPath); + } + } catch { + } + + return paths; +} + +function pathSetContains(a, b) { + for (const value of a) { + if (b.has(value)) { + return true; + } + } + return false; +} + +function getCurrentPackagePath() { + return path.resolve(__dirname, '..', '..'); +} + +function getPackagePathForGlobalRoot(rootPath) { + if (!rootPath) return null; + return path.join(rootPath, ...PACKAGE_PATH_SEGMENTS); +} + +function getUniquePaths(paths) { + const seen = new Set(); + const result = []; + for (const value of paths) { + const normalized = normalizePathForComparison(value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(path.resolve(value)); + } + return result; +} + +function getCommandOutput(command, args) { + try { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10000, + ...getSpawnSyncBaseOptions(), + }); + + if (result.status !== 0) { + return null; + } + + const stdout = result.stdout.trim(); + return stdout || null; + } catch { + return null; + } +} + +function getGlobalBinDirs(pm) { + const pmCommand = resolvePackageManagerCommand(pm); + if (!isCommandAvailable(pmCommand)) { + return []; + } + + const dirs = []; + switch (pm) { + case 'pnpm': { + const pnpmBin = getCommandOutput(pmCommand, ['bin', '-g']); + if (pnpmBin) dirs.push(pnpmBin); + const pnpmPrefix = getCommandOutput(pmCommand, ['prefix', '-g']); + if (pnpmPrefix) dirs.push(process.platform === 'win32' ? pnpmPrefix : path.join(pnpmPrefix, 'bin')); + break; + } + case 'yarn': { + const yarnBin = getCommandOutput(pmCommand, ['global', 'bin']); + if (yarnBin) dirs.push(yarnBin); + break; + } + case 'bun': { + const bunBin = getCommandOutput(pmCommand, ['pm', 'bin', '-g']); + if (bunBin) dirs.push(bunBin); + break; + } + default: { + const npmPrefix = getCommandOutput(pmCommand, ['prefix', '-g']); + if (npmPrefix) dirs.push(process.platform === 'win32' ? npmPrefix : path.join(npmPrefix, 'bin')); + break; + } + } + + return getUniquePaths(dirs); +} + +function getGlobalNodeModulesRoots(pm) { + try { + const pmCommand = resolvePackageManagerCommand(pm); + if (!isCommandAvailable(pmCommand)) { + return []; + } + + const roots = []; + + switch (pm) { + case 'pnpm': { + const pnpmRoot = getCommandOutput(pmCommand, ['root', '-g']); + if (pnpmRoot) roots.push(pnpmRoot); + const pnpmPrefix = getCommandOutput(pmCommand, ['prefix', '-g']); + if (pnpmPrefix) roots.push(process.platform === 'win32' ? path.join(pnpmPrefix, 'node_modules') : path.join(pnpmPrefix, 'lib', 'node_modules')); + break; + } + case 'yarn': { + const yarnDir = getCommandOutput(pmCommand, ['global', 'dir']); + if (yarnDir) roots.push(path.join(yarnDir, 'node_modules')); + break; + } + case 'bun': { + const bunBinDir = getCommandOutput(pmCommand, ['pm', 'bin', '-g']); + if (bunBinDir) { + roots.push(path.resolve(bunBinDir, '..', 'install', 'global', 'node_modules')); + roots.push(path.resolve(bunBinDir, '..', '..', 'node_modules')); + } + break; + } + default: + { + const npmRoot = getCommandOutput(pmCommand, ['root', '-g']); + if (npmRoot) roots.push(npmRoot); + const npmPrefix = getCommandOutput(pmCommand, ['prefix', '-g']); + if (npmPrefix) roots.push(process.platform === 'win32' ? path.join(npmPrefix, 'node_modules') : path.join(npmPrefix, 'lib', 'node_modules')); + break; + } + } + + return getUniquePaths(roots); + } catch { + return []; + } +} + +function getOwnedPackagePathsFromGlobalBins(pm) { + const packagePaths = []; + for (const binDir of getGlobalBinDirs(pm)) { + const binaryName = process.platform === 'win32' ? 'openchamber.cmd' : 'openchamber'; + const binaryPath = path.join(binDir, binaryName); + if (!fs.existsSync(binaryPath)) continue; + + try { + const realBinaryPath = fs.realpathSync.native ? fs.realpathSync.native(binaryPath) : fs.realpathSync(binaryPath); + packagePaths.push(path.resolve(realBinaryPath, '..', '..')); + } catch { + } + } + + return getUniquePaths(packagePaths); +} + +function detectPackageManagerFromCurrentInstallPath() { + return detectPackageManagerFromInstallPath(getCurrentPackagePath()); +} + +function packageManagerOwnsCurrentInstall(pm) { + const currentPackagePaths = getComparablePaths(getCurrentPackagePath()); + const candidatePackagePaths = [ + ...getGlobalNodeModulesRoots(pm).map(getPackagePathForGlobalRoot), + ...getOwnedPackagePathsFromGlobalBins(pm), + ]; + + for (const candidatePath of candidatePackagePaths) { + if (!candidatePath) continue; + if (pathSetContains(currentPackagePaths, getComparablePaths(candidatePath))) { + return true; + } + } + + return false; +} + +export function detectPackageManagerDetails() { if (cachedDetectedPm) { - return cachedDetectedPm; + return { + packageManager: cachedDetectedPm, + reason: 'cached', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; } const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim(); @@ -149,18 +342,44 @@ export function detectPackageManager() { const forcedPmCommand = resolvePackageManagerCommand(forcedPm); if (isCommandAvailable(forcedPmCommand)) { cachedDetectedPm = forcedPm; - return cachedDetectedPm; + return { + packageManager: cachedDetectedPm, + reason: 'forced-env', + packagePath: getCurrentPackagePath(), + packageManagerCommand: forcedPmCommand, + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; } } - // Strategy 1: Detect from runtime executable path (reliable for server-side updates) - const runtimePm = detectPackageManagerFromRuntimePath(process.execPath); - if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) { - cachedDetectedPm = runtimePm; - return cachedDetectedPm; + // First prefer the package manager that demonstrably owns the current install. + const installPathPm = detectPackageManagerFromCurrentInstallPath(); + if (installPathPm && packageManagerOwnsCurrentInstall(installPathPm)) { + cachedDetectedPm = installPathPm; + return { + packageManager: cachedDetectedPm, + reason: 'install-path-owner', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; } - // Strategy 2: Check user agent (most reliable during install) + const ownershipCandidates = ['pnpm', 'yarn', 'bun', 'npm']; + for (const candidate of ownershipCandidates) { + if (packageManagerOwnsCurrentInstall(candidate)) { + cachedDetectedPm = candidate; + return { + packageManager: cachedDetectedPm, + reason: 'global-root-owner', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; + } + } + + // Fall back to weaker hints only when ownership cannot be established. const userAgent = process.env.npm_config_user_agent || ''; let hintedPm = null; if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm'; @@ -168,7 +387,7 @@ export function detectPackageManager() { else if (userAgent.startsWith('bun')) hintedPm = 'bun'; else if (userAgent.startsWith('npm')) hintedPm = 'npm'; - // Strategy 3: Check execpath + // Check execpath. const execPath = process.env.npm_execpath || ''; if (!hintedPm) { if (execPath.includes('pnpm')) hintedPm = 'pnpm'; @@ -177,39 +396,41 @@ export function detectPackageManager() { else if (execPath.includes('npm')) hintedPm = 'npm'; } - // Strategy 4: Detect from invoked binary path (works for bun global symlink installs) + // Detect from invoked binary path. const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]); - if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) { - cachedDetectedPm = invokedPm; - return cachedDetectedPm; - } if (!hintedPm) { hintedPm = invokedPm; } - // Strategy 5: Analyze package location for PM-specific patterns - try { - const pkgPath = path.resolve(__dirname, '..', '..'); - const pmFromPath = detectPackageManagerFromInstallPath(pkgPath); - if (pmFromPath && isCommandAvailable(resolvePackageManagerCommand(pmFromPath))) { - cachedDetectedPm = pmFromPath; - return cachedDetectedPm; - } - if (!hintedPm) { - hintedPm = pmFromPath; - } - } catch { - // Ignore path resolution errors + if (!hintedPm) { + hintedPm = installPathPm; } - // Validate the hinted PM actually owns the global install. - // This avoids false positives (for example running via bunx while installed with npm). + // Validate the hint against package visibility, but only after ownership checks failed. if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) { cachedDetectedPm = hintedPm; - return cachedDetectedPm; + return { + packageManager: cachedDetectedPm, + reason: 'hinted-visible-install', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; } - // Strategy 6: Check which PM binaries are available and preferred + const runtimePm = detectPackageManagerFromRuntimePath(process.execPath); + if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm)) && isPackageInstalledWith(runtimePm)) { + cachedDetectedPm = runtimePm; + return { + packageManager: cachedDetectedPm, + reason: 'runtime-visible-install', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; + } + + // Last resort: pick a PM that can at least see the package. const pmChecks = [ { name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) }, { name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) }, @@ -222,13 +443,29 @@ export function detectPackageManager() { // Verify this PM actually has the package installed globally if (isPackageInstalledWith(name)) { cachedDetectedPm = name; - return cachedDetectedPm; + return { + packageManager: cachedDetectedPm, + reason: 'last-resort-visible-install', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; } } } cachedDetectedPm = 'npm'; - return cachedDetectedPm; + return { + packageManager: cachedDetectedPm, + reason: 'default-fallback', + packagePath: getCurrentPackagePath(), + packageManagerCommand: resolvePackageManagerCommand(cachedDetectedPm), + globalNodeModulesRoot: getGlobalNodeModulesRoots(cachedDetectedPm)[0] || null, + }; +} + +export function detectPackageManager() { + return detectPackageManagerDetails().packageManager; } function detectPackageManagerFromInstallPath(pkgPath) {