feat: cookie Secure conditionnel + sous-commande d'installation de service
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled

Cookie: routes/auth.ts pose `secure` sur le cookie de session quand la requête
arrive en HTTPS (x-forwarded-proto), sans trustProxy — durcit le cookie derrière
Tailscale Serve sans casser le localhost http.

Install: nouveau cli/install.ts + routeur de sous-commandes dans index.ts
(install/uninstall/status/serve). Service utilisateur systemd (Linux) ou launchd
(macOS), bootstrap du token, --dry-run/--no-enable. Rétrocompat stricte du daemon
par défaut (runDaemon extrait).

Tests: app.e2e (cookie Secure local vs HTTPS) + cli-install (fonctions pures).
203/203 verts, acceptation P1/P4 vertes.

Docs: README.md + README.fr.md (installeur multi-OS, distinction utiliser/cloner,
modèle de sécurité durci).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-17 16:17:08 +02:00
parent e2147e369b
commit 7f42743fce
7 changed files with 694 additions and 21 deletions

View File

@@ -0,0 +1,363 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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) => ` <string>${xmlEscape(a)}</string>`).join('\n');
// 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>
</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];
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<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;
}

View File

@@ -2,16 +2,17 @@
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.js';
import { loadConfig, type Config } from './config.js';
import { openDb } from './db/index.js';
import { buildApp } from './app.js';
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
const pkg = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
) as { version: string };
async function main(): Promise<void> {
const config = loadConfig();
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
export async function runDaemon(config: Config): Promise<void> {
const db = openDb(config.dbPath);
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
@@ -23,11 +24,7 @@ async function main(): Promise<void> {
app.log.info(`Arboretum v${pkg.version}${url}`);
if (bootstrapToken) {
// Affiché une seule fois : le hash seul est stocké.
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
console.log('│ First start — your access token (shown once, store it safely): │');
console.log('└──────────────────────────────────────────────────────────────────┘');
console.log(`\n ${bootstrapToken}\n`);
console.log(` Login at: ${url}/\n`);
printTokenBanner(bootstrapToken, url);
} else if (config.printToken) {
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
}
@@ -47,6 +44,36 @@ async function main(): Promise<void> {
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
// Routeur de sous-commandes. On inspecte argv[0] AVANT loadConfig (parseArgs strict throw sur
// un positionnel inconnu). Aucune sous-commande (ou un flag en tête) → daemon : rétrocompat stricte
// de `arboretum`, `arboretum --port 8080`, `npx @johanleroy/git-arboretum --allow-origin …`.
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const cmd = argv[0];
switch (cmd) {
case 'install':
return runInstall(argv.slice(1));
case 'uninstall':
return runUninstall(argv.slice(1));
case 'status':
return runStatus(argv.slice(1));
case 'serve':
return runDaemon(loadConfig(argv.slice(1)));
case 'help':
case '--help':
case '-h':
printUsage(pkg.version);
return;
default:
if (cmd && !cmd.startsWith('-')) {
console.error(`Unknown command: ${cmd}\n`);
printUsage(pkg.version);
process.exit(1);
}
return runDaemon(loadConfig(argv));
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);

View File

@@ -1,7 +1,16 @@
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
function isHttpsRequest(req: FastifyRequest): boolean {
const xfp = req.headers['x-forwarded-proto'];
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
return proto === 'https';
}
export function registerAuthRoutes(
app: FastifyInstance,
auth: AuthService,
@@ -24,6 +33,7 @@ export function registerAuthRoutes(
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: isHttpsRequest(req),
maxAge: 30 * 24 * 3600,
});
const res: LoginResponse = { ok: true, label: ctx.label };
@@ -35,8 +45,9 @@ export function registerAuthRoutes(
return reply.send(res);
});
app.post('/api/v1/auth/logout', async (_req, reply) => {
void reply.clearCookie(auth.cookieName, { path: '/' });
app.post('/api/v1/auth/logout', async (req, reply) => {
// Les attributs doivent matcher ceux posés au login pour que le navigateur efface bien le cookie.
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
return reply.send({ ok: true });
});
}

View File

@@ -91,9 +91,25 @@ describe('app e2e — auth, origin et sessions', () => {
expect(cookie).toBeDefined();
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe('Strict');
// Login local (http, pas de x-forwarded-proto) → pas de Secure, sinon le cookie casserait en localhost.
expect(cookie?.secure).toBeFalsy();
cookieValue = cookie!.value;
});
it('cookie Secure posé derrière un front HTTPS (x-forwarded-proto)', async () => {
const res = await t.bundle.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'x-forwarded-proto': 'https' },
payload: { token: t.token },
});
expect(res.statusCode).toBe(200);
const cookie = res.cookies.find((c) => c.name === 'arb_session');
expect(cookie?.secure).toBe(true);
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe('Strict');
});
it('routes API sans auth → 401', async () => {
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
const res = await t.bundle.app.inject({ method: 'GET', url });

View File

@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it } from 'vitest';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
detectPlatform,
parseInstallArgs,
buildServiceArgs,
resolveBin,
resolveScriptPath,
renderSystemdUnit,
renderLaunchAgentPlist,
xmlEscape,
systemdUnitPath,
launchAgentPlistPath,
} from '../src/cli/install.js';
describe('cli install — detectPlatform', () => {
it('accepte linux et darwin', () => {
expect(detectPlatform('linux')).toBe('linux');
expect(detectPlatform('darwin')).toBe('darwin');
});
it('rejette les autres plateformes avec un message clair', () => {
expect(() => detectPlatform('win32')).toThrow(/Linux \(systemd\) and macOS \(launchd\)/);
expect(() => detectPlatform('freebsd')).toThrow(/freebsd/);
});
});
describe('cli install — buildServiceArgs', () => {
it('aucun flag → aucun argument (le service garde les défauts loopback)', () => {
expect(buildServiceArgs(parseInstallArgs([]))).toEqual([]);
});
it('propage --port et --allow-origin (répétable) dans un ordre stable', () => {
const flags = parseInstallArgs(['--port', '8080', '--allow-origin', 'a', '--allow-origin', 'b']);
expect(buildServiceArgs(flags)).toEqual(['--port', '8080', '--allow-origin', 'a', '--allow-origin', 'b']);
});
it("n'injecte JAMAIS --i-know-this-exposes-a-terminal (modèle de sécurité)", () => {
const flags = parseInstallArgs(['--bind', '0.0.0.0', '--port', '7317']);
expect(buildServiceArgs(flags)).not.toContain('--i-know-this-exposes-a-terminal');
});
it("ne propage pas les flags propres à l'install (bin-path, label, dry-run, no-enable)", () => {
const flags = parseInstallArgs(['--bin-path', '/usr/local/bin/arboretum', '--label', 'x', '--dry-run', '--no-enable']);
expect(buildServiceArgs(flags)).toEqual([]);
});
});
describe('cli install — resolveBin', () => {
it('par défaut : node (process.execPath) + dist/index.js', () => {
const { exec, args } = resolveBin({});
expect(exec).toBe(process.execPath);
expect(args).toHaveLength(1);
expect(args[0]).toBe(resolveScriptPath());
expect(args[0]).toMatch(/index\.(js|ts)$/);
});
it('--bin-path force le wrapper, sans argument de script', () => {
expect(resolveBin({ binPath: '/usr/local/bin/arboretum' })).toEqual({
exec: '/usr/local/bin/arboretum',
args: [],
});
});
});
describe('cli install — renderSystemdUnit', () => {
it('contient le ExecStart calculé et les directives clés', () => {
const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/opt/arboretum/index.js', '--port', '7317'] });
expect(unit).toContain('ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317');
expect(unit).toContain('Restart=on-failure');
expect(unit).toContain('KillSignal=SIGTERM');
expect(unit).toContain('TimeoutStopSec=10');
expect(unit).toContain('WantedBy=default.target');
});
it('quote les tokens contenant un espace', () => {
const unit = renderSystemdUnit({ exec: '/path with space/node', scriptArgs: ['/s.js'] });
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
});
it('snapshot du unit pour un jeu de flags fixe', () => {
const unit = renderSystemdUnit({
exec: '/usr/bin/node',
scriptArgs: ['/opt/arboretum/index.js', '--port', '7317', '--allow-origin', 'https://m.ts.net'],
});
expect(unit).toMatchInlineSnapshot(`
"[Unit]
Description=Arboretum — git worktree & Claude Code dashboard
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317 --allow-origin https://m.ts.net
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=10
Environment=NODE_ENV=production
[Install]
WantedBy=default.target
"
`);
});
});
describe('cli install — renderLaunchAgentPlist', () => {
const base = {
label: 'fr.lidge.arboretum',
programArguments: ['/usr/bin/node', '/opt/index.js', '--port', '7317'],
stdoutPath: '/Users/me/Library/Logs/arboretum/out.log',
stderrPath: '/Users/me/Library/Logs/arboretum/err.log',
};
it('contient le label, les ProgramArguments ordonnés et les clés launchd', () => {
const plist = renderLaunchAgentPlist(base);
expect(plist).toContain('<string>fr.lidge.arboretum</string>');
const idxNode = plist.indexOf('<string>/usr/bin/node</string>');
const idxScript = plist.indexOf('<string>/opt/index.js</string>');
expect(idxNode).toBeGreaterThan(0);
expect(idxScript).toBeGreaterThan(idxNode);
expect(plist).toContain('<key>RunAtLoad</key>');
expect(plist).toContain('<key>KeepAlive</key>');
expect(plist).toContain('<key>StandardOutPath</key>');
});
it('échappe les caractères XML dans les arguments (URL avec &)', () => {
const plist = renderLaunchAgentPlist({
...base,
programArguments: ['/usr/bin/node', '/opt/index.js', '--allow-origin', 'https://a?b&c=d'],
});
expect(plist).toContain('https://a?b&amp;c=d');
expect(plist).not.toContain('b&c=d');
});
});
describe('cli install — xmlEscape', () => {
it('échappe &, < et >', () => {
expect(xmlEscape('a & b < c > d')).toBe('a &amp; b &lt; c &gt; d');
});
});
describe('cli install — chemins', () => {
const savedXdg = process.env.XDG_CONFIG_HOME;
afterEach(() => {
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = savedXdg;
});
it('systemdUnitPath respecte XDG_CONFIG_HOME', () => {
process.env.XDG_CONFIG_HOME = '/tmp/xdg';
expect(systemdUnitPath()).toBe('/tmp/xdg/systemd/user/arboretum.service');
});
it('systemdUnitPath retombe sur ~/.config sans XDG_CONFIG_HOME', () => {
delete process.env.XDG_CONFIG_HOME;
expect(systemdUnitPath()).toBe(join(homedir(), '.config', 'systemd', 'user', 'arboretum.service'));
});
it('launchAgentPlistPath place le plist dans ~/Library/LaunchAgents', () => {
expect(launchAgentPlistPath('fr.lidge.arboretum')).toBe(
join(homedir(), 'Library', 'LaunchAgents', 'fr.lidge.arboretum.plist'),
);
});
});