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 { const {
checkForUpdates, checkForUpdates,
getUpdateCommand, getUpdateCommand,
detectPackageManager, detectPackageManagerDetails,
} = await import('../package-manager.js'); } = await import('../package-manager.js');
const updateInfo = await checkForUpdates(); const updateInfo = await checkForUpdates();
@@ -69,7 +69,8 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
return res.status(400).json({ error: 'No update available' }); return res.status(400).json({ error: 'No update available' });
} }
const pm = detectPackageManager(); const pmDetails = detectPackageManagerDetails();
const pm = pmDetails.packageManager;
const updateCmd = getUpdateCommand(pm); const updateCmd = getUpdateCommand(pm);
const isContainer = const isContainer =
fs.existsSync('/.dockerenv') || fs.existsSync('/.dockerenv') ||
@@ -119,17 +120,16 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
return `"${stringValue.replace(/"/g, '""')}"`; return `"${stringValue.replace(/"/g, '""')}"`;
}; };
const cliPath = path.resolve(__dirname, '..', '..', 'bin', 'cli.js'); const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js');
const restartParts = [ const restartParts = [
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
'serve', 'serve',
'--port', '--port',
String(storedOptions.port), String(storedOptions.port),
'--daemon',
]; ];
let restartCmdPrimary = restartParts.join(' '); let restartCmdPrimary = restartParts.join(' ');
let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; let restartCmdFallback = `openchamber serve --port ${storedOptions.port}`;
if (storedOptions.uiPassword) { if (storedOptions.uiPassword) {
if (isWindows) { if (isWindows) {
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
@@ -142,6 +142,22 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
} }
} }
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`; 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({ res.json({
success: true, success: true,
@@ -151,14 +167,16 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
autoRestart: true, autoRestart: true,
}); });
setTimeout(() => { setTimeout(() => {
console.log(`\nInstalling update using ${pm}...`); console.log(`\nInstalling update using ${pm}...`);
console.log(`Running: ${updateCmd}`); console.log(`Running: ${updateCmd}`);
console.log(logPreamble);
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh'; const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
const shellFlag = isWindows ? '/c' : '-c'; const shellFlag = isWindows ? '/c' : '-c';
const script = isWindows const script = isWindows
? ` ? `
echo ${quoteCmd(logPreamble)}
timeout /t 2 /nobreak >nul timeout /t 2 /nobreak >nul
${updateCmd} ${updateCmd}
if %ERRORLEVEL% EQU 0 ( if %ERRORLEVEL% EQU 0 (
@@ -168,8 +186,9 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
echo Update failed echo Update failed
exit /b 1 exit /b 1
) )
` `
: ` : `
printf '%s\n' ${quotePosix(logPreamble)}
sleep 2 sleep 2
${updateCmd} ${updateCmd}
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
@@ -181,7 +200,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
fi fi
`; `;
const updateLogPath = path.join(openchamberDataDir, 'update-install.log');
let logFd = null; let logFd = null;
try { try {
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); 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 __dirname = path.dirname(__filename);
const PACKAGE_NAME = '@openchamber/web'; const PACKAGE_NAME = '@openchamber/web';
const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/');
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md';
let cachedDetectedPm = null; let cachedDetectedPm = null;
@@ -131,17 +132,209 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) {
} }
} }
/** function normalizePathForComparison(filePath) {
* Detect which package manager was used to install this package. if (!filePath || typeof filePath !== 'string') return null;
* Strategy: const normalized = path.normalize(path.resolve(filePath));
* 1. Check npm_config_user_agent (set during npm/pnpm/yarn/bun install) return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
* 2. Check npm_execpath for PM binary path }
* 3. Analyze package location path for PM-specific patterns
* 4. Fall back to npm function getComparablePaths(filePath) {
*/ const paths = new Set();
export function detectPackageManager() { 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) { 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(); const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim();
@@ -149,18 +342,44 @@ export function detectPackageManager() {
const forcedPmCommand = resolvePackageManagerCommand(forcedPm); const forcedPmCommand = resolvePackageManagerCommand(forcedPm);
if (isCommandAvailable(forcedPmCommand)) { if (isCommandAvailable(forcedPmCommand)) {
cachedDetectedPm = forcedPm; 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) // First prefer the package manager that demonstrably owns the current install.
const runtimePm = detectPackageManagerFromRuntimePath(process.execPath); const installPathPm = detectPackageManagerFromCurrentInstallPath();
if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) { if (installPathPm && packageManagerOwnsCurrentInstall(installPathPm)) {
cachedDetectedPm = runtimePm; cachedDetectedPm = installPathPm;
return cachedDetectedPm; 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 || ''; const userAgent = process.env.npm_config_user_agent || '';
let hintedPm = null; let hintedPm = null;
if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm'; if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm';
@@ -168,7 +387,7 @@ export function detectPackageManager() {
else if (userAgent.startsWith('bun')) hintedPm = 'bun'; else if (userAgent.startsWith('bun')) hintedPm = 'bun';
else if (userAgent.startsWith('npm')) hintedPm = 'npm'; else if (userAgent.startsWith('npm')) hintedPm = 'npm';
// Strategy 3: Check execpath // Check execpath.
const execPath = process.env.npm_execpath || ''; const execPath = process.env.npm_execpath || '';
if (!hintedPm) { if (!hintedPm) {
if (execPath.includes('pnpm')) hintedPm = 'pnpm'; if (execPath.includes('pnpm')) hintedPm = 'pnpm';
@@ -177,39 +396,41 @@ export function detectPackageManager() {
else if (execPath.includes('npm')) hintedPm = 'npm'; 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]); const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]);
if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) {
cachedDetectedPm = invokedPm;
return cachedDetectedPm;
}
if (!hintedPm) { if (!hintedPm) {
hintedPm = invokedPm; hintedPm = invokedPm;
} }
// Strategy 5: Analyze package location for PM-specific patterns if (!hintedPm) {
try { hintedPm = installPathPm;
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
} }
// Validate the hinted PM actually owns the global install. // Validate the hint against package visibility, but only after ownership checks failed.
// This avoids false positives (for example running via bunx while installed with npm).
if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) { if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) {
cachedDetectedPm = 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 = [ const pmChecks = [
{ name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) }, { name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) },
{ name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) }, { name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) },
@@ -222,13 +443,29 @@ export function detectPackageManager() {
// Verify this PM actually has the package installed globally // Verify this PM actually has the package installed globally
if (isPackageInstalledWith(name)) { if (isPackageInstalledWith(name)) {
cachedDetectedPm = 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'; 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) { function detectPackageManagerFromInstallPath(pkgPath) {