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
This commit is contained in:
Bohdan Triapitsyn
2026-04-07 13:02:30 +03:00
parent db19f83f46
commit e9c7c9a436
2 changed files with 311 additions and 56 deletions
@@ -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 });
+279 -42
View File
@@ -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) {