feat: add web update check and installation functionality

This commit is contained in:
Bohdan Triapitsyn
2025-12-19 19:57:18 +02:00
parent bddb174bd3
commit 6323050461
10 changed files with 1137 additions and 21 deletions
+8 -1
View File
@@ -59,13 +59,20 @@ Install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?it
### CLI (Web Server) ### CLI (Web Server)
```bash ```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 # Start on port 3000
openchamber --port 8080 # Custom port openchamber --port 8080 # Custom port
openchamber --daemon # Background mode openchamber --daemon # Background mode
openchamber --ui-password secret # Password-protect UI openchamber --ui-password secret # Password-protect UI
openchamber stop # Stop server openchamber stop # Stop server
openchamber update # Update to latest version
``` ```
### Desktop App (macOS) ### Desktop App (macOS)
@@ -274,6 +274,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
error={update.error} error={update.error}
onDownload={update.downloadUpdate} onDownload={update.downloadUpdate}
onRestart={update.restartToUpdate} onRestart={update.restartToUpdate}
runtimeType={update.runtimeType}
/> />
</div> </div>
</aside> </aside>
+192 -11
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -6,10 +6,12 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; 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 { cn } from '@/lib/utils';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
interface UpdateDialogProps { interface UpdateDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -20,10 +22,45 @@ interface UpdateDialogProps {
error: string | null; error: string | null;
onDownload: () => void; onDownload: () => void;
onRestart: () => 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'; 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<boolean> {
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<UpdateDialogProps> = ({ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
open, open,
onOpenChange, onOpenChange,
@@ -34,7 +71,12 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
error, error,
onDownload, onDownload,
onRestart, onRestart,
runtimeType = 'desktop',
}) => { }) => {
const [copied, setCopied] = useState(false);
const [webUpdateState, setWebUpdateState] = useState<WebUpdateState>('idle');
const [webError, setWebError] = useState<string | null>(null);
const releaseUrl = info?.version const releaseUrl = info?.version
? `${GITHUB_RELEASES_URL}/tag/v${info.version}` ? `${GITHUB_RELEASES_URL}/tag/v${info.version}`
: GITHUB_RELEASES_URL; : GITHUB_RELEASES_URL;
@@ -43,13 +85,69 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
? Math.round((progress.downloaded / progress.total) * 100) ? Math.round((progress.downloaded / progress.total) * 100)
: 0; : 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={isWebUpdating ? undefined : onOpenChange}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<RiDownloadCloudLine className="h-5 w-5 text-primary" /> <RiDownloadCloudLine className="h-5 w-5 text-primary" />
Update Available {webUpdateState === 'restarting' || webUpdateState === 'reconnecting'
? 'Updating...'
: 'Update Available'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -68,7 +166,24 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
)} )}
{info?.body && ( {/* Web update progress */}
{isWebRuntime && isWebUpdating && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<RiLoaderLine className="h-5 w-5 animate-spin text-primary" />
<div className="text-sm">
{webUpdateState === 'updating' && 'Installing update...'}
{webUpdateState === 'restarting' && 'Server restarting...'}
{webUpdateState === 'reconnecting' && 'Waiting for server...'}
</div>
</div>
<p className="text-xs text-muted-foreground">
The page will reload automatically when the update is complete.
</p>
</div>
)}
{info?.body && !isWebUpdating && (
<ScrollableOverlay <ScrollableOverlay
className="max-h-48 rounded-md border border-border bg-muted/30 p-3" className="max-h-48 rounded-md border border-border bg-muted/30 p-3"
fillContainer={false} fillContainer={false}
@@ -92,7 +207,39 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</ScrollableOverlay> </ScrollableOverlay>
)} )}
{downloading && ( {/* Web runtime: show CLI command only on error as fallback */}
{isWebRuntime && webUpdateState === 'error' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RiTerminalLine className="h-4 w-4" />
<span>Or update via terminal:</span>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm text-foreground overflow-x-auto">
{updateCommand}
</code>
<button
onClick={handleCopyCommand}
className={cn(
'flex items-center justify-center p-2 rounded-md',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'transition-colors',
copied && 'text-primary'
)}
title={copied ? 'Copied!' : 'Copy command'}
>
{copied ? (
<RiCheckLine className="h-4 w-4" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
{/* Desktop runtime: show download progress */}
{!isWebRuntime && downloading && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Downloading...</span> <span className="text-muted-foreground">Downloading...</span>
@@ -107,9 +254,9 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
)} )}
{error && ( {(error || webError) && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg"> <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error || webError}</p>
</div> </div>
)} )}
@@ -129,7 +276,8 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
GitHub GitHub
</a> </a>
{!downloaded && !downloading && ( {/* Desktop runtime buttons */}
{!isWebRuntime && !downloaded && !downloading && (
<button <button
onClick={onDownload} onClick={onDownload}
className={cn( className={cn(
@@ -145,7 +293,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</button> </button>
)} )}
{downloading && ( {!isWebRuntime && downloading && (
<button <button
disabled disabled
className={cn( className={cn(
@@ -160,7 +308,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
</button> </button>
)} )}
{downloaded && ( {!isWebRuntime && downloaded && (
<button <button
onClick={onRestart} onClick={onRestart}
className={cn( className={cn(
@@ -175,6 +323,39 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
Restart to Update Restart to Update
</button> </button>
)} )}
{/* Web runtime: Update Now button */}
{isWebRuntime && !isWebUpdating && (
<button
onClick={handleWebUpdate}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'transition-colors'
)}
>
<RiDownloadLine className="h-4 w-4" />
Update Now
</button>
)}
{/* Web runtime: updating state */}
{isWebRuntime && isWebUpdating && (
<button
disabled
className={cn(
'flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-md',
'text-sm font-medium',
'bg-primary/50 text-primary-foreground',
'cursor-not-allowed'
)}
>
<RiLoaderLine className="h-4 w-4 animate-spin" />
Updating...
</button>
)}
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
+64 -4
View File
@@ -4,6 +4,7 @@ import {
downloadDesktopUpdate, downloadDesktopUpdate,
restartToApplyUpdate, restartToApplyUpdate,
isDesktopRuntime, isDesktopRuntime,
isWebRuntime,
type UpdateInfo, type UpdateInfo,
type UpdateProgress, type UpdateProgress,
} from '@/lib/desktop'; } from '@/lib/desktop';
@@ -16,6 +17,8 @@ export type UpdateState = {
info: UpdateInfo | null; info: UpdateInfo | null;
progress: UpdateProgress | null; progress: UpdateProgress | null;
error: string | null; error: string | null;
/** Runtime type for conditional UI rendering */
runtimeType: 'desktop' | 'web' | 'vscode' | null;
}; };
export type UseUpdateCheckReturn = UpdateState & { export type UseUpdateCheckReturn = UpdateState & {
@@ -58,12 +61,49 @@ const createMockUpdate = (config: MockUpdateConfig): UpdateState => ({
}, },
progress: null, progress: null,
error: null, error: null,
runtimeType: 'desktop',
}); });
const shouldMockUpdate = (): boolean => { const shouldMockUpdate = (): boolean => {
return getMockConfig() !== null; return getMockConfig() !== null;
}; };
/**
* Check for web updates via server API
*/
async function checkForWebUpdates(): Promise<UpdateInfo | null> {
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 => { export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
const [state, setState] = useState<UpdateState>({ const [state, setState] = useState<UpdateState>({
checking: false, checking: false,
@@ -73,6 +113,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
info: null, info: null,
progress: null, progress: null,
error: null, error: null,
runtimeType: null,
}); });
// Only check mock mode once at startup - no polling // Only check mock mode once at startup - no polling
@@ -85,6 +126,12 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
return null; return null;
}); });
// Detect runtime type on mount
useEffect(() => {
const runtime = detectRuntimeType();
setState((prev) => ({ ...prev, runtimeType: runtime }));
}, []);
const checkForUpdates = useCallback(async () => { const checkForUpdates = useCallback(async () => {
if (mockMode) { if (mockMode) {
const config = getMockConfig(); const config = getMockConfig();
@@ -98,14 +145,23 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
} }
return; return;
} }
if (!isDesktopRuntime()) {
const runtime = detectRuntimeType();
if (!runtime) {
return; return;
} }
setState((prev) => ({ ...prev, checking: true, error: null })); setState((prev) => ({ ...prev, checking: true, error: null, runtimeType: runtime }));
try { 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) => ({ setState((prev) => ({
...prev, ...prev,
checking: false, checking: false,
@@ -144,6 +200,8 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
return; return;
} }
// For web runtime, there's no download - user runs CLI command
// This is only applicable to desktop
if (!isDesktopRuntime() || !state.available) { if (!isDesktopRuntime() || !state.available) {
return; return;
} }
@@ -169,6 +227,7 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
return; return;
} }
// Only applicable to desktop
if (!isDesktopRuntime() || !state.downloaded) { if (!isDesktopRuntime() || !state.downloaded) {
return; return;
} }
@@ -192,7 +251,8 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
}, [mockMode]); }, [mockMode]);
useEffect(() => { useEffect(() => {
if (checkOnMount && (isDesktopRuntime() || mockMode)) { const runtime = detectRuntimeType();
if (checkOnMount && (runtime === 'desktop' || runtime === 'web' || mockMode)) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
checkForUpdates(); checkForUpdates();
}, 3000); }, 3000);
+9
View File
@@ -9,6 +9,9 @@ export type UpdateInfo = {
currentVersion: string; currentVersion: string;
body?: string; body?: string;
date?: string; date?: string;
// Web-specific fields
packageManager?: string;
updateCommand?: string;
}; };
export type UpdateProgress = { export type UpdateProgress = {
@@ -71,6 +74,12 @@ export const isVSCodeRuntime = (): boolean => {
return apis?.runtime?.isVSCode === true; 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 => { export const getDesktopApi = (): DesktopApi | null => {
if (!isDesktopRuntime()) { if (!isDesktopRuntime()) {
return null; return null;
+10 -1
View File
@@ -5,13 +5,22 @@ Web interface for the [OpenCode](https://opencode.ai) AI coding agent.
## Installation ## Installation
```bash ```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 # Start on port 3000
openchamber --port 8080 # Custom port openchamber --port 8080 # Custom port
openchamber --daemon # Background mode openchamber --daemon # Background mode
openchamber --ui-password secret # Password-protect UI openchamber --ui-password secret # Password-protect UI
openchamber stop # Stop server openchamber stop # Stop server
openchamber update # Update to latest version
``` ```
## Prerequisites ## Prerequisites
+274 -4
View File
@@ -94,6 +94,7 @@ COMMANDS:
stop Stop running instance(s) stop Stop running instance(s)
restart Stop and start the server restart Stop and start the server
status Show server status status Show server status
update Check for and install updates
OPTIONS: OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT}) -p, --port Web server port (default: ${DEFAULT_PORT})
@@ -112,6 +113,7 @@ EXAMPLES:
openchamber stop # Stop all running instances openchamber stop # Stop all running instances
openchamber stop --port 3000 # Stop specific instance openchamber stop --port 3000 # Stop specific instance
openchamber status # Check status 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`); 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) { function readPidFile(pidFilePath) {
try { try {
const content = fs.readFileSync(pidFilePath, 'utf8').trim(); 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) { function isProcessRunning(pid) {
try { try {
process.kill(pid, 0); process.kill(pid, 0);
@@ -288,6 +340,7 @@ function isProcessRunning(pid) {
const commands = { const commands = {
async serve(options) { async serve(options) {
const pidFilePath = await getPidFilePath(options.port); const pidFilePath = await getPidFilePath(options.port);
const instanceFilePath = await getInstanceFilePath(options.port);
const existingPid = readPidFile(pidFilePath); const existingPid = readPidFile(pidFilePath);
if (existingPid && isProcessRunning(existingPid)) { if (existingPid && isProcessRunning(existingPid)) {
@@ -323,6 +376,7 @@ const commands = {
setTimeout(() => { setTimeout(() => {
if (isProcessRunning(child.pid)) { if (isProcessRunning(child.pid)) {
writePidFile(pidFilePath, child.pid); writePidFile(pidFilePath, child.pid);
writeInstanceOptions(instanceFilePath, options);
console.log(`OpenChamber started in daemon mode on port ${options.port}`); console.log(`OpenChamber started in daemon mode on port ${options.port}`);
console.log(`PID: ${child.pid}`); console.log(`PID: ${child.pid}`);
console.log(`Visit: http://localhost:${options.port}`); console.log(`Visit: http://localhost:${options.port}`);
@@ -338,6 +392,7 @@ const commands = {
if (typeof options.uiPassword === 'string') { if (typeof options.uiPassword === 'string') {
process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword; process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword;
} }
writeInstanceOptions(instanceFilePath, options);
const { startWebUiServer } = await import(serverPath); const { startWebUiServer } = await import(serverPath);
await startWebUiServer({ await startWebUiServer({
port: options.port, port: options.port,
@@ -365,10 +420,12 @@ const commands = {
const pid = readPidFile(pidFilePath); const pid = readPidFile(pidFilePath);
if (pid && isProcessRunning(pid)) { if (pid && isProcessRunning(pid)) {
runningInstances.push({ port, pid, pidFilePath }); const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`);
runningInstances.push({ port, pid, pidFilePath, instanceFilePath });
} else { } else {
removePidFile(pidFilePath); removePidFile(pidFilePath);
removeInstanceFile(path.join(tmpDir, `openchamber-${port}.json`));
} }
} }
} }
@@ -404,12 +461,14 @@ const commands = {
if (!isProcessRunning(targetInstance.pid)) { if (!isProcessRunning(targetInstance.pid)) {
clearInterval(checkShutdown); clearInterval(checkShutdown);
removePidFile(targetInstance.pidFilePath); removePidFile(targetInstance.pidFilePath);
removeInstanceFile(targetInstance.instanceFilePath);
console.log('OpenChamber stopped successfully'); console.log('OpenChamber stopped successfully');
} else if (attempts >= maxAttempts) { } else if (attempts >= maxAttempts) {
clearInterval(checkShutdown); clearInterval(checkShutdown);
console.log('Force killing process...'); console.log('Force killing process...');
process.kill(targetInstance.pid, 'SIGKILL'); process.kill(targetInstance.pid, 'SIGKILL');
removePidFile(targetInstance.pidFilePath); removePidFile(targetInstance.pidFilePath);
removeInstanceFile(targetInstance.instanceFilePath);
console.log('OpenChamber force stopped'); console.log('OpenChamber force stopped');
} }
}, 500); }, 500);
@@ -437,6 +496,7 @@ const commands = {
if (!isProcessRunning(instance.pid)) { if (!isProcessRunning(instance.pid)) {
clearInterval(checkShutdown); clearInterval(checkShutdown);
removePidFile(instance.pidFilePath); removePidFile(instance.pidFilePath);
removeInstanceFile(instance.instanceFilePath);
console.log(` Port ${instance.port} stopped successfully`); console.log(` Port ${instance.port} stopped successfully`);
resolve(true); resolve(true);
} else if (attempts >= maxAttempts) { } else if (attempts >= maxAttempts) {
@@ -445,6 +505,7 @@ const commands = {
try { try {
process.kill(instance.pid, 'SIGKILL'); process.kill(instance.pid, 'SIGKILL');
removePidFile(instance.pidFilePath); removePidFile(instance.pidFilePath);
removeInstanceFile(instance.instanceFilePath);
console.log(` Port ${instance.port} force stopped`); console.log(` Port ${instance.port} force stopped`);
} catch (e) { } catch (e) {
@@ -464,11 +525,95 @@ const commands = {
}, },
async restart(options) { async restart(options) {
await commands.stop(options); const os = await import('os');
await commands.serve(options); 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 os = await import('os');
const tmpDir = os.tmpdir(); 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() { async function main() {
+106
View File
@@ -1605,6 +1605,112 @@ async function main(options = {}) {
app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next)); 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) => { app.get('/api/openchamber/models-metadata', async (req, res) => {
const now = Date.now(); const now = Date.now();
+255
View File
@@ -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,
};
}
+218
View File
@@ -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 "$@"