Files
openchamber/packages/web/bin/lib/commands-lifecycle.js
T

397 lines
16 KiB
JavaScript

import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { requestServerShutdown } from './cli-http.js';
import { isPortAvailable } from './cli-ports.js';
import {
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
} from './cli-lifecycle.js';
import {
readInstanceOptions,
removePidFile,
removeInstanceFile,
isProcessRunning,
stopInstanceProcess,
} from './cli-process.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
async function stopCommand(options) {
const showOutput = shouldRenderHumanOutput(options);
const suppressQuietOutput = options?.suppressQuietOutput === true;
const jsonResults = [];
const printQuietStopResults = () => {
if (suppressQuietOutput) return;
if (!isQuietMode(options) || isJsonMode(options)) return;
if (jsonResults.length === 0) {
process.stdout.write('none\n');
return;
}
for (const result of jsonResults) {
if (result.stopped) {
process.stdout.write(`stopped ${result.port}\n`);
} else {
const reason = result.reason || 'failed';
process.stderr.write(`failed ${result.port} ${reason}\n`);
}
}
};
const finish = (text) => {
if (!showOutput) return;
clackOutro(text);
};
if (showOutput) {
clackIntro('OpenChamber Stop');
}
let runningInstances = await discoverLifecycleInstances(options);
if (options.explicitPort) {
if (runningInstances.length === 0) {
const unconfirmedInstance = await discoverUnconfirmedRegistryInstanceOnPort(options.port, options);
if (unconfirmedInstance) {
runningInstances = [unconfirmedInstance];
}
}
if (runningInstances.length === 0) {
jsonResults.push({ port: options.port, stopped: false, reason: 'not-found' });
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults });
}
if (showOutput) {
logStatus('info', `no OpenChamber instance found on port ${options.port}`);
finish('nothing to stop');
}
printQuietStopResults();
return;
}
const explicitInstance = runningInstances[0];
if (explicitInstance.runtime === 'desktop') {
jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' });
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults, messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message: `Port ${options.port} is managed by OpenChamber Desktop and cannot be stopped with this command.` }] });
}
if (showOutput) {
logStatus('warning', `port ${options.port} is managed by OpenChamber Desktop`, 'cannot be stopped with this command');
finish('no changes applied');
}
printQuietStopResults();
return;
}
if (explicitInstance.source === 'probe') {
const unmanagedStopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !unmanagedStopSpin) {
logStatus('info', `found unmanaged OpenChamber instance on port ${options.port}`, 'attempting shutdown');
}
unmanagedStopSpin?.start(`Stopping unmanaged OpenChamber on port ${options.port}...`);
const requested = await requestServerShutdown(options.port, options.host);
if (Number.isFinite(explicitInstance.pid) && isProcessRunning(explicitInstance.pid)) {
await stopInstanceProcess(explicitInstance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
}).catch(() => false);
}
const stopped = await isPortAvailable(options.port, options.host);
if (stopped) {
unmanagedStopSpin?.stop(`Stopped unmanaged OpenChamber on port ${options.port}`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: true });
if (isJsonMode(options)) {
printJson({ stoppedCount: 1, results: jsonResults });
}
if (showOutput && !unmanagedStopSpin) {
logStatus('success', `stopped OpenChamber on port ${options.port}`);
finish('stop complete');
}
printQuietStopResults();
} else if (requested) {
unmanagedStopSpin?.stop(`Shutdown requested on port ${options.port} (still occupied)`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'shutdown-requested-port-busy' });
if (isJsonMode(options)) {
printJson({
status: 'warning',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'warning', code: 'SHUTDOWN_PARTIAL', message: `Shutdown was requested for port ${options.port}, but the port is still occupied.` }],
});
}
if (showOutput && !unmanagedStopSpin) {
logStatus('warning', `shutdown requested on port ${options.port}`, 'port is still occupied');
finish('partial stop');
}
printQuietStopResults();
} else {
unmanagedStopSpin?.error(`Could not stop OpenChamber on port ${options.port}`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'stop-failed' });
if (isJsonMode(options)) {
printJson({
status: 'error',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber on port ${options.port}.` }],
});
}
if (showOutput && !unmanagedStopSpin) {
logStatus('error', `could not stop OpenChamber on port ${options.port}`);
finish('failed');
}
printQuietStopResults();
}
return;
}
if (explicitInstance.source === 'registry-unconfirmed') {
const unconfirmedStopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !unconfirmedStopSpin) {
logStatus('info', `found unconfirmed OpenChamber pid ${explicitInstance.pid} on port ${options.port}`, 'HTTP shutdown endpoint is unreachable; stopping by PID');
}
unconfirmedStopSpin?.start(`Stopping unconfirmed OpenChamber on port ${options.port}...`);
const stopped = await stopInstanceProcess(explicitInstance.pid, {
shutdownWaitMs: 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
}).catch(() => false);
if (stopped || !isProcessRunning(explicitInstance.pid)) {
removePidFile(explicitInstance.pidFilePath);
removeInstanceFile(explicitInstance.instanceFilePath);
unconfirmedStopSpin?.stop(`Stopped OpenChamber PID ${explicitInstance.pid}`);
jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: true });
if (isJsonMode(options)) {
printJson({ stoppedCount: 1, results: jsonResults });
}
if (showOutput && !unconfirmedStopSpin) {
logStatus('success', `stopped pid ${explicitInstance.pid}`);
finish('stop complete');
}
printQuietStopResults();
return;
}
unconfirmedStopSpin?.error(`Could not stop OpenChamber PID ${explicitInstance.pid}`);
jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: false, reason: 'stop-failed' });
if (isJsonMode(options)) {
printJson({
status: 'error',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber PID ${explicitInstance.pid}.` }],
});
}
if (showOutput && !unconfirmedStopSpin) {
logStatus('error', `could not stop pid ${explicitInstance.pid}`);
finish('failed');
}
printQuietStopResults();
return;
}
} else if (runningInstances.length === 0) {
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults });
}
if (showOutput) {
logStatus('info', 'No running OpenChamber instances found');
finish('nothing to stop');
}
printQuietStopResults();
return;
}
for (const instance of runningInstances) {
const stopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !stopSpin) {
logStatus('info', `stopping port ${instance.port} (PID: ${instance.pid})`);
}
stopSpin?.start(`Stopping OpenChamber on port ${instance.port}...`);
try {
const requested = await requestServerShutdown(instance.port, instance.host || options.host);
const stopped = await stopInstanceProcess(instance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
});
if (!stopped && isProcessRunning(instance.pid)) {
throw new Error(`Timed out stopping pid ${instance.pid}`);
}
removePidFile(instance.pidFilePath);
removeInstanceFile(instance.instanceFilePath);
stopSpin?.stop(`Stopped OpenChamber on port ${instance.port}`);
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: true });
if (showOutput && !stopSpin) {
logStatus('success', `stopped port ${instance.port}`);
}
} catch (error) {
stopSpin?.error(`Failed to stop OpenChamber on port ${instance.port}`);
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: false, reason: error instanceof Error ? error.message : String(error) });
if (showOutput) {
logStatus('error', `error stopping port ${instance.port}`, error.message);
} else if (!isJsonMode(options) && !isQuietMode(options)) {
console.error(`Error stopping port ${instance.port}: ${error.message}`);
}
}
}
if (isJsonMode(options)) {
const stoppedCount = jsonResults.filter((entry) => entry.stopped).length;
const hasFailure = jsonResults.some((entry) => !entry.stopped);
printJson({
status: hasFailure ? 'warning' : 'ok',
stoppedCount,
results: jsonResults,
});
return;
}
finish(`${runningInstances.length} instance(s)`);
printQuietStopResults();
}
async function restartCommand(options, serveCommand) {
const commandContext = this && typeof this === 'object' ? this : {};
const runStop = typeof commandContext.stop === 'function'
? commandContext.stop.bind(commandContext)
: stopCommand;
const runServe = typeof commandContext.serve === 'function'
? commandContext.serve.bind(commandContext)
: serveCommand;
const showOutput = shouldRenderHumanOutput(options);
const restarted = [];
if (showOutput) {
clackIntro('OpenChamber Restart');
}
let runningInstances = await discoverLifecycleInstances(options);
if (runningInstances.length === 0) {
if (isJsonMode(options)) {
printJson({ restartedCount: 0, results: restarted });
}
if (showOutput) {
logStatus('info', 'No running OpenChamber instances to restart');
clackOutro('nothing to restart');
} else if (isQuietMode(options)) {
process.stdout.write('restarted 0\n');
}
return;
}
for (const instance of runningInstances) {
if (instance.runtime === 'desktop') {
const message = `Port ${instance.port} is managed by OpenChamber Desktop and cannot be restarted with this command.`;
if (isJsonMode(options)) {
printJson({
status: 'warning',
restartedCount: 0,
results: [{ fromPort: instance.port, runtime: 'desktop', ok: false, reason: 'desktop-managed' }],
messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message }],
});
return;
}
if (showOutput) {
logStatus('warning', `port ${instance.port} is managed by OpenChamber Desktop`, 'cannot be restarted with this command');
clackOutro('no changes applied');
} else if (isQuietMode(options)) {
process.stdout.write('restarted 0\n');
}
return;
}
const storedOptions = instance.instanceFilePath
? (readInstanceOptions(instance.instanceFilePath) || { port: instance.port })
: { port: instance.port };
const instanceHost = storedOptions.host || instance.host || options.host;
const launchMode = instance.launchMode || 'daemon';
const isForeground = launchMode === 'foreground';
const restartPort = options.explicitPort ? options.port : instance.port;
const restartSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !restartSpin) {
logStatus('info', `restarting port ${instance.port}`, `mode: ${launchMode}`);
}
restartSpin?.start(`Restarting OpenChamber on port ${instance.port}...`);
try {
await runStop({
explicitPort: true,
port: instance.port,
host: instanceHost,
quiet: true,
suppressQuietOutput: true,
});
// Foreground instances are managed by a process manager (systemd,
// Docker, etc.) that will restart them automatically after stop.
// Do not call serve() here — just record the stop as a successful
// restart and let the process manager handle the actual restart.
if (isForeground) {
restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: true });
restartSpin?.stop(`Stopped foreground instance on port ${instance.port} (process manager will restart)`);
if (showOutput && !restartSpin) {
logStatus('success', `port ${instance.port} stopped`, 'process manager will restart');
}
continue;
}
await new Promise((resolve) => setTimeout(resolve, 500));
const restartedPort = await runServe({
port: restartPort,
host: instanceHost,
explicitPort: true,
uiPassword: options.explicitUiPassword ? options.uiPassword : (storedOptions.uiPassword || options.uiPassword),
apiOnly: storedOptions.apiOnly === true || options.apiOnly === true,
suppressStartupSummary: true,
quiet: true,
suppressUiPasswordWarning: true,
suppressQuietOutput: true,
});
restarted.push({ fromPort: instance.port, toPort: restartedPort, launchMode, ok: true });
restartSpin?.stop(`Restarted OpenChamber on port ${restartedPort}`);
if (showOutput && !restartSpin) {
logStatus('success', `port ${restartedPort} restarted`, `mode: ${launchMode}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
restartSpin?.error(`Failed to restart OpenChamber on port ${instance.port}`);
if (showOutput && !restartSpin) {
logStatus('error', `failed to restart port ${instance.port}`, message);
}
throw error;
}
}
if (isJsonMode(options)) {
printJson({ restartedCount: restarted.length, results: restarted.map((r) => ({ ...r, launchMode: r.launchMode })) });
return;
}
if (showOutput) {
clackOutro(`${runningInstances.length} instance(s) restarted`);
} else if (isQuietMode(options)) {
process.stdout.write(`restarted ${restarted.length}\n`);
}
}
function createLifecycleCommands({ serveCommand }) {
return {
stop: stopCommand,
restart(options) {
return restartCommand.call(this, options, serveCommand);
},
};
}
export { createLifecycleCommands };