import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { DEFAULT_PORT } from './cli-args.js';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { getDataDir } from './cli-paths.js';
import { hasUiPasswordConfigured } from './cli-network.js';
import { searchPathFor } from './cli-executables.js';
const STARTUP_SERVICE_ID = 'dev.openchamber.web';
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, ''');
}
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 getWindowsStartupWrapperPath() {
return path.join(getDataDir(), 'bin', 'OpenChamber.ps1');
}
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 (options.apiOnly === true) {
env.OPENCHAMBER_API_ONLY = 'true';
}
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);
}
if (options.apiOnly === true) {
args.push('--api-only');
}
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 writeWindowsStartupWrapper(options = {}) {
const wrapperPath = getWindowsStartupWrapperPath();
const envFilePath = getStartupEnvFilePath();
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(' ');
const ps1Content = [
`$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('; ');
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(wrapperPath, ps1Content, { mode: 0o700 });
return wrapperPath;
}
function buildWindowsStartupTaskCommand(wrapperPath) {
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${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) => ` ${escapeXml(arg)}`).join('\n');
const envXml = Object.entries(env).length > 0
? ` EnvironmentVariables\n \n${Object.entries(env).map(([key, value]) => ` ${escapeXml(key)}\n ${escapeXml(value)}`).join('\n')}\n \n`
: '';
return `
Label
${STARTUP_SERVICE_ID}
ProgramArguments
${argXml}
${envXml} ProcessType
Background
RunAtLoad
KeepAlive
WorkingDirectory
${escapeXml(os.homedir())}
StandardOutPath
${escapeXml(path.join(logDir, 'startup.log'))}
StandardErrorPath
${escapeXml(path.join(logDir, 'startup.err.log'))}
`;
}
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 parseLingerState(stdout) {
if (typeof stdout !== 'string') {
return null;
}
const match = stdout.match(/^Linger=([A-Za-z]+)\s*$/m);
if (!match) {
return null;
}
const value = match[1].toLowerCase();
if (value === 'yes') return true;
if (value === 'no') return false;
return null;
}
function getCurrentUsername() {
try {
const name = os.userInfo().username;
if (typeof name === 'string' && name.length > 0) {
return name;
}
} catch {
// userInfo() can throw when the uid has no passwd entry; fall back to env.
}
return process.env.USER || process.env.LOGNAME || '';
}
// A systemd --user service only keeps running without an active login session
// when the user has lingering enabled. Detect it so `startup enable` can warn
// that the service may otherwise stop on logout. Returns null when the state
// cannot be determined (no username, loginctl unavailable, or odd output).
function getUserLingerEnabled(user) {
if (!user) {
return null;
}
let result;
try {
result = runStartupCommand('loginctl', ['show-user', user, '-p', 'Linger'], { allowFailure: true });
} catch {
return null;
}
if (result.status !== 0) {
return null;
}
return parseLingerState(result.stdout);
}
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';
const lingerUser = getCurrentUsername();
return {
supported: true,
platform: paths.platform,
enabled: enabledResult.status === 0 || fs.existsSync(paths.servicePath),
active: activeState === 'active',
activeState,
servicePath: paths.servicePath,
lingerEnabled: getUserLingerEnabled(lingerUser),
lingerUser: lingerUser || null,
};
}
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();
}
writeStartupEnvFile(options);
const wrapperPath = writeWindowsStartupWrapper(options);
const taskCommand = buildWindowsStartupTaskCommand(wrapperPath);
runStartupCommand('schtasks.exe', [
'/Create',
'/TN', STARTUP_SERVICE_ID,
'/SC', 'ONLOGON',
'/RL', 'LIMITED',
'/F',
'/TR', taskCommand,
]);
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 });
try { fs.unlinkSync(getWindowsStartupWrapperPath()); } catch {}
removeStartupEnvFile();
return getStartupStatus();
}
export {
getStartupStatus,
enableStartupService,
disableStartupService,
buildWindowsStartupTaskCommand,
};