feat: add startup launch support (#1421)
Add launch-at-startup support across the Electron desktop app and the web CLI. Electron now supports macOS launch-at-login through the native login item API. Login launches start OpenChamber in the background without opening a window, while Dock activation, deep links, and second-instance launches still open or focus the normal app window. The desktop Settings UI now exposes a localized launch-at-login toggle in Desktop Network Access. The web CLI now includes `openchamber startup status|enable|disable`, backed by native user services: - macOS: launchd LaunchAgent - Linux: systemd --user service - Windows: Task Scheduler Startup services run `openchamber serve --foreground` so the OS service manager owns process lifetime and restarts. Foreground service updates now defer restarts to the service manager instead of spawning duplicate CLI restarts. Startup services snapshot useful environment variables by default so provider tokens, PATH, SSH agent settings, and OpenCode configuration survive login/reboot starts. The snapshot avoids shell/session-only state, uses systemd-compatible env quoting on Linux, and avoids unused env artifacts on macOS. Also adds localized docs for startup services and environment variables.
This commit is contained in:
committed by
GitHub
parent
e97bf0d9dc
commit
2014303bc0
+443
-2
@@ -32,6 +32,7 @@ const DEFAULT_TAIL_LINES = 200;
|
||||
const DAEMON_READY_TIMEOUT_MS = 30000;
|
||||
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const LOG_ROTATE_KEEP = 5;
|
||||
const STARTUP_SERVICE_ID = 'dev.openchamber.web';
|
||||
const TUNNEL_PROFILES_VERSION = 1;
|
||||
const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json';
|
||||
const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json';
|
||||
@@ -601,6 +602,7 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
quiet: false,
|
||||
explicitPort: false,
|
||||
explicitUiPassword: false,
|
||||
envSnapshot: true,
|
||||
foreground: false,
|
||||
};
|
||||
|
||||
@@ -753,6 +755,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
case 'no-follow':
|
||||
options.follow = false;
|
||||
break;
|
||||
case 'no-env-snapshot':
|
||||
options.envSnapshot = false;
|
||||
break;
|
||||
case 'lines': {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
@@ -833,11 +838,13 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
const command = positional[0] || 'serve';
|
||||
const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null;
|
||||
const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null;
|
||||
const startupAction = command === 'startup' ? (positional[1] || 'status') : null;
|
||||
|
||||
return {
|
||||
command,
|
||||
subcommand,
|
||||
tunnelAction,
|
||||
startupAction,
|
||||
options,
|
||||
removedFlagErrors,
|
||||
helpRequested,
|
||||
@@ -858,6 +865,7 @@ COMMANDS:
|
||||
restart Stop and start the server
|
||||
status Show server status
|
||||
tunnel Tunnel lifecycle commands
|
||||
startup Manage launch at system startup
|
||||
logs Tail OpenChamber logs
|
||||
update Check for and install updates
|
||||
|
||||
@@ -883,11 +891,39 @@ EXAMPLES:
|
||||
openchamber # Start in daemon mode on default port 3000 (or free port)
|
||||
openchamber --port 8080 # Start on port 8080 (daemon)
|
||||
openchamber serve --foreground # Start in foreground (for systemd Type=simple)
|
||||
openchamber startup enable # Start OpenChamber at user login
|
||||
openchamber tunnel help # Show tunnel lifecycle help
|
||||
openchamber logs # Follow logs for latest running instance
|
||||
`);
|
||||
}
|
||||
|
||||
function showStartupHelp() {
|
||||
console.log(`
|
||||
OpenChamber Startup Commands
|
||||
|
||||
USAGE:
|
||||
openchamber startup <SUBCOMMAND> [OPTIONS]
|
||||
|
||||
SUBCOMMANDS:
|
||||
status Show startup integration status
|
||||
enable Install and start native user startup integration
|
||||
disable Stop and remove native user startup integration
|
||||
|
||||
OPTIONS:
|
||||
-p, --port Web server port used by startup service
|
||||
--host Bind address used by startup service
|
||||
--ui-password Protect browser UI with single password
|
||||
--no-env-snapshot Do not save current environment for startup service
|
||||
--json Output machine-readable JSON
|
||||
-q, --quiet Suppress non-essential output
|
||||
|
||||
EXAMPLES:
|
||||
openchamber startup enable
|
||||
openchamber startup enable --port 3000
|
||||
openchamber startup status --json
|
||||
`);
|
||||
}
|
||||
|
||||
function showTunnelHelp() {
|
||||
console.log(`
|
||||
Tunnel Lifecycle Commands
|
||||
@@ -1702,6 +1738,352 @@ function getRunDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getStartupServicePaths() {
|
||||
if (process.platform === 'darwin') {
|
||||
return {
|
||||
platform: 'macos',
|
||||
servicePath: path.join(os.homedir(), 'Library', 'LaunchAgents', `${STARTUP_SERVICE_ID}.plist`),
|
||||
};
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return {
|
||||
platform: 'linux',
|
||||
servicePath: path.join(os.homedir(), '.config', 'systemd', 'user', 'openchamber.service'),
|
||||
};
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return { platform: 'windows', servicePath: STARTUP_SERVICE_ID };
|
||||
}
|
||||
return { platform: process.platform, servicePath: null };
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function systemdEscapeArg(value) {
|
||||
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function startupShellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function systemdUnitPath(value) {
|
||||
return String(value).replace(/\\/g, '\\\\').replace(/ /g, '\\x20');
|
||||
}
|
||||
|
||||
function powershellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function startupEnvFileQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function systemdEnvFileQuote(value) {
|
||||
return `"${String(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$/g, '\\$')}"`;
|
||||
}
|
||||
|
||||
function getStartupEnvFilePath() {
|
||||
return path.join(getDataDir(), 'startup.env');
|
||||
}
|
||||
|
||||
function getMacosStartupWrapperPath() {
|
||||
return path.join(getDataDir(), 'bin', 'OpenChamber');
|
||||
}
|
||||
|
||||
function collectStartupEnv(options = {}) {
|
||||
const env = options.envSnapshot === false ? {} : Object.fromEntries(
|
||||
Object.entries(process.env)
|
||||
.filter(([key, value]) => shouldPersistStartupEnv(key, value))
|
||||
.map(([key, value]) => [key, String(value)])
|
||||
);
|
||||
|
||||
if (options.envSnapshot !== false) {
|
||||
const opencodeBinary = process.env.OPENCODE_BINARY || searchPathFor('opencode');
|
||||
if (typeof opencodeBinary === 'string' && opencodeBinary.trim().length > 0) {
|
||||
env.OPENCODE_BINARY = opencodeBinary.trim();
|
||||
}
|
||||
}
|
||||
const uiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
|
||||
if (uiPassword) {
|
||||
env.OPENCHAMBER_UI_PASSWORD = uiPassword;
|
||||
}
|
||||
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim().length > 0) {
|
||||
env.OPENCHAMBER_DATA_DIR = path.resolve(process.env.OPENCHAMBER_DATA_DIR.trim());
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function shouldPersistStartupEnv(key, value) {
|
||||
if (typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (/[\r\n]/.test(value)) return false;
|
||||
|
||||
// These are shell/session implementation details, not app configuration.
|
||||
const volatileKeys = new Set([
|
||||
'_',
|
||||
'BASH_ENV',
|
||||
'COLUMNS',
|
||||
'CONDA_DEFAULT_ENV',
|
||||
'CONDA_PREFIX',
|
||||
'CONDA_PROMPT_MODIFIER',
|
||||
'CONDA_SHLVL',
|
||||
'ENV',
|
||||
'HISTFILE',
|
||||
'HISTFILESIZE',
|
||||
'HISTSIZE',
|
||||
'LINES',
|
||||
'OLDPWD',
|
||||
'PROMPT',
|
||||
'PROMPT_COMMAND',
|
||||
'PS1',
|
||||
'PS2',
|
||||
'PS3',
|
||||
'PS4',
|
||||
'PWD',
|
||||
'PYENV_VERSION',
|
||||
'SHLVL',
|
||||
'TERM',
|
||||
'TERM_PROGRAM',
|
||||
'TERM_PROGRAM_VERSION',
|
||||
'TTY',
|
||||
'VIRTUAL_ENV',
|
||||
'VIRTUAL_ENV_PROMPT',
|
||||
]);
|
||||
return !volatileKeys.has(key);
|
||||
}
|
||||
|
||||
function writeStartupEnvFile(options = {}, fileOptions = {}) {
|
||||
const envFilePath = getStartupEnvFilePath();
|
||||
const lines = [];
|
||||
const env = collectStartupEnv(options);
|
||||
const quoteValue = typeof fileOptions.quoteValue === 'function' ? fileOptions.quoteValue : startupEnvFileQuote;
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
lines.push(`${key}=${quoteValue(value)}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(envFilePath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(envFilePath, lines.length > 0 ? `${lines.join('\n')}\n` : '', { mode: 0o600 });
|
||||
return envFilePath;
|
||||
}
|
||||
|
||||
function removeStartupEnvFile() {
|
||||
try { fs.unlinkSync(getStartupEnvFilePath()); } catch {}
|
||||
}
|
||||
|
||||
function resolveCliEntrypoint() {
|
||||
const entry = typeof process.argv[1] === 'string' && process.argv[1].trim().length > 0
|
||||
? process.argv[1]
|
||||
: path.join(__dirname, 'cli.js');
|
||||
try {
|
||||
return fs.realpathSync(entry);
|
||||
} catch {
|
||||
return path.resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function buildStartupArgs(options = {}) {
|
||||
const args = [resolveCliEntrypoint(), 'serve', '--foreground', '--port', String(options.port || DEFAULT_PORT)];
|
||||
if (typeof options.host === 'string' && options.host.length > 0) {
|
||||
args.push('--host', options.host);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function writeMacosStartupWrapper(options = {}) {
|
||||
const wrapperPath = getMacosStartupWrapperPath();
|
||||
const args = buildStartupArgs(options).map(startupShellQuote).join(' ');
|
||||
const content = `#!/bin/sh
|
||||
exec ${startupShellQuote(process.execPath)} ${args}
|
||||
`;
|
||||
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(wrapperPath, content, { mode: 0o700 });
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
function buildMacosLaunchAgent(options = {}) {
|
||||
const wrapperPath = writeMacosStartupWrapper(options);
|
||||
const args = [wrapperPath];
|
||||
const env = collectStartupEnv(options);
|
||||
const logDir = path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber');
|
||||
const argXml = args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join('\n');
|
||||
const envXml = Object.entries(env).length > 0
|
||||
? ` <key>EnvironmentVariables</key>\n <dict>\n${Object.entries(env).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}\n </dict>\n`
|
||||
: '';
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${STARTUP_SERVICE_ID}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${argXml}
|
||||
</array>
|
||||
${envXml} <key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${escapeXml(os.homedir())}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${escapeXml(path.join(logDir, 'startup.log'))}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${escapeXml(path.join(logDir, 'startup.err.log'))}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSystemdUserService(options = {}) {
|
||||
const args = buildStartupArgs(options).map((arg) => `"${systemdEscapeArg(arg)}"`).join(' ');
|
||||
const envFilePath = getStartupEnvFilePath();
|
||||
return `[Unit]
|
||||
Description=OpenChamber web server
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-${systemdEscapeArg(envFilePath)}
|
||||
ExecStart="${systemdEscapeArg(process.execPath)}" ${args}
|
||||
WorkingDirectory=${systemdUnitPath(os.homedir())}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
function runStartupCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio || 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0 && options.allowFailure !== true) {
|
||||
const detail = (result.stderr || result.stdout || '').trim();
|
||||
throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getStartupStatus() {
|
||||
const paths = getStartupServicePaths();
|
||||
if (!paths.servicePath) {
|
||||
return { supported: false, platform: paths.platform, enabled: false, servicePath: null };
|
||||
}
|
||||
if (paths.platform === 'windows') {
|
||||
const result = runStartupCommand('schtasks.exe', ['/Query', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
||||
return { supported: true, platform: paths.platform, enabled: result.status === 0, active: null, servicePath: paths.servicePath };
|
||||
}
|
||||
if (paths.platform === 'linux') {
|
||||
const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
|
||||
const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true });
|
||||
const activeState = (activeResult.stdout || '').trim() || 'inactive';
|
||||
return {
|
||||
supported: true,
|
||||
platform: paths.platform,
|
||||
enabled: enabledResult.status === 0 || fs.existsSync(paths.servicePath),
|
||||
active: activeState === 'active',
|
||||
activeState,
|
||||
servicePath: paths.servicePath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
supported: true,
|
||||
platform: paths.platform,
|
||||
enabled: fs.existsSync(paths.servicePath),
|
||||
active: null,
|
||||
servicePath: paths.servicePath,
|
||||
};
|
||||
}
|
||||
|
||||
function enableStartupService(options = {}) {
|
||||
const paths = getStartupServicePaths();
|
||||
if (!paths.servicePath) {
|
||||
throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
|
||||
if (paths.platform === 'macos') {
|
||||
removeStartupEnvFile();
|
||||
fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber'), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(paths.servicePath, buildMacosLaunchAgent(options), { mode: 0o600 });
|
||||
runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
|
||||
runStartupCommand('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, paths.servicePath]);
|
||||
runStartupCommand('/bin/launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${STARTUP_SERVICE_ID}`], { allowFailure: true });
|
||||
return getStartupStatus();
|
||||
}
|
||||
|
||||
if (paths.platform === 'linux') {
|
||||
writeStartupEnvFile(options, { quoteValue: systemdEnvFileQuote });
|
||||
fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(paths.servicePath, buildSystemdUserService(options), { mode: 0o600 });
|
||||
runStartupCommand('systemctl', ['--user', 'daemon-reload']);
|
||||
runStartupCommand('systemctl', ['--user', 'enable', '--now', 'openchamber.service']);
|
||||
return getStartupStatus();
|
||||
}
|
||||
|
||||
const envFilePath = writeStartupEnvFile(options);
|
||||
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
|
||||
const powerShellCommand = [
|
||||
`$envFile=${powershellQuote(envFilePath)}`,
|
||||
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
|
||||
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
|
||||
].join('; ');
|
||||
const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`;
|
||||
runStartupCommand('schtasks.exe', [
|
||||
'/Create',
|
||||
'/TN', STARTUP_SERVICE_ID,
|
||||
'/SC', 'ONLOGON',
|
||||
'/RL', 'LIMITED',
|
||||
'/F',
|
||||
'/TR', taskArgs,
|
||||
]);
|
||||
runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
||||
return getStartupStatus();
|
||||
}
|
||||
|
||||
function disableStartupService() {
|
||||
const paths = getStartupServicePaths();
|
||||
if (!paths.servicePath) {
|
||||
throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
|
||||
}
|
||||
|
||||
if (paths.platform === 'macos') {
|
||||
runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
|
||||
try { fs.unlinkSync(paths.servicePath); } catch {}
|
||||
return getStartupStatus();
|
||||
}
|
||||
|
||||
if (paths.platform === 'linux') {
|
||||
runStartupCommand('systemctl', ['--user', 'disable', '--now', 'openchamber.service'], { allowFailure: true });
|
||||
try { fs.unlinkSync(paths.servicePath); } catch {}
|
||||
runStartupCommand('systemctl', ['--user', 'daemon-reload'], { allowFailure: true });
|
||||
return getStartupStatus();
|
||||
}
|
||||
|
||||
runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
|
||||
runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
|
||||
return getStartupStatus();
|
||||
}
|
||||
|
||||
async function getPidFilePath(port) {
|
||||
return path.join(getRunDir(), `openchamber-${port}.pid`);
|
||||
}
|
||||
@@ -4719,6 +5101,58 @@ const commands = {
|
||||
});
|
||||
},
|
||||
|
||||
async startup(options, action = 'status') {
|
||||
const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
|
||||
if (!['status', 'enable', 'disable'].includes(normalized)) {
|
||||
throw new TunnelCliError(
|
||||
`Unknown startup subcommand '${action}'. Use 'openchamber startup --help'.`,
|
||||
EXIT_CODE.USAGE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
let status;
|
||||
if (normalized === 'enable') {
|
||||
status = enableStartupService(options);
|
||||
} else if (normalized === 'disable') {
|
||||
status = disableStartupService();
|
||||
} else {
|
||||
status = getStartupStatus();
|
||||
}
|
||||
|
||||
const result = { action: normalized, ...status };
|
||||
if (!result.supported) {
|
||||
throw new TunnelCliError(
|
||||
`Startup integration is not supported on ${result.platform}.`,
|
||||
EXIT_CODE.USAGE_ERROR
|
||||
);
|
||||
}
|
||||
if (normalized === 'enable' && result.activeState === 'failed') {
|
||||
throw new TunnelCliError(
|
||||
'Startup service was installed but failed to start. Run `journalctl --user -u openchamber.service -n 80 --no-pager` for details.',
|
||||
EXIT_CODE.GENERAL_ERROR
|
||||
);
|
||||
}
|
||||
if (isJsonMode(options)) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuietMode(options)) {
|
||||
process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
clackIntro('OpenChamber Startup');
|
||||
logStatus(result.enabled ? 'success' : 'info', `startup ${result.enabled ? 'enabled' : 'disabled'}`, result.servicePath || undefined);
|
||||
if (typeof result.activeState === 'string') {
|
||||
logStatus(result.active ? 'success' : result.activeState === 'failed' ? 'error' : 'warning', `service ${result.activeState}`);
|
||||
}
|
||||
if (normalized === 'enable') {
|
||||
logStatus('info', 'service command', 'openchamber serve --foreground');
|
||||
}
|
||||
clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
|
||||
},
|
||||
|
||||
async update(options = {}) {
|
||||
const showOutput = shouldRenderHumanOutput(options);
|
||||
const updateSpin = createSpinner(options);
|
||||
@@ -4843,7 +5277,7 @@ const commands = {
|
||||
|
||||
async function main() {
|
||||
const parsed = parseArgs();
|
||||
const { command, subcommand, tunnelAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
|
||||
const { command, subcommand, tunnelAction, startupAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
|
||||
activeCommandOptions = options;
|
||||
|
||||
if (versionRequested) {
|
||||
@@ -4875,6 +5309,8 @@ async function main() {
|
||||
if (helpRequested) {
|
||||
if (command === 'tunnel') {
|
||||
showTunnelHelp();
|
||||
} else if (command === 'startup') {
|
||||
showStartupHelp();
|
||||
} else {
|
||||
showHelp();
|
||||
}
|
||||
@@ -4886,8 +5322,13 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'startup') {
|
||||
await commands.startup(options, startupAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!commands[command]) {
|
||||
const knownCommands = ['serve', 'stop', 'restart', 'status', 'tunnel', 'logs', 'update'];
|
||||
const knownCommands = ['serve', 'stop', 'restart', 'status', 'tunnel', 'startup', 'logs', 'update'];
|
||||
const suggestion = findClosestMatch(command, knownCommands);
|
||||
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
||||
if (isJsonMode(options)) {
|
||||
|
||||
Reference in New Issue
Block a user