1023 lines
32 KiB
JavaScript
1023 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import net from 'net';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const DEFAULT_PORT = 3000;
|
|
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
|
|
function getBunBinary() {
|
|
if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) {
|
|
return process.env.BUN_BINARY.trim();
|
|
}
|
|
if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) {
|
|
return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun');
|
|
}
|
|
return 'bun';
|
|
}
|
|
|
|
const BUN_BIN = getBunBinary();
|
|
|
|
function importFromFilePath(filePath) {
|
|
return import(pathToFileURL(filePath).href);
|
|
}
|
|
|
|
function isBunRuntime() {
|
|
return typeof globalThis.Bun !== 'undefined';
|
|
}
|
|
|
|
function isBunInstalled() {
|
|
try {
|
|
const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env });
|
|
return result.status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getPreferredServerRuntime() {
|
|
return isBunInstalled() ? 'bun' : 'node';
|
|
}
|
|
|
|
function generateRandomPassword(length = 16) {
|
|
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
|
let password = '';
|
|
for (let i = 0; i < length; i++) {
|
|
const randomIndex = Math.floor(Math.random() * charset.length);
|
|
password += charset[randomIndex];
|
|
}
|
|
return password;
|
|
}
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
|
|
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword };
|
|
let command = 'serve';
|
|
|
|
const consumeValue = (currentIndex, inlineValue) => {
|
|
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
|
|
return { value: inlineValue, nextIndex: currentIndex };
|
|
}
|
|
const candidate = args[currentIndex + 1];
|
|
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
|
|
return { value: candidate, nextIndex: currentIndex + 1 };
|
|
}
|
|
return { value: undefined, nextIndex: currentIndex };
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (arg.startsWith('-')) {
|
|
let optionName;
|
|
let inlineValue;
|
|
|
|
if (arg.startsWith('--')) {
|
|
const eqIndex = arg.indexOf('=');
|
|
optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
|
inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
|
} else {
|
|
optionName = arg.slice(1);
|
|
inlineValue = undefined;
|
|
}
|
|
|
|
switch (optionName) {
|
|
case 'port':
|
|
case 'p': {
|
|
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
i = nextIndex;
|
|
const parsed = parseInt(value ?? '', 10);
|
|
options.port = Number.isFinite(parsed) ? parsed : DEFAULT_PORT;
|
|
break;
|
|
}
|
|
case 'daemon':
|
|
case 'd':
|
|
options.daemon = true;
|
|
break;
|
|
case 'ui-password': {
|
|
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
i = nextIndex;
|
|
options.uiPassword = typeof value === 'string' ? value : '';
|
|
break;
|
|
}
|
|
case 'help':
|
|
case 'h':
|
|
showHelp();
|
|
process.exit(0);
|
|
break;
|
|
case 'version':
|
|
case 'v':
|
|
console.log(PACKAGE_JSON.version);
|
|
process.exit(0);
|
|
break;
|
|
}
|
|
} else {
|
|
command = arg;
|
|
}
|
|
}
|
|
|
|
return { command, options };
|
|
}
|
|
|
|
function showHelp() {
|
|
console.log(`
|
|
OpenChamber - Web interface for the OpenCode AI coding agent
|
|
|
|
USAGE:
|
|
openchamber [COMMAND] [OPTIONS]
|
|
|
|
COMMANDS:
|
|
serve Start the web server (default)
|
|
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})
|
|
--ui-password Protect browser UI with single password
|
|
-d, --daemon Run in background (serve command)
|
|
-h, --help Show help
|
|
-v, --version Show version
|
|
|
|
ENVIRONMENT:
|
|
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
|
OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096 (overrides OPENCODE_PORT)
|
|
OPENCODE_PORT Port of external OpenCode server to connect to
|
|
OPENCODE_SKIP_START Skip starting OpenCode, use external server
|
|
|
|
EXAMPLES:
|
|
openchamber # Start on default port 3000 (or a free port)
|
|
openchamber --port 8080 # Start on port 8080
|
|
openchamber serve --daemon # Start in background
|
|
openchamber stop # Stop all running instances
|
|
openchamber stop --port 3000 # Stop specific instance
|
|
openchamber status # Check status
|
|
openchamber update # Update to latest version
|
|
`);
|
|
}
|
|
|
|
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
|
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
.split(';')
|
|
.map((ext) => ext.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
|
: [''];
|
|
|
|
function isExecutable(filePath) {
|
|
try {
|
|
const stats = fs.statSync(filePath);
|
|
if (!stats.isFile()) {
|
|
return false;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return true;
|
|
}
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resolveExplicitBinary(candidate) {
|
|
if (!candidate) {
|
|
return null;
|
|
}
|
|
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
|
|
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
|
|
return isExecutable(resolved) ? resolved : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function searchPathFor(command) {
|
|
const pathValue = process.env.PATH || '';
|
|
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
|
for (const dir of segments) {
|
|
for (const ext of WINDOWS_EXTENSIONS) {
|
|
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
|
const candidate = path.join(dir, fileName);
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function checkOpenCodeCLI() {
|
|
if (process.env.OPENCODE_BINARY) {
|
|
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
|
|
if (override) {
|
|
process.env.OPENCODE_BINARY = override;
|
|
return override;
|
|
}
|
|
console.warn(`Warning: OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`);
|
|
}
|
|
|
|
const resolvedFromPath = searchPathFor('opencode');
|
|
if (resolvedFromPath) {
|
|
process.env.OPENCODE_BINARY = resolvedFromPath;
|
|
return resolvedFromPath;
|
|
}
|
|
|
|
if (process.platform !== 'win32') {
|
|
const shellCandidates = [];
|
|
if (process.env.SHELL) {
|
|
shellCandidates.push(process.env.SHELL);
|
|
}
|
|
shellCandidates.push('/bin/bash', '/bin/zsh', '/bin/sh');
|
|
|
|
for (const shellPath of shellCandidates) {
|
|
if (!shellPath || !isExecutable(shellPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
if (result.status === 0) {
|
|
const candidate = result.stdout.trim().split(/\s+/).pop();
|
|
if (candidate && isExecutable(candidate)) {
|
|
const dir = path.dirname(candidate);
|
|
const currentPath = process.env.PATH || '';
|
|
const segments = currentPath.split(path.delimiter).filter(Boolean);
|
|
if (!segments.includes(dir)) {
|
|
segments.unshift(dir);
|
|
process.env.PATH = segments.join(path.delimiter);
|
|
}
|
|
process.env.OPENCODE_BINARY = candidate;
|
|
return candidate;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const result = spawnSync('where', ['opencode'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
if (result.status === 0) {
|
|
const candidate = result.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
|
|
if (candidate && isExecutable(candidate)) {
|
|
process.env.OPENCODE_BINARY = candidate;
|
|
return candidate;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
|
|
}
|
|
}
|
|
|
|
console.error('Error: Unable to locate the opencode CLI on PATH.');
|
|
console.error(`Current PATH: ${process.env.PATH || '<empty>'}`);
|
|
console.error('Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.');
|
|
process.exit(1);
|
|
}
|
|
|
|
async function isPortAvailable(port) {
|
|
if (!Number.isFinite(port) || port <= 0) {
|
|
return false;
|
|
}
|
|
|
|
// Don't specify host here; `server.listen(port)` in the web server also doesn't,
|
|
// and on some platforms (notably macOS) IPv4/IPv6 binding differences can make
|
|
// a 127.0.0.1-only probe report "free" while the real server bind fails.
|
|
return await new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.on('error', () => resolve(false));
|
|
server.listen({ port }, () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
async function resolveAvailablePort(desiredPort) {
|
|
const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT;
|
|
|
|
// If user explicitly chose a port (incl 0), respect it.
|
|
if (process.argv.includes('--port') || process.argv.includes('-p')) {
|
|
return startPort;
|
|
}
|
|
|
|
// Prefer the default port for predictable URLs, but fall back to an OS-assigned
|
|
// free port when it is already in use.
|
|
if (await isPortAvailable(startPort)) {
|
|
return startPort;
|
|
}
|
|
|
|
console.warn(`Port ${startPort} in use; using a free port`);
|
|
return 0;
|
|
}
|
|
|
|
async function getPidFilePath(port) {
|
|
const os = await import('os');
|
|
const tmpDir = os.tmpdir();
|
|
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();
|
|
const pid = parseInt(content);
|
|
if (isNaN(pid)) {
|
|
return null;
|
|
}
|
|
return pid;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writePidFile(pidFilePath, pid) {
|
|
try {
|
|
fs.writeFileSync(pidFilePath, pid.toString());
|
|
} catch (error) {
|
|
console.warn(`Warning: Could not write PID file: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function removePidFile(pidFilePath) {
|
|
try {
|
|
if (fs.existsSync(pidFilePath)) {
|
|
fs.unlinkSync(pidFilePath);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Warning: Could not remove PID file: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function requestServerShutdown(port) {
|
|
if (!Number.isFinite(port) || port <= 0) return false;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
try {
|
|
const resp = await fetch(`http://127.0.0.1:${port}/api/system/shutdown`, {
|
|
method: 'POST',
|
|
signal: controller.signal,
|
|
});
|
|
return resp.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
const commands = {
|
|
async serve(options) {
|
|
options.port = await resolveAvailablePort(options.port);
|
|
|
|
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
|
|
|
// When using dynamic port (port=0), don't use pid/instance files - the port is
|
|
// unknown until the server binds.
|
|
if (options.port !== 0) {
|
|
const pidFilePath = await getPidFilePath(options.port);
|
|
const instanceFilePath = await getInstanceFilePath(options.port);
|
|
|
|
const existingPid = readPidFile(pidFilePath);
|
|
if (existingPid && isProcessRunning(existingPid)) {
|
|
console.error(`Error: OpenChamber is already running on port ${options.port} (PID: ${existingPid})`);
|
|
console.error('Use "openchamber stop" to stop the existing instance');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Persist for restart/update to reuse the chosen port.
|
|
writeInstanceOptions(instanceFilePath, { ...options });
|
|
} else if (portWasSpecified) {
|
|
// Explicitly requested port=0; nothing to persist.
|
|
}
|
|
|
|
const opencodeBinary = await checkOpenCodeCLI();
|
|
|
|
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
|
|
|
|
const effectiveUiPassword = options.uiPassword;
|
|
|
|
const serverArgs = [serverPath, '--port', options.port.toString()];
|
|
if (typeof effectiveUiPassword === 'string') {
|
|
serverArgs.push('--ui-password', effectiveUiPassword);
|
|
}
|
|
|
|
const preferredRuntime = getPreferredServerRuntime();
|
|
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
|
|
|
|
if (options.daemon) {
|
|
const child = spawn(runtimeBin, serverArgs, {
|
|
detached: true,
|
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
env: {
|
|
...process.env,
|
|
OPENCHAMBER_PORT: options.port.toString(),
|
|
OPENCODE_BINARY: opencodeBinary,
|
|
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
|
|
...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
|
|
}
|
|
});
|
|
|
|
child.unref();
|
|
|
|
const resolvedPort = await new Promise((resolve) => {
|
|
let settled = false;
|
|
const timeout = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(options.port);
|
|
}, 5000);
|
|
|
|
child.on('message', (msg) => {
|
|
if (settled) return;
|
|
if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') {
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
resolve(msg.port);
|
|
}
|
|
});
|
|
|
|
child.on('exit', () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
resolve(options.port);
|
|
});
|
|
});
|
|
|
|
// Important: in daemon mode we must close the IPC channel, otherwise the CLI
|
|
// process can hang around as the parent of the detached server.
|
|
try {
|
|
child.removeAllListeners('message');
|
|
child.removeAllListeners('exit');
|
|
if (typeof child.disconnect === 'function' && child.connected) {
|
|
child.disconnect();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
if (isProcessRunning(child.pid)) {
|
|
const pidFilePathResolved = await getPidFilePath(resolvedPort);
|
|
const instanceFilePathResolved = await getInstanceFilePath(resolvedPort);
|
|
|
|
writePidFile(pidFilePathResolved, child.pid);
|
|
writeInstanceOptions(instanceFilePathResolved, { ...options, port: resolvedPort, uiPassword: effectiveUiPassword });
|
|
|
|
console.log(`OpenChamber started in daemon mode on port ${resolvedPort}`);
|
|
console.log(`PID: ${child.pid}`);
|
|
console.log(`Visit: http://localhost:${resolvedPort}`);
|
|
if (showAutoGeneratedPassword) {
|
|
console.log(`\n🔐 Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`);
|
|
console.log('⚠️ Save this password - it won\'t be shown again!\n');
|
|
}
|
|
} else {
|
|
console.error('Failed to start server in daemon mode');
|
|
process.exit(1);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
process.env.OPENCODE_BINARY = opencodeBinary;
|
|
if (typeof effectiveUiPassword === 'string') {
|
|
process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword;
|
|
}
|
|
if (process.env.OPENCODE_SKIP_START) {
|
|
process.env.OPENCHAMBER_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START;
|
|
}
|
|
|
|
// Prefer bun when installed (much faster PTY). If CLI is running under Node,
|
|
// run the server in a child process so Node doesn't have to load bun-pty.
|
|
if (preferredRuntime === 'bun' && !isBunRuntime()) {
|
|
const child = spawn(runtimeBin, serverArgs, {
|
|
stdio: 'inherit',
|
|
env: {
|
|
...process.env,
|
|
OPENCHAMBER_PORT: options.port.toString(),
|
|
OPENCODE_BINARY: opencodeBinary,
|
|
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
|
|
...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
|
|
},
|
|
});
|
|
|
|
child.on('exit', (code) => {
|
|
process.exit(typeof code === 'number' ? code : 1);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const { startWebUiServer } = await importFromFilePath(serverPath);
|
|
await startWebUiServer({
|
|
port: options.port,
|
|
attachSignals: true,
|
|
exitOnShutdown: true,
|
|
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
|
|
});
|
|
},
|
|
|
|
async stop(options) {
|
|
const os = await import('os');
|
|
const tmpDir = os.tmpdir();
|
|
|
|
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 pid = readPidFile(pidFilePath);
|
|
|
|
if (pid && isProcessRunning(pid)) {
|
|
const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`);
|
|
runningInstances.push({ port, pid, pidFilePath, instanceFilePath });
|
|
} else {
|
|
|
|
removePidFile(pidFilePath);
|
|
removeInstanceFile(path.join(tmpDir, `openchamber-${port}.json`));
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
|
|
}
|
|
|
|
if (runningInstances.length === 0) {
|
|
console.log('No running OpenChamber instances found');
|
|
return;
|
|
}
|
|
|
|
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
|
|
|
if (portWasSpecified) {
|
|
const targetInstance = runningInstances.find(inst => inst.port === options.port);
|
|
|
|
if (!targetInstance) {
|
|
console.log(`No OpenChamber instance found running on port ${options.port}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
|
|
|
try {
|
|
await requestServerShutdown(targetInstance.port);
|
|
process.kill(targetInstance.pid, 'SIGTERM');
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 10;
|
|
|
|
const checkShutdown = setInterval(() => {
|
|
attempts++;
|
|
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);
|
|
|
|
} catch (error) {
|
|
console.error(`Error stopping process: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
|
|
console.log(`Stopping all OpenChamber instances (${runningInstances.length} found)...`);
|
|
|
|
for (const instance of runningInstances) {
|
|
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
|
|
|
try {
|
|
await requestServerShutdown(instance.port);
|
|
process.kill(instance.pid, 'SIGTERM');
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 10;
|
|
|
|
await new Promise((resolve) => {
|
|
const checkShutdown = setInterval(() => {
|
|
attempts++;
|
|
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) {
|
|
clearInterval(checkShutdown);
|
|
console.log(` Force killing port ${instance.port}...`);
|
|
try {
|
|
process.kill(instance.pid, 'SIGKILL');
|
|
removePidFile(instance.pidFilePath);
|
|
removeInstanceFile(instance.instanceFilePath);
|
|
console.log(` Port ${instance.port} force stopped`);
|
|
} catch (e) {
|
|
|
|
}
|
|
resolve(true);
|
|
}
|
|
}, 500);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error(` Error stopping port ${instance.port}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
console.log('\nAll OpenChamber instances stopped');
|
|
}
|
|
},
|
|
|
|
async restart(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 {
|
|
await requestServerShutdown(instance.port);
|
|
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() {
|
|
const os = await import('os');
|
|
const tmpDir = os.tmpdir();
|
|
|
|
let runningInstances = [];
|
|
let stoppedInstances = [];
|
|
|
|
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 pid = readPidFile(pidFilePath);
|
|
|
|
if (pid && isProcessRunning(pid)) {
|
|
runningInstances.push({ port, pid, pidFilePath });
|
|
} else {
|
|
|
|
removePidFile(pidFilePath);
|
|
stoppedInstances.push({ port });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
|
|
}
|
|
|
|
if (runningInstances.length === 0) {
|
|
console.log('OpenChamber Status:');
|
|
console.log(' Status: Stopped');
|
|
if (stoppedInstances.length > 0) {
|
|
console.log(` Previously used ports: ${stoppedInstances.map(s => s.port).join(', ')}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('OpenChamber Status:');
|
|
for (const [index, instance] of runningInstances.entries()) {
|
|
if (runningInstances.length > 1) {
|
|
console.log(`\nInstance ${index + 1}:`);
|
|
}
|
|
console.log(' Status: Running');
|
|
console.log(` PID: ${instance.pid}`);
|
|
console.log(` Port: ${instance.port}`);
|
|
console.log(` Visit: http://localhost:${instance.port}`);
|
|
|
|
try {
|
|
const { execSync } = await import('child_process');
|
|
const startTime = execSync(`ps -o lstart= -p ${instance.pid}`, { encoding: 'utf8' }).trim();
|
|
console.log(` Start Time: ${startTime}`);
|
|
} catch (error) {
|
|
|
|
}
|
|
}
|
|
},
|
|
|
|
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 importFromFilePath(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 {
|
|
await requestServerShutdown(instance.port);
|
|
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() {
|
|
const { command, options } = parseArgs();
|
|
|
|
if (!commands[command]) {
|
|
console.error(`Error: Unknown command '${command}'`);
|
|
console.error('Use --help to see available commands');
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
await commands[command](options);
|
|
} catch (error) {
|
|
console.error(`Error executing command '${command}': ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
console.error('Uncaught Exception:', error);
|
|
process.exit(1);
|
|
});
|
|
|
|
main();
|
|
|
|
export { commands, parseArgs, getPidFilePath };
|