feat: cookie Secure conditionnel + sous-commande d'installation de service

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).
This commit is contained in:
2026-06-17 16:17:08 +02:00
parent 11c45ff060
commit f446a3dda1
7 changed files with 694 additions and 21 deletions

View File

@@ -42,6 +42,11 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
## Démarrage rapide ## 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é) ### 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` : 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**. 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 ### 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 ```bash
git clone https://git.lidge.fr/johanleroy/arboretum.git git clone https://git.lidge.fr/johanleroy/arboretum.git
cd arboretum cd arboretum
@@ -110,7 +124,26 @@ Ouvrez `https://<machine>.<tailnet>.ts.net` depuis n'importe quel appareil de vo
## Le faire tourner en service d'arrière-plan ## 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.
<details>
<summary>Vous préférez configurer systemd à la main ? (Linux)</summary>
Créez `~/.config/systemd/user/arboretum.service` :
```ini ```ini
[Unit] [Unit]
@@ -137,12 +170,15 @@ systemctl --user enable --now arboretum
loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte
journalctl --user -u arboretum -f # logs journalctl --user -u arboretum -f # logs
``` ```
</details>
> 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 ## 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 | | 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). | | `--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. | | `--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 <path>` | Utilise ce binaire dans le service au lieu de `node` + le script embarqué. |
| `--label <id>` | 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`). L'état (la base SQLite) vit dans `$XDG_DATA_HOME/arboretum` (par défaut `~/.local/share/arboretum`).
## Modèle de sécurité ## 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. - 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). - 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 ## Ce qui le distingue

View File

@@ -42,6 +42,11 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
## Quick start ## 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) ### 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`: 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**. 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 ### 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 ```bash
git clone https://git.lidge.fr/johanleroy/arboretum.git git clone https://git.lidge.fr/johanleroy/arboretum.git
cd arboretum cd arboretum
@@ -110,7 +124,26 @@ Open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet. **Web
## Running it as a background service ## 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.
<details>
<summary>Prefer to set up systemd by hand? (Linux)</summary>
Create `~/.config/systemd/user/arboretum.service`:
```ini ```ini
[Unit] [Unit]
@@ -137,12 +170,15 @@ systemctl --user enable --now arboretum
loginctl enable-linger "$USER" # start the service at boot, without an open session loginctl enable-linger "$USER" # start the service at boot, without an open session
journalctl --user -u arboretum -f # logs journalctl --user -u arboretum -f # logs
``` ```
</details>
> 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 ## 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 | | 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). | | `--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. | | `--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 <path>` | Use this binary in the service instead of `node` + the bundled script. |
| `--label <id>` | 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`). State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum` (default `~/.local/share/arboretum`).
## Security model ## 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. - 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). - 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 ## What makes it different

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 { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.js'; import { loadConfig, type Config } from './config.js';
import { openDb } from './db/index.js'; import { openDb } from './db/index.js';
import { buildApp } from './app.js'; import { buildApp } from './app.js';
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
const pkg = JSON.parse( const pkg = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'), readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
) as { version: string }; ) as { version: string };
async function main(): Promise<void> { /** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
const config = loadConfig(); export async function runDaemon(config: Config): Promise<void> {
const db = openDb(config.dbPath); const db = openDb(config.dbPath);
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version); 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}`); app.log.info(`Arboretum v${pkg.version}${url}`);
if (bootstrapToken) { if (bootstrapToken) {
// Affiché une seule fois : le hash seul est stocké. // Affiché une seule fois : le hash seul est stocké.
console.log('\n┌──────────────────────────────────────────────────────────────────┐'); printTokenBanner(bootstrapToken, url);
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`);
} else if (config.printToken) { } 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).'); 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')); 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) => { main().catch((err) => {
console.error(err instanceof Error ? err.message : err); console.error(err instanceof Error ? err.message : err);
process.exit(1); 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 { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
import type { AuthService, LoginRateLimiter } from '../auth/service.js'; 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( export function registerAuthRoutes(
app: FastifyInstance, app: FastifyInstance,
auth: AuthService, auth: AuthService,
@@ -24,6 +33,7 @@ export function registerAuthRoutes(
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
secure: isHttpsRequest(req),
maxAge: 30 * 24 * 3600, maxAge: 30 * 24 * 3600,
}); });
const res: LoginResponse = { ok: true, label: ctx.label }; const res: LoginResponse = { ok: true, label: ctx.label };
@@ -35,8 +45,9 @@ export function registerAuthRoutes(
return reply.send(res); return reply.send(res);
}); });
app.post('/api/v1/auth/logout', async (_req, reply) => { app.post('/api/v1/auth/logout', async (req, reply) => {
void reply.clearCookie(auth.cookieName, { path: '/' }); // 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 }); return reply.send({ ok: true });
}); });
} }

View File

@@ -91,9 +91,25 @@ describe('app e2e — auth, origin et sessions', () => {
expect(cookie).toBeDefined(); expect(cookie).toBeDefined();
expect(cookie?.httpOnly).toBe(true); expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe('Strict'); 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; 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 () => { it('routes API sans auth → 401', async () => {
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) { for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
const res = await t.bundle.app.inject({ method: 'GET', url }); 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'),
);
});
});