fix: harden self-update flow and improve chat message readability (#562)

* fix: resolve Windows CLI module loading on absolute paths

* fix: improve chat tool rows layout and timestamp readability

* fix: make web update restart more reliable

* fix: make web self-update detect package manager correctly
This commit is contained in:
Bohdan Triapitsyn
2026-03-01 01:21:01 +02:00
committed by GitHub
parent 7b5fd9e70c
commit 8bfbed0e68
7 changed files with 430 additions and 170 deletions
+57 -16
View File
@@ -3367,27 +3367,30 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => {
const ENV_CONFIGURED_OPENCODE_HOST = (() => {
const raw = process.env.OPENCODE_HOST?.trim();
if (!raw) return null;
const warnInvalidHost = (reason) => {
console.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`);
};
let url;
try {
url = new URL(raw);
} catch {
console.error(`[fatal] OPENCODE_HOST is not a valid URL: ${JSON.stringify(raw)}`);
process.exit(1);
warnInvalidHost('not a valid URL');
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
console.error(`[fatal] OPENCODE_HOST must use http or https scheme, got: ${JSON.stringify(url.protocol)}`);
process.exit(1);
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
return null;
}
const port = parseInt(url.port, 10);
if (!Number.isFinite(port) || port <= 0) {
console.error(`[fatal] OPENCODE_HOST must include an explicit port (e.g. http://hostname:4096), got: ${JSON.stringify(raw)}`);
process.exit(1);
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
return null;
}
if (url.pathname !== '/' || url.search || url.hash) {
console.error(
`[fatal] OPENCODE_HOST must not include a path, query, or hash; got: ${JSON.stringify(raw)}`
);
process.exit(1);
warnInvalidHost('must not include path, query, or hash');
return null;
}
return { origin: url.origin, port };
})();
@@ -7240,19 +7243,39 @@ async function main(options = {}) {
const isWindows = process.platform === 'win32';
// Build restart command with stored options
let restartCmd = `openchamber serve --port ${storedOptions.port} --daemon`;
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
const quoteCmd = (value) => {
const stringValue = String(value);
return `"${stringValue.replace(/"/g, '""')}"`;
};
// Build restart command using explicit runtime + CLI path.
// Avoids relying on `openchamber` being in PATH for service environments.
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`;
if (storedOptions.uiPassword) {
if (isWindows) {
// Escape for cmd.exe quoted argument
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
restartCmd += ` --ui-password "${escapedPw}"`;
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
restartCmdFallback += ` --ui-password "${escapedPw}"`;
} else {
// Escape for POSIX single-quoted argument
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
restartCmd += ` --ui-password '${escapedPw}'`;
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
restartCmdFallback += ` --ui-password '${escapedPw}'`;
}
}
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`;
// Respond immediately - update will happen after response
res.json({
@@ -7298,14 +7321,32 @@ async function main(options = {}) {
fi
`;
// Spawn detached shell to run update after we exit
// Spawn detached shell to run update after we exit.
// Capture output to disk so restart failures are diagnosable.
const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log');
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: 'ignore',
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
env: process.env,
});
child.unref();
if (logFd !== null) {
try {
fs.closeSync(logFd);
} catch {
// ignore
}
}
console.log('Update process spawned, shutting down server...');
// Give child process time to start, then exit
+117 -27
View File
@@ -19,7 +19,21 @@ const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber
* 4. Fall back to npm
*/
export function detectPackageManager() {
// Strategy 1: Check user agent (most reliable during install)
const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim();
if (forcedPm && ['npm', 'pnpm', 'yarn', 'bun'].includes(forcedPm)) {
const forcedPmCommand = resolvePackageManagerCommand(forcedPm);
if (isCommandAvailable(forcedPmCommand)) {
return forcedPm;
}
}
// Strategy 1: Detect from runtime executable path (reliable for server-side updates)
const runtimePm = detectPackageManagerFromRuntimePath(process.execPath);
if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) {
return runtimePm;
}
// Strategy 2: Check user agent (most reliable during install)
const userAgent = process.env.npm_config_user_agent || '';
let hintedPm = null;
if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm';
@@ -27,7 +41,7 @@ export function detectPackageManager() {
else if (userAgent.startsWith('bun')) hintedPm = 'bun';
else if (userAgent.startsWith('npm')) hintedPm = 'npm';
// Strategy 2: Check execpath
// Strategy 3: Check execpath
const execPath = process.env.npm_execpath || '';
if (!hintedPm) {
if (execPath.includes('pnpm')) hintedPm = 'pnpm';
@@ -36,34 +50,41 @@ export function detectPackageManager() {
else if (execPath.includes('npm')) hintedPm = 'npm';
}
// Strategy 3: Analyze package location for PM-specific patterns
// Strategy 4: Detect from invoked binary path (works for bun global symlink installs)
const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]);
if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) {
return invokedPm;
}
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
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))) {
return pmFromPath;
}
if (!hintedPm) {
hintedPm = pmFromPath;
}
} 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)) {
if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) {
return hintedPm;
}
if (isCommandAvailable('npm') && isPackageInstalledWith('npm')) {
return 'npm';
}
// Strategy 4: Check which PM binaries are available and preferred
// Strategy 6: 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') },
{ name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) },
{ name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) },
{ name: 'bun', check: () => isCommandAvailable(resolvePackageManagerCommand('bun')) },
{ name: 'npm', check: () => isCommandAvailable(resolvePackageManagerCommand('npm')) },
];
for (const { name, check } of pmChecks) {
@@ -78,6 +99,74 @@ export function detectPackageManager() {
return 'npm';
}
function detectPackageManagerFromInstallPath(pkgPath) {
if (!pkgPath) return null;
const normalized = pkgPath.replace(/\\/g, '/').toLowerCase();
if (normalized.includes('/.pnpm/') || normalized.includes('/pnpm/')) return 'pnpm';
if (normalized.includes('/.yarn/')) return 'yarn';
if (normalized.includes('/.bun/') || normalized.includes('/bun/install/')) return 'bun';
if (normalized.includes('/node_modules/')) return 'npm';
return null;
}
function detectPackageManagerFromRuntimePath(runtimePath) {
if (!runtimePath || typeof runtimePath !== 'string') return null;
const normalized = runtimePath.replace(/\\/g, '/').toLowerCase();
if (normalized.includes('/.bun/bin/bun') || normalized.endsWith('/bun') || normalized.endsWith('/bun.exe')) {
return 'bun';
}
if (normalized.includes('/pnpm/')) return 'pnpm';
if (normalized.includes('/yarn/')) return 'yarn';
if (normalized.includes('/node') || normalized.endsWith('/node.exe')) return 'npm';
return null;
}
function detectPackageManagerFromInvocationPath(invokedPath) {
if (!invokedPath || typeof invokedPath !== 'string') return null;
const normalized = invokedPath.replace(/\\/g, '/').toLowerCase();
if (normalized.includes('/.bun/bin/')) return 'bun';
if (normalized.includes('/.pnpm/')) return 'pnpm';
if (normalized.includes('/.yarn/')) return 'yarn';
return null;
}
function getPackageManagerCommandCandidates(pm) {
const candidates = [];
if (pm === 'bun') {
const bunExecutable = process.platform === 'win32' ? 'bun.exe' : 'bun';
if (process.env.BUN_INSTALL) {
candidates.push(path.join(process.env.BUN_INSTALL, 'bin', bunExecutable));
}
if (process.env.HOME) {
candidates.push(path.join(process.env.HOME, '.bun', 'bin', bunExecutable));
}
if (process.env.USERPROFILE) {
candidates.push(path.join(process.env.USERPROFILE, '.bun', 'bin', bunExecutable));
}
}
candidates.push(pm);
return [...new Set(candidates.filter(Boolean))];
}
function resolvePackageManagerCommand(pm) {
const candidates = getPackageManagerCommandCandidates(pm);
for (const candidate of candidates) {
if (isCommandAvailable(candidate)) {
return candidate;
}
}
return pm;
}
function quoteCommand(command) {
if (!command) return command;
if (!/\s/.test(command)) return command;
if (process.platform === 'win32') {
return `"${command.replace(/"/g, '""')}"`;
}
return `'${command.replace(/'/g, "'\\''")}'`;
}
function isCommandAvailable(command) {
try {
const result = spawnSync(command, ['--version'], {
@@ -93,6 +182,7 @@ function isCommandAvailable(command) {
function isPackageInstalledWith(pm) {
try {
const pmCommand = resolvePackageManagerCommand(pm);
let args;
switch (pm) {
case 'pnpm':
@@ -108,7 +198,7 @@ function isPackageInstalledWith(pm) {
args = ['list', '-g', '--depth=0', PACKAGE_NAME];
}
const result = spawnSync(pm, args, {
const result = spawnSync(pmCommand, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
@@ -125,15 +215,16 @@ function isPackageInstalledWith(pm) {
* Get the update command for the detected package manager
*/
export function getUpdateCommand(pm = detectPackageManager()) {
const pmCommand = quoteCommand(resolvePackageManagerCommand(pm));
switch (pm) {
case 'pnpm':
return `pnpm add -g ${PACKAGE_NAME}@latest`;
return `${pmCommand} add -g ${PACKAGE_NAME}@latest`;
case 'yarn':
return `yarn global add ${PACKAGE_NAME}@latest`;
return `${pmCommand} global add ${PACKAGE_NAME}@latest`;
case 'bun':
return `bun add -g ${PACKAGE_NAME}@latest`;
return `${pmCommand} add -g ${PACKAGE_NAME}@latest`;
default:
return `npm install -g ${PACKAGE_NAME}@latest`;
return `${pmCommand} install -g ${PACKAGE_NAME}@latest`;
}
}
@@ -259,8 +350,7 @@ export function executeUpdate(pm = detectPackageManager()) {
console.log(`Updating ${PACKAGE_NAME} using ${pm}...`);
console.log(`Running: ${command}`);
const [cmd, ...args] = command.split(' ');
const result = spawnSync(cmd, args, {
const result = spawnSync(command, {
stdio: 'inherit',
shell: true,
});