Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts). Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
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<InstallFlags, 'binPath'>): { 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, '<').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[]; pathEnv?: string | undefined }): string {
|
|
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
|
|
// Un service systemd user démarre avec un PATH minimal (typiquement /usr/bin:/bin…) qui n'inclut
|
|
// PAS ~/.local/bin ni le bin npm/nvm où vit le CLI `claude` → resolveClaudeBin() (`which claude`)
|
|
// échouerait. On fige donc le PATH de l'environnement d'installation, où `claude` est résolvable.
|
|
// Doubles quotes systemd : tolèrent un chemin du PATH contenant des espaces.
|
|
const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : '';
|
|
// 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${pathLine}
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
`;
|
|
}
|
|
|
|
export function renderLaunchAgentPlist(input: {
|
|
label: string;
|
|
programArguments: string[];
|
|
stdoutPath: string;
|
|
stderrPath: string;
|
|
pathEnv?: string | undefined;
|
|
}): string {
|
|
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
// launchd démarre un LaunchAgent avec un PATH minimal qui n'inclut pas ~/.local/bin ni le bin
|
|
// npm/nvm où vit le CLI `claude` → resolveClaudeBin() échouerait. On fige le PATH d'installation.
|
|
const pathEntry = input.pathEnv
|
|
? `\n <key>PATH</key>\n <string>${xmlEscape(input.pathEnv)}</string>`
|
|
: '';
|
|
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
|
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>${xmlEscape(input.label)}</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
${args}
|
|
</array>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<dict>
|
|
<key>SuccessfulExit</key>
|
|
<false/>
|
|
</dict>
|
|
<key>StandardOutPath</key>
|
|
<string>${xmlEscape(input.stdoutPath)}</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>${xmlEscape(input.stderrPath)}</string>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>NODE_ENV</key>
|
|
<string>production</string>${pathEntry}
|
|
</dict>
|
|
</dict>
|
|
</plist>
|
|
`;
|
|
}
|
|
|
|
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 <n> Port to listen on (default 7317)
|
|
--bind <addr> Bind address (default 127.0.0.1)
|
|
--allow-origin <url> Additional allowed Origin (repeatable)
|
|
--db <path> SQLite database path
|
|
--vapid-contact <mailto|url> 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 <path> Use this binary in the service instead of node + script
|
|
--label <id> 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<void> {
|
|
const platform = detectPlatform();
|
|
const flags = parseInstallArgs(argv);
|
|
const serviceArgs = buildServiceArgs(flags);
|
|
const { exec, args: binArgs } = resolveBin(flags);
|
|
const scriptArgs = [...binArgs, ...serviceArgs];
|
|
// PATH de l'environnement d'installation (shell interactif où `claude` est résolvable) : on le
|
|
// fige dans l'unit/plist car systemd/launchd démarrent le service avec un PATH minimal.
|
|
const pathEnv = process.env.PATH;
|
|
|
|
if (platform === 'linux') {
|
|
const unit = renderSystemdUnit({ exec, scriptArgs, pathEnv });
|
|
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,
|
|
pathEnv,
|
|
});
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|