import { parseArgs } from 'node:util'; import { spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config.js'; import { openDb } from '../db/index.js'; import { AuthService } from '../auth/service.js'; // Nom de l'unit systemd (Linux) et label launchd par défaut (macOS, surchargeable via --label). const SERVICE_NAME = 'arboretum'; const LAUNCHD_LABEL = 'fr.lidge.arboretum'; export type SupportedPlatform = 'linux' | 'darwin'; export interface InstallFlags { port?: string | undefined; bind?: string | undefined; allowOrigin: string[]; db?: string | undefined; vapidContact?: string | undefined; claudeHome?: string | undefined; binPath?: string | undefined; label: string; dryRun: boolean; noEnable: boolean; } // ─── Fonctions pures (génération de contenu / chemins) ──────────────────────────────── /** macOS (launchd) et Linux (systemd) uniquement ; sinon throw avec un message pédagogique. */ export function detectPlatform(platform: NodeJS.Platform = process.platform): SupportedPlatform { if (platform === 'linux' || platform === 'darwin') return platform; throw new Error( `Automatic service installation is supported on Linux (systemd) and macOS (launchd) only.\n` + `On ${platform}, run \`arboretum\` manually or set up your own supervisor.`, ); } export function parseInstallArgs(argv: string[]): InstallFlags { const { values } = parseArgs({ args: argv, options: { port: { type: 'string' }, bind: { type: 'string' }, 'allow-origin': { type: 'string', multiple: true }, db: { type: 'string' }, 'vapid-contact': { type: 'string' }, 'claude-home': { type: 'string' }, 'bin-path': { type: 'string' }, label: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, 'no-enable': { type: 'boolean', default: false }, }, strict: true, }); return { port: values.port, bind: values.bind, allowOrigin: values['allow-origin'] ?? [], db: values.db, vapidContact: values['vapid-contact'], claudeHome: values['claude-home'], binPath: values['bin-path'], label: values.label ?? LAUNCHD_LABEL, dryRun: values['dry-run'] ?? false, noEnable: values['no-enable'] ?? false, }; } /** * Flags propagés au service : UNIQUEMENT ceux fournis par l'utilisateur (ordre stable, * ExecStart déterministe). On n'ajoute JAMAIS --i-know-this-exposes-a-terminal automatiquement * (cf. modèle de sécurité : un service exposé doit être un choix conscient et explicite). */ export function buildServiceArgs(flags: InstallFlags): string[] { const args: string[] = []; if (flags.port) args.push('--port', flags.port); if (flags.bind) args.push('--bind', flags.bind); for (const origin of flags.allowOrigin) args.push('--allow-origin', origin); if (flags.db) args.push('--db', flags.db); if (flags.vapidContact) args.push('--vapid-contact', flags.vapidContact); if (flags.claudeHome) args.push('--claude-home', flags.claudeHome); return args; } /** Le `dist/index.js` réellement installé (depuis dist/cli/install.js). */ export function resolveScriptPath(): string { return fileURLToPath(new URL('../index.js', import.meta.url)); } /** * Cible exécutable du service. Par défaut node + script (immunisé contre un PATH minimal sous * systemd/launchd) ; --bin-path force le wrapper `arboretum` global (suit les upgrades npm i -g). */ export function resolveBin(flags: Pick): { exec: string; args: string[] } { if (flags.binPath) return { exec: flags.binPath, args: [] }; return { exec: process.execPath, args: [resolveScriptPath()] }; } export function systemdUnitPath(): string { const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'); return join(configHome, 'systemd', 'user', `${SERVICE_NAME}.service`); } export function launchAgentPlistPath(label: string): string { return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`); } export function launchdLogPaths(): { dir: string; out: string; err: string } { const dir = join(homedir(), 'Library', 'Logs', 'arboretum'); return { dir, out: join(dir, 'out.log'), err: join(dir, 'err.log') }; } export function xmlEscape(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } // systemd accepte les doubles quotes dans ExecStart ; on ne quote que les tokens à espaces. function quoteIfNeeded(token: string): string { return /\s/.test(token) ? `"${token}"` : token; } export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string { const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' '); // KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close). return `[Unit] Description=Arboretum — git worktree & Claude Code dashboard After=network-online.target Wants=network-online.target [Service] ExecStart=${execStart} Restart=on-failure RestartSec=5 KillSignal=SIGTERM TimeoutStopSec=10 Environment=NODE_ENV=production [Install] WantedBy=default.target `; } export function renderLaunchAgentPlist(input: { label: string; programArguments: string[]; stdoutPath: string; stderrPath: string; }): string { const args = input.programArguments.map((a) => ` ${xmlEscape(a)}`).join('\n'); // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire). return ` Label ${xmlEscape(input.label)} ProgramArguments ${args} RunAtLoad KeepAlive SuccessfulExit StandardOutPath ${xmlEscape(input.stdoutPath)} StandardErrorPath ${xmlEscape(input.stderrPath)} EnvironmentVariables NODE_ENV production `; } export function printTokenBanner(token: string, url: string): void { console.log('\n┌──────────────────────────────────────────────────────────────────┐'); console.log('│ First start — your access token (shown once, store it safely): │'); console.log('└──────────────────────────────────────────────────────────────────┘'); console.log(`\n ${token}\n`); console.log(` Login at: ${url}/\n`); } export function printUsage(version: string): void { console.log(`Arboretum v${version} — git worktree & Claude Code dashboard Usage: arboretum [flags] Start the daemon (default) arboretum serve [flags] Start the daemon (explicit alias) arboretum install [flags] Install & start a user service (systemd on Linux, launchd on macOS) arboretum uninstall Stop & remove the user service arboretum status Show the service status arboretum help Show this help Daemon flags: --port Port to listen on (default 7317) --bind Bind address (default 127.0.0.1) --allow-origin Additional allowed Origin (repeatable) --db SQLite database path --vapid-contact VAPID contact subject for Web Push --i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid — prefer Tailscale Serve) Install flags (daemon flags above are propagated to the service): --bin-path Use this binary in the service instead of node + script --label launchd label (macOS only, default ${LAUNCHD_LABEL}) --dry-run Print the unit/plist and commands without applying anything --no-enable Write the service file but do not enable/start it `); } // ─── Effets de bord (fs + exec) ─────────────────────────────────────────────────────── function run(cmd: string, args: string[], opts?: { check?: boolean }): number { const res = spawnSync(cmd, args, { stdio: 'inherit' }); if (res.error) { if ((res.error as NodeJS.ErrnoException).code === 'ENOENT') { throw new Error(`Command not found: ${cmd}. Is it installed and on your PATH?`); } throw res.error; } const code = res.status ?? 0; if (opts?.check && code !== 0) { throw new Error(`Command failed (exit ${code}): ${cmd} ${args.join(' ')}`); } return code; } /** * Bootstrap du token avec EXACTEMENT les flags du service (même db). Valide aussi le bind * (garde-fou de loadConfig). Affiche le token une fois, puis ferme la db avant que le service * ne l'ouvre. Skippé en --dry-run par l'appelant. */ function bootstrapToken(serviceArgs: string[]): void { const config = loadConfig(serviceArgs); const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`; const db = openDb(config.dbPath); try { const token = new AuthService(db).ensureBootstrapToken(); if (token) printTokenBanner(token, url); else console.log('\nAn access token already exists in this database — manage tokens from Settings.\n'); } finally { db.close(); } } export async function runInstall(argv: string[]): Promise { const platform = detectPlatform(); const flags = parseInstallArgs(argv); const serviceArgs = buildServiceArgs(flags); const { exec, args: binArgs } = resolveBin(flags); const scriptArgs = [...binArgs, ...serviceArgs]; if (platform === 'linux') { const unit = renderSystemdUnit({ exec, scriptArgs }); const unitPath = systemdUnitPath(); if (flags.dryRun) { console.log(`# ${unitPath}\n${unit}\n# commands:`); console.log('systemctl --user daemon-reload'); if (!flags.noEnable) { console.log(`systemctl --user enable --now ${SERVICE_NAME}`); console.log(`loginctl enable-linger ${process.env.USER ?? '$USER'}`); } return; } bootstrapToken(serviceArgs); mkdirSync(dirname(unitPath), { recursive: true }); writeFileSync(unitPath, unit); console.log(`Wrote ${unitPath}`); run('systemctl', ['--user', 'daemon-reload'], { check: true }); if (!flags.noEnable) { run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME], { check: true }); // enable-linger best-effort : absent en CI / sans session loginctl, non bloquant. if (run('loginctl', ['enable-linger', process.env.USER ?? '']) !== 0) { console.warn('Warning: could not enable linger — the service may not start at boot.'); } } console.log(`\nArboretum service installed. Logs: journalctl --user -u ${SERVICE_NAME} -f`); return; } // macOS (launchd) const logs = launchdLogPaths(); const programArguments = [exec, ...scriptArgs]; const plist = renderLaunchAgentPlist({ label: flags.label, programArguments, stdoutPath: logs.out, stderrPath: logs.err, }); const plistPath = launchAgentPlistPath(flags.label); const uid = process.getuid?.() ?? 0; const target = `gui/${uid}/${flags.label}`; if (flags.dryRun) { console.log(`# ${plistPath}\n${plist}\n# commands:`); console.log(`launchctl bootout ${target} # best-effort`); if (!flags.noEnable) { console.log(`launchctl bootstrap gui/${uid} ${plistPath}`); console.log(`launchctl enable ${target}`); console.log(`launchctl kickstart -k ${target}`); } return; } bootstrapToken(serviceArgs); mkdirSync(dirname(plistPath), { recursive: true }); mkdirSync(logs.dir, { recursive: true }); writeFileSync(plistPath, plist); console.log(`Wrote ${plistPath}`); if (!flags.noEnable) { run('launchctl', ['bootout', target]); // best-effort : ignore "not loaded" (rend bootstrap idempotent) run('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { check: true }); run('launchctl', ['enable', target]); run('launchctl', ['kickstart', '-k', target]); } console.log(`\nArboretum service installed. Logs: ${logs.out}`); } export async function runUninstall(argv: string[]): Promise { const platform = detectPlatform(); const flags = parseInstallArgs(argv); if (platform === 'linux') { const unitPath = systemdUnitPath(); run('systemctl', ['--user', 'disable', '--now', SERVICE_NAME]); // best-effort if (existsSync(unitPath)) { rmSync(unitPath); console.log(`Removed ${unitPath}`); } run('systemctl', ['--user', 'daemon-reload']); console.log('Arboretum service removed.'); return; } const plistPath = launchAgentPlistPath(flags.label); const uid = process.getuid?.() ?? 0; run('launchctl', ['bootout', `gui/${uid}/${flags.label}`]); // best-effort if (existsSync(plistPath)) { rmSync(plistPath); console.log(`Removed ${plistPath}`); } console.log('Arboretum service removed.'); } export async function runStatus(argv: string[]): Promise { const platform = detectPlatform(); const flags = parseInstallArgs(argv); if (platform === 'linux') { const code = run('systemctl', ['--user', 'status', SERVICE_NAME, '--no-pager']); console.log(`\nLogs: journalctl --user -u ${SERVICE_NAME} -f`); process.exitCode = code; return; } const uid = process.getuid?.() ?? 0; const code = run('launchctl', ['print', `gui/${uid}/${flags.label}`]); console.log(`\nLogs: ${launchdLogPaths().out}`); process.exitCode = code; }