fix: make in-app web updates apply reliably in containers
This commit is contained in:
@@ -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<InstallWebUpdateResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/openchamber/update-install', {
|
const response = await fetch('/api/openchamber/update-install', {
|
||||||
method: 'POST',
|
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: 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) {
|
} catch (error) {
|
||||||
return { success: false, error: error instanceof Error ? error.message : 'Failed to install update' };
|
return { success: false, error: error instanceof Error ? error.message : 'Failed to install update' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServerRestart(maxAttempts = 30, intervalMs = 2000): Promise<boolean> {
|
async function waitForUpdateApplied(maxAttempts = 40, intervalMs = 2000): Promise<boolean> {
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
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) {
|
if (response.ok) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (data && data.available === false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Server not ready yet
|
// Server may be restarting
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||||
}
|
}
|
||||||
@@ -217,22 +233,20 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server will restart, wait for it to come back
|
if (result.autoRestart) {
|
||||||
setWebUpdateState('restarting');
|
setWebUpdateState('restarting');
|
||||||
|
|
||||||
// Wait a bit for server to shut down
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
setWebUpdateState('reconnecting');
|
setWebUpdateState('reconnecting');
|
||||||
|
|
||||||
const serverBack = await waitForServerRestart();
|
const applied = await waitForUpdateApplied();
|
||||||
|
|
||||||
if (serverBack) {
|
if (applied) {
|
||||||
// Reload the page to get the new version
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
setWebUpdateState('error');
|
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');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6255,6 +6255,36 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const pm = detectPackageManager();
|
const pm = detectPackageManager();
|
||||||
const updateCmd = getUpdateCommand(pm);
|
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
|
// Get current server port for restart
|
||||||
const currentPort = server.address()?.port || 3000;
|
const currentPort = server.address()?.port || 3000;
|
||||||
@@ -6292,6 +6322,7 @@ async function main(options = {}) {
|
|||||||
message: 'Update starting, server will restart shortly',
|
message: 'Update starting, server will restart shortly',
|
||||||
version: updateInfo.version,
|
version: updateInfo.version,
|
||||||
packageManager: pm,
|
packageManager: pm,
|
||||||
|
autoRestart: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Give time for response to be sent
|
// Give time for response to be sent
|
||||||
|
|||||||
@@ -21,29 +21,46 @@ const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber
|
|||||||
export function detectPackageManager() {
|
export function detectPackageManager() {
|
||||||
// Strategy 1: Check user agent (most reliable during install)
|
// Strategy 1: Check user agent (most reliable during install)
|
||||||
const userAgent = process.env.npm_config_user_agent || '';
|
const userAgent = process.env.npm_config_user_agent || '';
|
||||||
if (userAgent.startsWith('pnpm')) return 'pnpm';
|
let hintedPm = null;
|
||||||
if (userAgent.startsWith('yarn')) return 'yarn';
|
if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm';
|
||||||
if (userAgent.startsWith('bun')) return 'bun';
|
else if (userAgent.startsWith('yarn')) hintedPm = 'yarn';
|
||||||
if (userAgent.startsWith('npm')) return 'npm';
|
else if (userAgent.startsWith('bun')) hintedPm = 'bun';
|
||||||
|
else if (userAgent.startsWith('npm')) hintedPm = 'npm';
|
||||||
|
|
||||||
// Strategy 2: Check execpath
|
// Strategy 2: Check execpath
|
||||||
const execPath = process.env.npm_execpath || '';
|
const execPath = process.env.npm_execpath || '';
|
||||||
if (execPath.includes('pnpm')) return 'pnpm';
|
if (!hintedPm) {
|
||||||
if (execPath.includes('yarn')) return 'yarn';
|
if (execPath.includes('pnpm')) hintedPm = 'pnpm';
|
||||||
if (execPath.includes('bun')) return 'bun';
|
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
|
// Strategy 3: Analyze package location for PM-specific patterns
|
||||||
|
if (!hintedPm) {
|
||||||
try {
|
try {
|
||||||
const pkgPath = path.resolve(__dirname, '..', '..');
|
const pkgPath = path.resolve(__dirname, '..', '..');
|
||||||
if (pkgPath.includes('.pnpm')) return 'pnpm';
|
if (pkgPath.includes('.pnpm')) hintedPm = 'pnpm';
|
||||||
if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) return 'yarn';
|
else if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) hintedPm = 'yarn';
|
||||||
if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) return 'bun';
|
else if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) hintedPm = 'bun';
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore path resolution errors
|
// 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
|
// Strategy 4: Check which PM binaries are available and preferred
|
||||||
const pmChecks = [
|
const pmChecks = [
|
||||||
|
{ name: 'npm', check: () => isCommandAvailable('npm') },
|
||||||
{ name: 'pnpm', check: () => isCommandAvailable('pnpm') },
|
{ name: 'pnpm', check: () => isCommandAvailable('pnpm') },
|
||||||
{ name: 'yarn', check: () => isCommandAvailable('yarn') },
|
{ name: 'yarn', check: () => isCommandAvailable('yarn') },
|
||||||
{ name: 'bun', check: () => isCommandAvailable('bun') },
|
{ name: 'bun', check: () => isCommandAvailable('bun') },
|
||||||
|
|||||||
Reference in New Issue
Block a user