diff --git a/README.fr.md b/README.fr.md index 1826e34..0b80079 100644 --- a/README.fr.md +++ b/README.fr.md @@ -42,6 +42,11 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle ## Démarrage rapide +Deux chemins, selon ce que vous voulez : + +- **Juste l'utiliser (la plupart des gens).** Arboretum est un paquet npm publié — vous **n'avez pas besoin de cloner ce dépôt**. Pointez npm vers le registre et lancez-le (ci-dessous). À faire sur la machine où tournent vos sessions Claude Code. +- **Lancer depuis les sources.** Ne clonez le dépôt que pour développer Arboretum ou lancer une version non publiée. + ### Le lancer (recommandé) Arboretum est publié sur un registre npm Gitea auto-hébergé. Pointez le scope `@johanleroy` dessus une fois par machine — ajoutez à `~/.npmrc` : @@ -70,8 +75,17 @@ Au premier démarrage, Arboretum affiche un **token d'accès** unique et l'URL Ouvrez l'URL, collez le token pour vous connecter, et c'est parti. Le token est stocké **hashé** — il n'est affiché qu'une seule fois, alors gardez-le en lieu sûr (un gestionnaire de mots de passe). Vous pourrez gérer vos tokens plus tard depuis les **Réglages**. +`npx` télécharge et lance la dernière version publiée à chaque fois. Pour l'installer une bonne fois — et obtenir la commande `arboretum` sur votre `PATH`, dont se sert le [service d'arrière-plan](#le-faire-tourner-en-service-darrière-plan) —, installez-le plutôt globalement : + +```bash +npm i -g @johanleroy/git-arboretum +arboretum # identique à la commande npx, depuis le binaire installé +``` + ### Lancer depuis les sources +Nécessaire uniquement pour **développer** Arboretum ou lancer une version non publiée — pas pour simplement l'utiliser. Clonez le dépôt, installez les dépendances, buildez, puis démarrez le daemon : + ```bash git clone https://git.lidge.fr/johanleroy/arboretum.git cd arboretum @@ -110,7 +124,26 @@ Ouvrez `https://..ts.net` depuis n'importe quel appareil de vo ## Le faire tourner en service d'arrière-plan -Pour un daemon qui survit à la déconnexion et redémarre au boot, lancez-le via un **service systemd utilisateur**. Installez une version figée globalement (`npm i -g @johanleroy/git-arboretum`), puis créez `~/.config/systemd/user/arboretum.service` : +Le plus rapide pour faire tourner Arboretum en service qui survit à la déconnexion et redémarre au boot, c'est l'installeur intégré. Installez une version figée globalement, puis lancez `install` — il détecte votre OS, écrit le fichier de service, le démarre et affiche le token unique : + +```bash +npm i -g @johanleroy/git-arboretum +arboretum install --allow-origin https://MACHINE.TAILNET.ts.net +``` + +Cela met en place un **service systemd utilisateur** sous Linux (`~/.config/systemd/user/arboretum.service`) ou un **LaunchAgent launchd** sous macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Tous les flags du daemon (`--port`, `--allow-origin`, `--db`, …) sont propagés au service. Gérez-le avec : + +```bash +arboretum status # état du service (+ où lire les logs) +arboretum uninstall # arrête et supprime le service +``` + +Les logs vivent dans `journalctl --user -u arboretum -f` (Linux) ou `~/Library/Logs/arboretum/` (macOS). Lancez d'abord `arboretum install --dry-run …` pour afficher le unit/plist et les commandes exactes sans rien modifier. + +
+Vous préférez configurer systemd à la main ? (Linux) + +Créez `~/.config/systemd/user/arboretum.service` : ```ini [Unit] @@ -137,12 +170,15 @@ systemctl --user enable --now arboretum loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte journalctl --user -u arboretum -f # logs ``` +
-> Le **token d'accès** unique n'est affiché qu'au tout premier démarrage (base vierge). En service, récupérez-le depuis `journalctl`, ou faites un lancement manuel avant d'activer le service. Le token est hashé et n'est jamais réaffiché. +> Le **token d'accès** unique est affiché par `arboretum install` (et au tout premier lancement manuel sur base vierge). Le token est hashé et n'est jamais réaffiché — conservez-le en lieu sûr. ## Configuration -Toutes les options sont des flags CLI : +Commandes : `arboretum` démarre le daemon (par défaut), `arboretum serve` en est un alias explicite, `arboretum install` / `uninstall` / `status` gèrent le service d'arrière-plan, et `arboretum help` affiche l'aide. + +Les options du daemon sont des flags CLI : | Flag | Défaut | Description | |---|---|---| @@ -154,6 +190,15 @@ Toutes les options sont des flags CLI : | `--print-token` | `false` | Indication sur le réaffichage du token (les tokens sont hashés et ne peuvent pas être réaffichés). | | `--i-know-this-exposes-a-terminal` | `false` | Reconnaître le bind sur une adresse non-loopback. **À éviter** — préférez Tailscale Serve. | +`arboretum install` accepte tous les flags du daemon ci-dessus (propagés tels quels au service), plus : + +| Flag | Description | +|---|---| +| `--bin-path ` | Utilise ce binaire dans le service au lieu de `node` + le script embarqué. | +| `--label ` | Label launchd (macOS uniquement, défaut `fr.lidge.arboretum`). | +| `--dry-run` | Affiche le unit/plist et les commandes sans rien appliquer. | +| `--no-enable` | Écrit le fichier de service sans l'activer/le démarrer. | + L'état (la base SQLite) vit dans `$XDG_DATA_HOME/arboretum` (par défaut `~/.local/share/arboretum`). ## Modèle de sécurité @@ -162,9 +207,9 @@ Un terminal web, c'est de l'exécution de code à distance *par conception*. Les - Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite. - Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking). -- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC. Le login est rate-limité avec backoff exponentiel. +- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel. -La façon recommandée d'atteindre Arboretum depuis d'autres appareils est Tailscale Serve (HTTPS valide, identité tailnet, aucun port ouvert). Ne l'exposez jamais directement sur internet. +Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet. ## Ce qui le distingue diff --git a/README.md b/README.md index 6ee029e..f525714 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore ## Quick start +Two paths, depending on what you want: + +- **Just use it (most people).** Arboretum is a published npm package — you **don't need to clone this repo**. Point npm at the registry and run it (below). Do this on the machine where your Claude Code sessions run. +- **Run from source.** Clone the repo only to hack on Arboretum or run an unreleased build. + ### Run it (recommended) Arboretum is published to a self-hosted Gitea npm registry. Point the `@johanleroy` scope at it once per machine — add to `~/.npmrc`: @@ -70,8 +75,17 @@ On first start, Arboretum prints a one-time **access token** and the URL to open Open the URL, paste the token to log in, and you're in. The token is stored **hashed** — it is shown only once, so save it somewhere safe (a password manager). You can manage tokens later from **Settings**. +`npx` fetches and runs the latest published version each time. To install it once — and get the `arboretum` command on your `PATH`, which the [background service](#running-it-as-a-background-service) relies on — install it globally instead: + +```bash +npm i -g @johanleroy/git-arboretum +arboretum # identical to the npx command, from the installed binary +``` + ### Run from source +Only needed to **develop** Arboretum or run an unreleased build — not required just to use it. Clone the repo, install dependencies, build, then start the daemon: + ```bash git clone https://git.lidge.fr/johanleroy/arboretum.git cd arboretum @@ -110,7 +124,26 @@ Open `https://..ts.net` from any device on your tailnet. **Web ## Running it as a background service -For a daemon that survives logout and restarts on boot, run it under a **systemd user service**. Install a pinned version globally (`npm i -g @johanleroy/git-arboretum`), then create `~/.config/systemd/user/arboretum.service`: +The quickest way to run Arboretum as a service that survives logout and restarts on boot is the built-in installer. Install a pinned version globally, then run `install` — it detects your OS, writes the service file, starts it, and prints the one-time token: + +```bash +npm i -g @johanleroy/git-arboretum +arboretum install --allow-origin https://MACHINE.TAILNET.ts.net +``` + +This sets up a **systemd user service** on Linux (`~/.config/systemd/user/arboretum.service`) or a **launchd LaunchAgent** on macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Every daemon flag (`--port`, `--allow-origin`, `--db`, …) is propagated to the service. Manage it with: + +```bash +arboretum status # service status (+ where to read logs) +arboretum uninstall # stop and remove the service +``` + +Logs live in `journalctl --user -u arboretum -f` (Linux) or `~/Library/Logs/arboretum/` (macOS). Run `arboretum install --dry-run …` first to print the unit/plist and the exact commands without touching anything. + +
+Prefer to set up systemd by hand? (Linux) + +Create `~/.config/systemd/user/arboretum.service`: ```ini [Unit] @@ -137,12 +170,15 @@ systemctl --user enable --now arboretum loginctl enable-linger "$USER" # start the service at boot, without an open session journalctl --user -u arboretum -f # logs ``` +
-> The one-time **access token is printed only on the very first run** (empty database). When running as a service, capture it from `journalctl`, or do one manual run before enabling the service. The token is hashed and never shown again. +> The one-time **access token is printed by `arboretum install`** (and on the very first manual run with an empty database). The token is hashed and never shown again — store it safely. ## Configuration -All options are CLI flags: +Commands: `arboretum` starts the daemon (the default), `arboretum serve` is an explicit alias, `arboretum install` / `uninstall` / `status` manage the background service, and `arboretum help` prints usage. + +Daemon options are CLI flags: | Flag | Default | Description | |---|---|---| @@ -154,6 +190,15 @@ All options are CLI flags: | `--print-token` | `false` | Hint about token re-printing (tokens are hashed and cannot be re-shown). | | `--i-know-this-exposes-a-terminal` | `false` | Acknowledge binding to a non-loopback address. **Avoid** — prefer Tailscale Serve. | +`arboretum install` accepts every daemon flag above (propagated verbatim to the service) plus: + +| Flag | Description | +|---|---| +| `--bin-path ` | Use this binary in the service instead of `node` + the bundled script. | +| `--label ` | launchd label (macOS only, default `fr.lidge.arboretum`). | +| `--dry-run` | Print the unit/plist and commands without applying anything. | +| `--no-enable` | Write the service file but do not enable/start it. | + State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum` (default `~/.local/share/arboretum`). ## Security model @@ -162,9 +207,9 @@ A web terminal is remote code execution *by design*. Arboretum's guardrails are - Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag. - Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard). -- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload. Login is rate-limited with exponential backoff. +- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff. -The recommended way to reach Arboretum from other devices is Tailscale Serve (valid HTTPS, tailnet identity, no open ports). Never expose it directly to the internet. +Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet. ## What makes it different diff --git a/packages/server/src/cli/install.ts b/packages/server/src/cli/install.ts new file mode 100644 index 0000000..4021098 --- /dev/null +++ b/packages/server/src/cli/install.ts @@ -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): { 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; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b1afe5c..75301ed 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 { - const config = loadConfig(); +/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */ +export async function runDaemon(config: Config): Promise { const db = openDb(config.dbPath); const { app, auth, manager, discovery } = buildApp(config, db, pkg.version); @@ -23,11 +24,7 @@ async function main(): Promise { 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 { 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 { + 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); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 5fbf784..0fc15fb 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -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 }); }); } diff --git a/packages/server/test/app.e2e.test.ts b/packages/server/test/app.e2e.test.ts index 4902264..d753010 100644 --- a/packages/server/test/app.e2e.test.ts +++ b/packages/server/test/app.e2e.test.ts @@ -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 }); diff --git a/packages/server/test/cli-install.test.ts b/packages/server/test/cli-install.test.ts new file mode 100644 index 0000000..eabbbd2 --- /dev/null +++ b/packages/server/test/cli-install.test.ts @@ -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('fr.lidge.arboretum'); + const idxNode = plist.indexOf('/usr/bin/node'); + const idxScript = plist.indexOf('/opt/index.js'); + expect(idxNode).toBeGreaterThan(0); + expect(idxScript).toBeGreaterThan(idxNode); + expect(plist).toContain('RunAtLoad'); + expect(plist).toContain('KeepAlive'); + expect(plist).toContain('StandardOutPath'); + }); + + 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&c=d'); + expect(plist).not.toContain('b&c=d'); + }); +}); + +describe('cli install — xmlEscape', () => { + it('échappe &, < et >', () => { + expect(xmlEscape('a & b < c > d')).toBe('a & b < c > 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'), + ); + }); +});