diff --git a/README.md b/README.md index 12bc14f5..52b9c87f 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,20 @@ Install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?it ### CLI (Web Server) ```bash -pnpm add -g @openchamber/web +# Quick install (auto-detects your package manager) +curl -fsSL https://raw.githubusercontent.com/btriapitsyn/openchamber/main/scripts/install.sh | bash +# Or install manually +pnpm add -g @openchamber/web # or npm, yarn, bun +``` + +```bash openchamber # Start on port 3000 openchamber --port 8080 # Custom port openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI openchamber stop # Stop server +openchamber update # Update to latest version ``` ### Desktop App (macOS) diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index b1de20c1..dd56dd73 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -274,6 +274,7 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children }) error={update.error} onDownload={update.downloadUpdate} onRestart={update.restartToUpdate} + runtimeType={update.runtimeType} /> diff --git a/packages/ui/src/components/ui/UpdateDialog.tsx b/packages/ui/src/components/ui/UpdateDialog.tsx index df864c21..1b03d1ad 100644 --- a/packages/ui/src/components/ui/UpdateDialog.tsx +++ b/packages/ui/src/components/ui/UpdateDialog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Dialog, DialogContent, @@ -6,10 +6,12 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine } from '@remixicon/react'; +import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; +type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error'; + interface UpdateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -20,10 +22,45 @@ interface UpdateDialogProps { error: string | null; onDownload: () => void; onRestart: () => void; + /** Runtime type to show different UI for desktop vs web */ + runtimeType?: 'desktop' | 'web' | 'vscode' | null; } const GITHUB_RELEASES_URL = 'https://github.com/btriapitsyn/openchamber/releases'; +async function installWebUpdate(): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch('/api/openchamber/update-install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + return { success: false, error: data.error || `Server error: ${response.status}` }; + } + + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to install update' }; + } +} + +async function waitForServerRestart(maxAttempts = 30, intervalMs = 2000): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch('/health', { method: 'GET' }); + if (response.ok) { + return true; + } + } catch { + // Server not ready yet + } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + return false; +} + export const UpdateDialog: React.FC = ({ open, onOpenChange, @@ -34,7 +71,12 @@ export const UpdateDialog: React.FC = ({ error, onDownload, onRestart, + runtimeType = 'desktop', }) => { + const [copied, setCopied] = useState(false); + const [webUpdateState, setWebUpdateState] = useState('idle'); + const [webError, setWebError] = useState(null); + const releaseUrl = info?.version ? `${GITHUB_RELEASES_URL}/tag/v${info.version}` : GITHUB_RELEASES_URL; @@ -43,13 +85,69 @@ export const UpdateDialog: React.FC = ({ ? Math.round((progress.downloaded / progress.total) * 100) : 0; + const isWebRuntime = runtimeType === 'web'; + const updateCommand = info?.updateCommand || 'openchamber update'; + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setWebUpdateState('idle'); + setWebError(null); + } + }, [open]); + + const handleCopyCommand = async () => { + try { + await navigator.clipboard.writeText(updateCommand); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard access denied + } + }; + + const handleWebUpdate = useCallback(async () => { + setWebUpdateState('updating'); + setWebError(null); + + const result = await installWebUpdate(); + + if (!result.success) { + setWebUpdateState('error'); + setWebError(result.error || 'Update failed'); + return; + } + + // Server will restart, wait for it to come back + setWebUpdateState('restarting'); + + // Wait a bit for server to shut down + await new Promise(resolve => setTimeout(resolve, 2000)); + + setWebUpdateState('reconnecting'); + + const serverBack = await waitForServerRestart(); + + if (serverBack) { + // Reload the page to get the new version + window.location.reload(); + } else { + setWebUpdateState('error'); + setWebError('Server did not restart. Please refresh manually or run: openchamber restart'); + } + }, []); + + const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error'; + return ( - + - Update Available + {webUpdateState === 'restarting' || webUpdateState === 'reconnecting' + ? 'Updating...' + : 'Update Available'} @@ -68,7 +166,24 @@ export const UpdateDialog: React.FC = ({ )} - {info?.body && ( + {/* Web update progress */} + {isWebRuntime && isWebUpdating && ( +
+
+ +
+ {webUpdateState === 'updating' && 'Installing update...'} + {webUpdateState === 'restarting' && 'Server restarting...'} + {webUpdateState === 'reconnecting' && 'Waiting for server...'} +
+
+

+ The page will reload automatically when the update is complete. +

+
+ )} + + {info?.body && !isWebUpdating && ( = ({ )} - {downloading && ( + {/* Web runtime: show CLI command only on error as fallback */} + {isWebRuntime && webUpdateState === 'error' && ( +
+
+ + Or update via terminal: +
+
+ + {updateCommand} + + +
+
+ )} + + {/* Desktop runtime: show download progress */} + {!isWebRuntime && downloading && (
Downloading... @@ -107,9 +254,9 @@ export const UpdateDialog: React.FC = ({
)} - {error && ( + {(error || webError) && (
-

{error}

+

{error || webError}

)} @@ -129,7 +276,8 @@ export const UpdateDialog: React.FC = ({ GitHub - {!downloaded && !downloading && ( + {/* Desktop runtime buttons */} + {!isWebRuntime && !downloaded && !downloading && ( )} - {downloading && ( + {!isWebRuntime && downloading && ( )} - {downloaded && ( + {!isWebRuntime && downloaded && ( )} + + {/* Web runtime: Update Now button */} + {isWebRuntime && !isWebUpdating && ( + + )} + + {/* Web runtime: updating state */} + {isWebRuntime && isWebUpdating && ( + + )}
diff --git a/packages/ui/src/hooks/useUpdateCheck.ts b/packages/ui/src/hooks/useUpdateCheck.ts index 7d6613f6..4af72a48 100644 --- a/packages/ui/src/hooks/useUpdateCheck.ts +++ b/packages/ui/src/hooks/useUpdateCheck.ts @@ -4,6 +4,7 @@ import { downloadDesktopUpdate, restartToApplyUpdate, isDesktopRuntime, + isWebRuntime, type UpdateInfo, type UpdateProgress, } from '@/lib/desktop'; @@ -16,6 +17,8 @@ export type UpdateState = { info: UpdateInfo | null; progress: UpdateProgress | null; error: string | null; + /** Runtime type for conditional UI rendering */ + runtimeType: 'desktop' | 'web' | 'vscode' | null; }; export type UseUpdateCheckReturn = UpdateState & { @@ -58,12 +61,49 @@ const createMockUpdate = (config: MockUpdateConfig): UpdateState => ({ }, progress: null, error: null, + runtimeType: 'desktop', }); const shouldMockUpdate = (): boolean => { return getMockConfig() !== null; }; +/** + * Check for web updates via server API + */ +async function checkForWebUpdates(): Promise { + try { + const response = await fetch('/api/openchamber/update-check', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}`); + } + + const data = await response.json(); + return { + available: data.available ?? false, + version: data.version, + currentVersion: data.currentVersion ?? 'unknown', + body: data.body, + packageManager: data.packageManager, + updateCommand: data.updateCommand, + }; + } catch (error) { + console.warn('Failed to check for web updates:', error); + return null; + } +} + +function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null { + if (isDesktopRuntime()) return 'desktop'; + if (isWebRuntime()) return 'web'; + // VSCode doesn't support updates through this mechanism + return null; +} + export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { const [state, setState] = useState({ checking: false, @@ -73,6 +113,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { info: null, progress: null, error: null, + runtimeType: null, }); // Only check mock mode once at startup - no polling @@ -85,6 +126,12 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { return null; }); + // Detect runtime type on mount + useEffect(() => { + const runtime = detectRuntimeType(); + setState((prev) => ({ ...prev, runtimeType: runtime })); + }, []); + const checkForUpdates = useCallback(async () => { if (mockMode) { const config = getMockConfig(); @@ -98,14 +145,23 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { } return; } - if (!isDesktopRuntime()) { + + const runtime = detectRuntimeType(); + if (!runtime) { return; } - setState((prev) => ({ ...prev, checking: true, error: null })); + setState((prev) => ({ ...prev, checking: true, error: null, runtimeType: runtime })); try { - const info = await checkForDesktopUpdates(); + let info: UpdateInfo | null = null; + + if (runtime === 'desktop') { + info = await checkForDesktopUpdates(); + } else if (runtime === 'web') { + info = await checkForWebUpdates(); + } + setState((prev) => ({ ...prev, checking: false, @@ -144,6 +200,8 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { return; } + // For web runtime, there's no download - user runs CLI command + // This is only applicable to desktop if (!isDesktopRuntime() || !state.available) { return; } @@ -169,6 +227,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { return; } + // Only applicable to desktop if (!isDesktopRuntime() || !state.downloaded) { return; } @@ -192,7 +251,8 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => { }, [mockMode]); useEffect(() => { - if (checkOnMount && (isDesktopRuntime() || mockMode)) { + const runtime = detectRuntimeType(); + if (checkOnMount && (runtime === 'desktop' || runtime === 'web' || mockMode)) { const timer = setTimeout(() => { checkForUpdates(); }, 3000); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 7ee766de..7616f254 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -9,6 +9,9 @@ export type UpdateInfo = { currentVersion: string; body?: string; date?: string; + // Web-specific fields + packageManager?: string; + updateCommand?: string; }; export type UpdateProgress = { @@ -71,6 +74,12 @@ export const isVSCodeRuntime = (): boolean => { return apis?.runtime?.isVSCode === true; }; +export const isWebRuntime = (): boolean => { + if (typeof window === "undefined") return false; + // Web runtime: not desktop, not VSCode + return !isDesktopRuntime() && !isVSCodeRuntime(); +}; + export const getDesktopApi = (): DesktopApi | null => { if (!isDesktopRuntime()) { return null; diff --git a/packages/web/README.md b/packages/web/README.md index 64b0febf..08241229 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -5,13 +5,22 @@ Web interface for the [OpenCode](https://opencode.ai) AI coding agent. ## Installation ```bash -npm add -g @openchamber/web +# Quick install (auto-detects your package manager) +curl -fsSL https://raw.githubusercontent.com/btriapitsyn/openchamber/main/scripts/install.sh | bash +# Or install manually +npm add -g @openchamber/web # or pnpm, yarn, bun +``` + +## Usage + +```bash openchamber # Start on port 3000 openchamber --port 8080 # Custom port openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI openchamber stop # Stop server +openchamber update # Update to latest version ``` ## Prerequisites diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 087bcced..402da671 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -94,6 +94,7 @@ COMMANDS: stop Stop running instance(s) restart Stop and start the server status Show server status + update Check for and install updates OPTIONS: -p, --port Web server port (default: ${DEFAULT_PORT}) @@ -112,6 +113,7 @@ EXAMPLES: openchamber stop # Stop all running instances openchamber stop --port 3000 # Stop specific instance openchamber status # Check status + openchamber update # Update to latest version `); } @@ -245,6 +247,12 @@ async function getPidFilePath(port) { return path.join(tmpDir, `openchamber-${port}.pid`); } +async function getInstanceFilePath(port) { + const os = await import('os'); + const tmpDir = os.tmpdir(); + return path.join(tmpDir, `openchamber-${port}.json`); +} + function readPidFile(pidFilePath) { try { const content = fs.readFileSync(pidFilePath, 'utf8').trim(); @@ -276,6 +284,50 @@ function removePidFile(pidFilePath) { } } +/** + * Read stored instance options (port, daemon, uiPassword) + */ +function readInstanceOptions(instanceFilePath) { + try { + const content = fs.readFileSync(instanceFilePath, 'utf8'); + return JSON.parse(content); + } catch (error) { + return null; + } +} + +/** + * Write instance options for restart/update to reuse + */ +function writeInstanceOptions(instanceFilePath, options) { + try { + // Only store non-sensitive restart-relevant options + const toStore = { + port: options.port, + daemon: options.daemon || false, + // Store password existence but not value - will use env var + hasUiPassword: typeof options.uiPassword === 'string', + }; + // For daemon mode, we need to store the password to restart properly + if (options.daemon && typeof options.uiPassword === 'string') { + toStore.uiPassword = options.uiPassword; + } + fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2)); + } catch (error) { + console.warn(`Warning: Could not write instance file: ${error.message}`); + } +} + +function removeInstanceFile(instanceFilePath) { + try { + if (fs.existsSync(instanceFilePath)) { + fs.unlinkSync(instanceFilePath); + } + } catch (error) { + // Ignore + } +} + function isProcessRunning(pid) { try { process.kill(pid, 0); @@ -288,6 +340,7 @@ function isProcessRunning(pid) { const commands = { async serve(options) { const pidFilePath = await getPidFilePath(options.port); + const instanceFilePath = await getInstanceFilePath(options.port); const existingPid = readPidFile(pidFilePath); if (existingPid && isProcessRunning(existingPid)) { @@ -323,6 +376,7 @@ const commands = { setTimeout(() => { if (isProcessRunning(child.pid)) { writePidFile(pidFilePath, child.pid); + writeInstanceOptions(instanceFilePath, options); console.log(`OpenChamber started in daemon mode on port ${options.port}`); console.log(`PID: ${child.pid}`); console.log(`Visit: http://localhost:${options.port}`); @@ -338,6 +392,7 @@ const commands = { if (typeof options.uiPassword === 'string') { process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword; } + writeInstanceOptions(instanceFilePath, options); const { startWebUiServer } = await import(serverPath); await startWebUiServer({ port: options.port, @@ -365,10 +420,12 @@ const commands = { const pid = readPidFile(pidFilePath); if (pid && isProcessRunning(pid)) { - runningInstances.push({ port, pid, pidFilePath }); + const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`); + runningInstances.push({ port, pid, pidFilePath, instanceFilePath }); } else { removePidFile(pidFilePath); + removeInstanceFile(path.join(tmpDir, `openchamber-${port}.json`)); } } } @@ -404,12 +461,14 @@ const commands = { if (!isProcessRunning(targetInstance.pid)) { clearInterval(checkShutdown); removePidFile(targetInstance.pidFilePath); + removeInstanceFile(targetInstance.instanceFilePath); console.log('OpenChamber stopped successfully'); } else if (attempts >= maxAttempts) { clearInterval(checkShutdown); console.log('Force killing process...'); process.kill(targetInstance.pid, 'SIGKILL'); removePidFile(targetInstance.pidFilePath); + removeInstanceFile(targetInstance.instanceFilePath); console.log('OpenChamber force stopped'); } }, 500); @@ -437,6 +496,7 @@ const commands = { if (!isProcessRunning(instance.pid)) { clearInterval(checkShutdown); removePidFile(instance.pidFilePath); + removeInstanceFile(instance.instanceFilePath); console.log(` Port ${instance.port} stopped successfully`); resolve(true); } else if (attempts >= maxAttempts) { @@ -445,6 +505,7 @@ const commands = { try { process.kill(instance.pid, 'SIGKILL'); removePidFile(instance.pidFilePath); + removeInstanceFile(instance.instanceFilePath); console.log(` Port ${instance.port} force stopped`); } catch (e) { @@ -464,11 +525,95 @@ const commands = { }, async restart(options) { - await commands.stop(options); - await commands.serve(options); + const os = await import('os'); + const tmpDir = os.tmpdir(); + + // Find running instances to get their stored options + let instancesToRestart = []; + + try { + const files = fs.readdirSync(tmpDir); + const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid')); + + for (const file of pidFiles) { + const port = parseInt(file.replace('openchamber-', '').replace('.pid', '')); + if (!isNaN(port)) { + const pidFilePath = path.join(tmpDir, file); + const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`); + const pid = readPidFile(pidFilePath); + + if (pid && isProcessRunning(pid)) { + const storedOptions = readInstanceOptions(instanceFilePath); + instancesToRestart.push({ + port, + pid, + pidFilePath, + instanceFilePath, + storedOptions: storedOptions || { port, daemon: false }, + }); + } + } + } + } catch (error) { + // Ignore + } + + const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p'); + + if (instancesToRestart.length === 0) { + console.log('No running OpenChamber instances to restart'); + console.log('Use "openchamber serve" to start a new instance'); + return; + } + + if (portWasSpecified) { + // Restart specific instance + const target = instancesToRestart.find(inst => inst.port === options.port); + if (!target) { + console.log(`No OpenChamber instance found running on port ${options.port}`); + return; + } + instancesToRestart = [target]; + } + + for (const instance of instancesToRestart) { + console.log(`Restarting OpenChamber on port ${instance.port}...`); + + // Merge stored options with any explicitly provided options + const restartOptions = { + ...instance.storedOptions, + // CLI-provided options override stored ones + ...(portWasSpecified ? { port: options.port } : {}), + ...(process.argv.includes('--daemon') || process.argv.includes('-d') ? { daemon: options.daemon } : {}), + ...(process.argv.includes('--ui-password') ? { uiPassword: options.uiPassword } : {}), + }; + + // Stop the instance + try { + process.kill(instance.pid, 'SIGTERM'); + // Wait for it to stop + let attempts = 0; + while (isProcessRunning(instance.pid) && attempts < 20) { + await new Promise(resolve => setTimeout(resolve, 250)); + attempts++; + } + if (isProcessRunning(instance.pid)) { + process.kill(instance.pid, 'SIGKILL'); + } + removePidFile(instance.pidFilePath); + } catch (error) { + console.warn(`Warning: Could not stop instance: ${error.message}`); + } + + // Small delay before restart + await new Promise(resolve => setTimeout(resolve, 500)); + + // Start with merged options + await commands.serve(restartOptions); + } }, - async status(options) { + async status() { const os = await import('os'); const tmpDir = os.tmpdir(); @@ -527,6 +672,131 @@ const commands = { } }, + async update() { + const os = await import('os'); + const tmpDir = os.tmpdir(); + const packageManagerPath = path.join(__dirname, '..', 'server', 'lib', 'package-manager.js'); + const { + checkForUpdates, + executeUpdate, + detectPackageManager, + getCurrentVersion, + } = await import(packageManagerPath); + + // Check for running instances before update + let runningInstances = []; + try { + const files = fs.readdirSync(tmpDir); + const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid')); + + for (const file of pidFiles) { + const port = parseInt(file.replace('openchamber-', '').replace('.pid', '')); + if (!isNaN(port)) { + const pidFilePath = path.join(tmpDir, file); + const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`); + const pid = readPidFile(pidFilePath); + + if (pid && isProcessRunning(pid)) { + const storedOptions = readInstanceOptions(instanceFilePath); + runningInstances.push({ + port, + pid, + pidFilePath, + instanceFilePath, + storedOptions: storedOptions || { port, daemon: true }, + }); + } + } + } + } catch (error) { + // Ignore + } + + console.log('Checking for updates...'); + console.log(`Current version: ${getCurrentVersion()}`); + + const updateInfo = await checkForUpdates(); + + if (updateInfo.error) { + console.error(`Error: ${updateInfo.error}`); + process.exit(1); + } + + if (!updateInfo.available) { + console.log('\nYou are running the latest version.'); + return; + } + + console.log(`\nNew version available: ${updateInfo.version}`); + + if (updateInfo.body) { + console.log('\nChangelog:'); + console.log('─'.repeat(40)); + // Simple formatting for CLI + const formatted = updateInfo.body + .replace(/^## \[(\d+\.\d+\.\d+)\] - \d{4}-\d{2}-\d{2}/gm, '\nv$1') + .replace(/^### /gm, '\n') + .replace(/^- /gm, ' • '); + console.log(formatted); + console.log('─'.repeat(40)); + } + + // Stop running instances before update + if (runningInstances.length > 0) { + console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`); + for (const instance of runningInstances) { + try { + process.kill(instance.pid, 'SIGTERM'); + let attempts = 0; + while (isProcessRunning(instance.pid) && attempts < 20) { + await new Promise(resolve => setTimeout(resolve, 250)); + attempts++; + } + if (isProcessRunning(instance.pid)) { + process.kill(instance.pid, 'SIGKILL'); + } + removePidFile(instance.pidFilePath); + console.log(` Stopped instance on port ${instance.port}`); + } catch (error) { + console.warn(` Warning: Could not stop instance on port ${instance.port}`); + } + } + } + + const pm = detectPackageManager(); + console.log(`\nDetected package manager: ${pm}`); + console.log('Installing update...\n'); + + const result = executeUpdate(pm); + + if (result.success) { + console.log('\nUpdate successful!'); + + // Restart previously running instances + if (runningInstances.length > 0) { + console.log(`\nRestarting ${runningInstances.length} instance(s)...`); + for (const instance of runningInstances) { + try { + // Force daemon mode for restart after update + const restartOptions = { + ...instance.storedOptions, + daemon: true, + }; + await commands.serve(restartOptions); + console.log(` Restarted instance on port ${instance.port}`); + } catch (error) { + console.error(` Failed to restart instance on port ${instance.port}: ${error.message}`); + console.log(` Run manually: openchamber serve --port ${instance.port} --daemon`); + } + } + } + } else { + console.error('\nUpdate failed.'); + console.error(`Exit code: ${result.exitCode}`); + process.exit(1); + } + }, + }; async function main() { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 80035c63..866ec667 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1605,6 +1605,112 @@ async function main(options = {}) { app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next)); + app.get('/api/openchamber/update-check', async (_req, res) => { + try { + const { checkForUpdates } = await import('./lib/package-manager.js'); + const updateInfo = await checkForUpdates(); + res.json(updateInfo); + } catch (error) { + console.error('Failed to check for updates:', error); + res.status(500).json({ + available: false, + error: error instanceof Error ? error.message : 'Failed to check for updates', + }); + } + }); + + app.post('/api/openchamber/update-install', async (_req, res) => { + try { + const { spawn: spawnChild } = await import('child_process'); + const { + checkForUpdates, + getUpdateCommand, + detectPackageManager, + } = await import('./lib/package-manager.js'); + + // Verify update is available + const updateInfo = await checkForUpdates(); + if (!updateInfo.available) { + return res.status(400).json({ error: 'No update available' }); + } + + const pm = detectPackageManager(); + const updateCmd = getUpdateCommand(pm); + + // Get current server port for restart + const currentPort = server.address()?.port || 3000; + + // Try to read stored instance options for restart + const tmpDir = os.tmpdir(); + const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`); + let storedOptions = { port: currentPort, daemon: true }; + try { + const content = fs.readFileSync(instanceFilePath, 'utf8'); + storedOptions = JSON.parse(content); + } catch { + // Use defaults + } + + // Build restart command with stored options + let restartCmd = `openchamber serve --port ${storedOptions.port} --daemon`; + if (storedOptions.uiPassword) { + // Escape password for shell + const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); + restartCmd += ` --ui-password '${escapedPw}'`; + } + + // Respond immediately - update will happen after response + res.json({ + success: true, + message: 'Update starting, server will restart shortly', + version: updateInfo.version, + packageManager: pm, + }); + + // Give time for response to be sent + setTimeout(() => { + console.log(`\nInstalling update using ${pm}...`); + console.log(`Running: ${updateCmd}`); + + // Create a script that will: + // 1. Wait for current process to exit + // 2. Run the update + // 3. Restart the server with original options + const script = ` + sleep 2 + ${updateCmd} + if [ $? -eq 0 ]; then + echo "Update successful, restarting OpenChamber..." + ${restartCmd} + else + echo "Update failed" + exit 1 + fi + `; + + // Spawn detached shell to run update after we exit + const child = spawnChild('sh', ['-c', script], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + child.unref(); + + console.log('Update process spawned, shutting down server...'); + + // Give child process time to start, then exit + setTimeout(() => { + process.exit(0); + }, 500); + }, 500); + } catch (error) { + console.error('Failed to install update:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to install update', + }); + } + }); + app.get('/api/openchamber/models-metadata', async (req, res) => { const now = Date.now(); diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js new file mode 100644 index 00000000..1c506287 --- /dev/null +++ b/packages/web/server/lib/package-manager.js @@ -0,0 +1,255 @@ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PACKAGE_NAME = '@openchamber/web'; +const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; +const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; + +/** + * 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() { + // Strategy 1: Check user agent (most reliable during install) + const userAgent = process.env.npm_config_user_agent || ''; + if (userAgent.startsWith('pnpm')) return 'pnpm'; + if (userAgent.startsWith('yarn')) return 'yarn'; + if (userAgent.startsWith('bun')) return 'bun'; + if (userAgent.startsWith('npm')) return 'npm'; + + // Strategy 2: Check execpath + const execPath = process.env.npm_execpath || ''; + if (execPath.includes('pnpm')) return 'pnpm'; + if (execPath.includes('yarn')) return 'yarn'; + if (execPath.includes('bun')) return 'bun'; + + // Strategy 3: Analyze package location for PM-specific patterns + try { + const pkgPath = path.resolve(__dirname, '..', '..'); + if (pkgPath.includes('.pnpm')) return 'pnpm'; + if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) return 'yarn'; + if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) return 'bun'; + } catch { + // Ignore path resolution errors + } + + // Strategy 4: Check which PM binaries are available and preferred + const pmChecks = [ + { name: 'pnpm', check: () => isCommandAvailable('pnpm') }, + { name: 'yarn', check: () => isCommandAvailable('yarn') }, + { name: 'bun', check: () => isCommandAvailable('bun') }, + ]; + + for (const { name, check } of pmChecks) { + if (check()) { + // Verify this PM actually has the package installed globally + if (isPackageInstalledWith(name)) { + return name; + } + } + } + + return 'npm'; +} + +function isCommandAvailable(command) { + try { + const result = spawnSync(command, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + return result.status === 0; + } catch { + return false; + } +} + +function isPackageInstalledWith(pm) { + try { + let args; + switch (pm) { + case 'pnpm': + args = ['list', '-g', '--depth=0', PACKAGE_NAME]; + break; + case 'yarn': + args = ['global', 'list', '--depth=0']; + break; + case 'bun': + args = ['pm', 'ls', '-g']; + break; + default: + args = ['list', '-g', '--depth=0', PACKAGE_NAME]; + } + + const result = spawnSync(pm, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10000, + }); + + if (result.status !== 0) return false; + return result.stdout.includes(PACKAGE_NAME) || result.stdout.includes('openchamber'); + } catch { + return false; + } +} + +/** + * Get the update command for the detected package manager + */ +export function getUpdateCommand(pm = detectPackageManager()) { + switch (pm) { + case 'pnpm': + return `pnpm add -g ${PACKAGE_NAME}@latest`; + case 'yarn': + return `yarn global add ${PACKAGE_NAME}@latest`; + case 'bun': + return `bun add -g ${PACKAGE_NAME}@latest`; + default: + return `npm install -g ${PACKAGE_NAME}@latest`; + } +} + +/** + * Get current installed version from package.json + */ +export function getCurrentVersion() { + try { + const pkgPath = path.resolve(__dirname, '..', '..', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return pkg.version || 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Fetch latest version from npm registry + */ +export async function getLatestVersion() { + try { + const response = await fetch(NPM_REGISTRY_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error(`Registry responded with ${response.status}`); + } + + const data = await response.json(); + return data['dist-tags']?.latest || null; + } catch (error) { + console.warn('Failed to fetch latest version from npm:', error.message); + return null; + } +} + +/** + * Parse semver version to numeric for comparison + */ +function parseVersion(version) { + const parts = version.replace(/^v/, '').split('.').map(Number); + return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0); +} + +/** + * Fetch changelog notes between versions + */ +export async function fetchChangelogNotes(fromVersion, toVersion) { + try { + const response = await fetch(CHANGELOG_URL, { + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return undefined; + + const changelog = await response.text(); + const sections = changelog.split(/^## /m).slice(1); + + const fromNum = parseVersion(fromVersion); + const toNum = parseVersion(toVersion); + + const relevantSections = sections.filter((section) => { + const match = section.match(/^\[(\d+\.\d+\.\d+)\]/); + if (!match) return false; + const ver = parseVersion(match[1]); + return ver > fromNum && ver <= toNum; + }); + + if (relevantSections.length === 0) return undefined; + + return relevantSections + .map((s) => '## ' + s.trim()) + .join('\n\n'); + } catch { + return undefined; + } +} + +/** + * Check for updates and return update info + */ +export async function checkForUpdates() { + const currentVersion = getCurrentVersion(); + const latestVersion = await getLatestVersion(); + + if (!latestVersion || currentVersion === 'unknown') { + return { + available: false, + currentVersion, + error: 'Unable to determine versions', + }; + } + + const currentNum = parseVersion(currentVersion); + const latestNum = parseVersion(latestVersion); + const available = latestNum > currentNum; + + const pm = detectPackageManager(); + + let changelog; + if (available) { + changelog = await fetchChangelogNotes(currentVersion, latestVersion); + } + + return { + available, + version: latestVersion, + currentVersion, + body: changelog, + packageManager: pm, + // Show our CLI command, not raw package manager command + updateCommand: 'openchamber update', + }; +} + +/** + * Execute the update (used by CLI) + */ +export function executeUpdate(pm = detectPackageManager()) { + const command = getUpdateCommand(pm); + console.log(`Updating ${PACKAGE_NAME} using ${pm}...`); + console.log(`Running: ${command}`); + + const [cmd, ...args] = command.split(' '); + const result = spawnSync(cmd, args, { + stdio: 'inherit', + shell: true, + }); + + return { + success: result.status === 0, + exitCode: result.status, + }; +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..83b8250e --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# OpenChamber Install Script +# Usage: curl -fsSL https://raw.githubusercontent.com/btriapitsyn/openchamber/main/scripts/install.sh | bash + +set -e + +PACKAGE_NAME="@openchamber/web" +MIN_NODE_VERSION=20 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +info() { + echo -e "${BLUE}info${NC} $1" +} + +success() { + echo -e "${GREEN}success${NC} $1" +} + +warn() { + echo -e "${YELLOW}warn${NC} $1" +} + +error() { + echo -e "${RED}error${NC} $1" +} + +# Check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Get Node.js major version +get_node_version() { + if command_exists node; then + node -v | sed 's/v//' | cut -d. -f1 + else + echo "0" + fi +} + +# Detect preferred package manager +detect_package_manager() { + # Check if running inside an npm/pnpm/yarn/bun context + if [ -n "$npm_config_user_agent" ]; then + case "$npm_config_user_agent" in + pnpm*) echo "pnpm"; return ;; + yarn*) echo "yarn"; return ;; + bun*) echo "bun"; return ;; + npm*) echo "npm"; return ;; + esac + fi + + # Check for lockfiles in current directory (user preference) + if [ -f "pnpm-lock.yaml" ]; then + echo "pnpm"; return + elif [ -f "yarn.lock" ]; then + echo "yarn"; return + elif [ -f "bun.lockb" ]; then + echo "bun"; return + elif [ -f "package-lock.json" ]; then + echo "npm"; return + fi + + # Check which package managers are available (prefer pnpm > bun > yarn > npm) + if command_exists pnpm; then + echo "pnpm" + elif command_exists bun; then + echo "bun" + elif command_exists yarn; then + echo "yarn" + elif command_exists npm; then + echo "npm" + else + echo "none" + fi +} + +# Get install command for package manager +get_install_command() { + local pm=$1 + case "$pm" in + pnpm) echo "pnpm add -g $PACKAGE_NAME" ;; + yarn) echo "yarn global add $PACKAGE_NAME" ;; + bun) echo "bun add -g $PACKAGE_NAME" ;; + npm) echo "npm install -g $PACKAGE_NAME" ;; + *) echo "" ;; + esac +} + +# Install Node.js suggestion +suggest_node_install() { + echo "" + error "Node.js $MIN_NODE_VERSION+ is required but not found." + echo "" + echo "Install Node.js using one of these methods:" + echo "" + + if [[ "$OSTYPE" == "darwin"* ]]; then + echo " Using Homebrew:" + echo " brew install node" + echo "" + fi + + echo " Using nvm (recommended):" + echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash" + echo " nvm install $MIN_NODE_VERSION" + echo "" + echo " Using fnm:" + echo " curl -fsSL https://fnm.vercel.app/install | bash" + echo " fnm install $MIN_NODE_VERSION" + echo "" + echo " Official installer:" + echo " https://nodejs.org/" + echo "" + exit 1 +} + +# Install package manager suggestion +suggest_pm_install() { + echo "" + error "No package manager found (npm, pnpm, yarn, or bun)." + echo "" + echo "Install a package manager:" + echo "" + echo " npm (comes with Node.js):" + echo " Install Node.js from https://nodejs.org/" + echo "" + echo " pnpm (recommended):" + echo " curl -fsSL https://get.pnpm.io/install.sh | sh -" + echo "" + echo " bun:" + echo " curl -fsSL https://bun.sh/install | bash" + echo "" + echo " yarn:" + echo " npm install -g yarn" + echo "" + exit 1 +} + +main() { + echo "" + echo " ╭───────────────────────────────────╮" + echo " │ │" + echo " │ OpenChamber Installer │" + echo " │ Web interface for OpenCode │" + echo " │ │" + echo " ╰───────────────────────────────────╯" + echo "" + + # Check Node.js + info "Checking Node.js..." + NODE_VERSION=$(get_node_version) + + if [ "$NODE_VERSION" -lt "$MIN_NODE_VERSION" ]; then + if [ "$NODE_VERSION" -eq "0" ]; then + suggest_node_install + else + error "Node.js $MIN_NODE_VERSION+ required, found v$NODE_VERSION" + suggest_node_install + fi + fi + success "Node.js v$NODE_VERSION found" + + # Detect package manager + info "Detecting package manager..." + PM=$(detect_package_manager) + + if [ "$PM" = "none" ]; then + suggest_pm_install + fi + success "Using $PM" + + # Get install command + INSTALL_CMD=$(get_install_command "$PM") + + if [ -z "$INSTALL_CMD" ]; then + error "Could not determine install command" + exit 1 + fi + + # Install + echo "" + info "Installing OpenChamber..." + echo " Running: $INSTALL_CMD" + echo "" + + if eval "$INSTALL_CMD"; then + echo "" + success "OpenChamber installed successfully!" + echo "" + echo " Get started:" + echo " openchamber # Start server on port 3000" + echo " openchamber --help # Show all options" + echo "" + echo " Prerequisites:" + echo " Make sure OpenCode is running: opencode serve" + echo "" + else + echo "" + error "Installation failed" + echo "" + echo " Try running manually:" + echo " $INSTALL_CMD" + echo "" + echo " If you get permission errors, see:" + echo " https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally" + echo "" + exit 1 + fi +} + +main "$@"