Files
openchamber/packages/web/bin/cli.js
T

462 lines
12 KiB
JavaScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import { isModuleCliExecution } from './cli-entry.js';
import { EXIT_CODE, TunnelCliError } from './lib/cli-errors.js';
2026-06-12 18:24:07 +03:00
import {
resolveServeHost,
hasUiPasswordConfigured,
generateUiPassword,
resolveServeUiPassword,
assertAuthenticatedNetworkExposure,
} from './lib/cli-network.js';
import {
maskToken,
resolveToken,
redactProfileForOutput,
redactProfilesForOutput,
warnIfUnsafeFilePermissions,
ensureTunnelProfilesMigrated as ensureTunnelProfilesMigratedBase,
} from './lib/cli-tunnel-profiles.js';
import {
parseArgs,
showHelp,
showControlHelp,
showStartupHelp,
showConnectUrlHelp,
showTunnelHelp,
generateCompletionScript,
findClosestMatch,
} from './lib/cli-args.js';
import { readDesktopLocalPortFromSettings } from './lib/cli-paths.js';
import { resolveExplicitBinary, searchPathFor } from './lib/cli-executables.js';
import { startupCommand } from './lib/commands-startup.js';
import { logsCommand } from './lib/commands-logs.js';
import { statusCommand } from './lib/commands-status.js';
import { scheduleCommand } from './lib/commands-schedule.js';
import { sessionCommand } from './lib/commands-session.js';
import { modelsCommand } from './lib/commands-models.js';
import { projectsCommand } from './lib/commands-projects.js';
import { createUpdateCommand } from './lib/commands-update.js';
import { createConnectUrlCommand } from './lib/commands-connect-url.js';
import { createLifecycleCommands } from './lib/commands-lifecycle.js';
import { createServeCommand } from './lib/commands-serve.js';
import { createTunnelCommand, isValidTunnelDoctorResponse, shouldDisplayTunnelQr } from './lib/commands-tunnel.js';
import {
resolveDoctorPortStatuses,
discoverRunningInstances,
discoverOpenChamberInstanceOnPort,
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
resolveTunnelProviders,
} from './lib/cli-lifecycle.js';
import {
fetchTunnelProvidersFromPort,
fetchSystemInfoFromPort,
} from './lib/cli-http.js';
import {
getPidFilePath,
getInstanceFilePath,
isProcessRunning,
isOpenchamberCmdline,
isOpenchamberProcessRunning,
getOpenchamberProcessState,
} from './lib/cli-process.js';
import {
intro as clackIntro, outro as clackOutro, cancel as clackCancel,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from './cli-output.js';
2025-12-07 19:32:53 +02:00
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
let onCancelCleanup = null;
let activeCommandOptions = null;
let foregroundServerActive = false;
let foregroundShutdown = null;
function setCancelCleanup(handler) {
onCancelCleanup = typeof handler === 'function' ? handler : null;
}
function shouldWarnForTunnelProfileFile() {
if (!activeCommandOptions) return false;
return !isJsonMode(activeCommandOptions) && !isQuietMode(activeCommandOptions);
}
function ensureTunnelProfilesMigrated() {
return ensureTunnelProfilesMigratedBase({ shouldWarn: shouldWarnForTunnelProfileFile() });
}
const HAS_PLAIN_FLAG = process.argv.includes('--plain');
const STYLE_ENABLED = process.stdout.isTTY && process.env.NO_COLOR !== '1' && !HAS_PLAIN_FLAG;
const ANSI = {
bold: '\x1b[1m',
unbold: '\x1b[22m',
};
function boldText(text) {
if (!STYLE_ENABLED) return text;
return `${ANSI.bold}${text}${ANSI.unbold}`;
}
function importFromFilePath(filePath) {
return import(pathToFileURL(filePath).href);
}
2025-12-07 19:32:53 +02:00
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 isBunRuntime() {
return typeof globalThis.Bun !== 'undefined';
}
function isBunInstalled() {
try {
const result = spawnSync(BUN_BIN, ['--version'], {
stdio: 'ignore',
env: process.env,
windowsHide: true,
});
return result.status === 0;
} catch {
return false;
}
}
function getPreferredServerRuntime() {
return isBunInstalled() ? 'bun' : 'node';
}
async function checkOpenCodeCLI(onNotice) {
if (process.env.OPENCODE_BINARY) {
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
if (override) {
process.env.OPENCODE_BINARY = override;
return override;
}
const message = `OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`;
if (typeof onNotice === 'function') {
onNotice({ level: 'warning', code: 'OPENCODE_BINARY_INVALID', message });
} else {
console.warn(`Warning: ${message}`);
2025-12-07 19:32:53 +02:00
}
}
const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) {
process.env.OPENCODE_BINARY = resolvedFromPath;
return resolvedFromPath;
}
throw new Error(
`Unable to locate the opencode CLI on PATH (${process.env.PATH || '<empty>'}). ` +
'Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.'
);
}
const commands = {
serve: null,
'connect-url': null,
stop: null,
restart: null,
status: statusCommand,
schedule: scheduleCommand,
session: sessionCommand,
models: modelsCommand,
projects: projectsCommand,
logs: logsCommand,
startup: startupCommand,
update: null,
};
commands.serve = createServeCommand({
serverPath: path.join(__dirname, '..', 'server', 'index.js'),
bunBin: BUN_BIN,
checkOpenCodeCLI,
getPreferredServerRuntime,
setForegroundServerActive(value) { foregroundServerActive = value; },
setForegroundShutdown(handler) { foregroundShutdown = handler; },
});
{
const lifecycleCommands = createLifecycleCommands({ serveCommand: commands.serve.bind(commands) });
commands.stop = lifecycleCommands.stop;
commands.restart = lifecycleCommands.restart;
}
commands.tunnel = createTunnelCommand({
serveCommand: commands.serve.bind(commands),
stopCommand: commands.stop.bind(commands),
setCancelCleanup,
boldText,
ensureTunnelProfilesMigrated,
});
commands['connect-url'] = createConnectUrlCommand({
serveCommand: commands.serve.bind(commands),
});
commands.update = createUpdateCommand({
importFromFilePath,
packageManagerPath: path.join(__dirname, '..', 'server', 'lib', 'package-manager.js'),
serveCommand: commands.serve.bind(commands),
});
2025-12-07 19:32:53 +02:00
async function main() {
const parsed = parseArgs();
const { command, subcommand, tunnelAction, startupAction, scheduleAction, sessionAction, controlAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
activeCommandOptions = options;
2025-12-07 19:32:53 +02:00
if (versionRequested) {
if (isJsonMode(options)) {
printJson({ version: PACKAGE_JSON.version });
} else {
console.log(PACKAGE_JSON.version);
}
return;
2025-12-07 19:32:53 +02:00
}
if (removedFlagErrors.length > 0) {
if (isJsonMode(options)) {
printJson({
status: 'error',
error: {
message: removedFlagErrors[0],
details: removedFlagErrors,
},
});
} else {
for (const error of removedFlagErrors) {
console.error(`Error: ${error}`);
}
}
2025-12-07 19:32:53 +02:00
process.exit(1);
}
if (helpRequested) {
if (command === 'tunnel') {
showTunnelHelp();
2026-05-26 01:36:11 +03:00
} else if (command === 'startup') {
showStartupHelp();
} else if (command === 'connect-url') {
showConnectUrlHelp();
} else if (command === 'schedule') {
await commands.schedule(options, 'help');
} else if (command === 'session') {
await commands.session(options, 'help');
} else if (command === 'models') {
await commands.models(options, 'help');
} else if (command === 'projects') {
await commands.projects(options, 'help');
} else if (command === 'control') {
showControlHelp();
} else {
showHelp();
}
return;
}
if (command === 'tunnel') {
await commands.tunnel(options, subcommand, tunnelAction);
return;
}
2026-05-26 01:36:11 +03:00
if (command === 'startup') {
await commands.startup(options, startupAction);
return;
}
if (command === 'schedule') {
await commands.schedule(options, scheduleAction);
return;
}
if (command === 'session') {
await commands.session(options, sessionAction);
return;
}
if (command === 'models') {
await commands.models(options, 'show');
return;
}
if (command === 'projects') {
await commands.projects(options, 'list');
return;
}
if (command === 'control') {
if (controlAction !== 'help') {
throw new TunnelCliError(`Unknown control command '${controlAction}'.`, EXIT_CODE.USAGE_ERROR);
}
showControlHelp();
return;
}
if (!commands[command]) {
const knownCommands = ['serve', 'stop', 'restart', 'status', 'schedule', 'session', 'models', 'projects', 'control', 'tunnel', 'startup', 'logs', 'update'];
const suggestion = findClosestMatch(command, knownCommands);
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
if (isJsonMode(options)) {
printJson({
status: 'error',
error: {
message: `Unknown command '${command}'.${hint}`,
},
messages: [{ level: 'info', code: 'USAGE_HELP', message: 'Use --help to see available commands' }],
});
} else {
console.error(`Error: Unknown command '${command}'.${hint}`);
console.error('Use --help to see available commands');
}
process.exit(EXIT_CODE.USAGE_ERROR);
}
await commands[command](options);
2025-12-07 19:32:53 +02:00
}
const isCliExecution = isModuleCliExecution(process.argv[1], import.meta.url, fs.realpathSync, 'openchamber');
if (isCliExecution) {
let isHandlingSigint = false;
process.on('SIGINT', () => {
if (isHandlingSigint) {
return;
}
if (foregroundServerActive) {
if (typeof foregroundShutdown === 'function') {
void foregroundShutdown('SIGINT');
}
return;
}
isHandlingSigint = true;
(async () => {
clackCancel('Operation cancelled.');
if (onCancelCleanup) {
try {
await onCancelCleanup();
} catch {
} finally {
setCancelCleanup(null);
}
}
process.exit(130);
})();
});
process.on('unhandledRejection', (reason, promise) => {
if (isJsonMode(activeCommandOptions)) {
printJson({
status: 'error',
error: {
message: `Unhandled rejection: ${String(reason)}`,
},
});
} else {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
}
process.exit(1);
});
2025-12-07 19:32:53 +02:00
process.on('uncaughtException', (error) => {
if (isJsonMode(activeCommandOptions)) {
printJson({
status: 'error',
error: {
message: `Uncaught exception: ${error instanceof Error ? error.message : String(error)}`,
},
});
} else {
console.error('Uncaught Exception:', error);
}
process.exit(1);
});
2025-12-07 19:32:53 +02:00
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
if (isJsonMode(activeCommandOptions)) {
printJson({
status: 'error',
error: {
message,
},
});
} else if (process.stdout?.isTTY && !HAS_PLAIN_FLAG) {
clackIntro(boldText('Error'));
logStatus('error', message);
clackOutro('failed');
} else {
console.error(`Error: ${message}`);
}
const exitCode = error instanceof TunnelCliError ? error.exitCode : EXIT_CODE.GENERAL_ERROR;
process.exit(exitCode);
});
}
2025-12-07 19:32:53 +02:00
export {
main,
commands,
parseArgs,
2026-06-12 18:24:07 +03:00
assertAuthenticatedNetworkExposure,
resolveServeHost,
hasUiPasswordConfigured,
generateUiPassword,
resolveServeUiPassword,
shouldDisplayTunnelQr,
isValidTunnelDoctorResponse,
readDesktopLocalPortFromSettings,
getPidFilePath,
getInstanceFilePath,
isProcessRunning,
isOpenchamberProcessRunning,
isOpenchamberCmdline,
getOpenchamberProcessState,
resolveTunnelProviders,
fetchTunnelProvidersFromPort,
fetchSystemInfoFromPort,
discoverRunningInstances,
discoverOpenChamberInstanceOnPort,
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
ensureTunnelProfilesMigrated,
resolveToken,
redactProfileForOutput,
redactProfilesForOutput,
maskToken,
findClosestMatch,
generateCompletionScript,
TunnelCliError,
EXIT_CODE,
warnIfUnsafeFilePermissions,
};