Node's happy-eyeballs default (autoSelectFamilyAttemptTimeout=250ms) aborts every connect attempt to provider endpoints whose TCP handshake exceeds 250ms, so quota fetches from Node processes fail with "fetch failed" while the Bun-based opencode CLI path succeeds (#3399). Raise the per-attempt cap to 5s via a shared applyConnectAttemptTimeout() helper called from the two Node entrypoints that host the quota module: the Electron main process and the openchamber CLI. Family autoselection stays enabled (::1 and IPv6→IPv4 fallback preserved); runtimes without the setter are a no-op.
472 lines
13 KiB
JavaScript
Executable File
472 lines
13 KiB
JavaScript
Executable File
#!/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';
|
|
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';
|
|
import { applyConnectAttemptTimeout } from '../server/lib/network-defaults.js';
|
|
|
|
// The CLI process performs provider fetches (quota/usage, update notes) under
|
|
// Node/undici, whose happy-eyeballs default aborts each connect attempt after
|
|
// 250ms — distant provider endpoints routinely need longer handshakes, surfacing
|
|
// as "fetch failed" (#3399). No-op on runtimes without the setter.
|
|
applyConnectAttemptTimeout();
|
|
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
// The Windows installer places bun.exe there; spawnSync does not append
|
|
// the extension to an explicit path, so without it the probe fails and
|
|
// the CLI silently falls back to Node.
|
|
return path.join(process.env.BUN_INSTALL.trim(), 'bin', process.platform === 'win32' ? 'bun.exe' : '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}`);
|
|
}
|
|
}
|
|
|
|
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),
|
|
});
|
|
|
|
async function main() {
|
|
const parsed = parseArgs();
|
|
const { command, subcommand, tunnelAction, startupAction, scheduleAction, sessionAction, controlAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
|
|
activeCommandOptions = options;
|
|
|
|
if (versionRequested) {
|
|
if (isJsonMode(options)) {
|
|
printJson({ version: PACKAGE_JSON.version });
|
|
} else {
|
|
console.log(PACKAGE_JSON.version);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
if (helpRequested) {
|
|
if (command === 'tunnel') {
|
|
showTunnelHelp();
|
|
} 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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
export {
|
|
main,
|
|
commands,
|
|
parseArgs,
|
|
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,
|
|
};
|