Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6999011d1 | |||
| f446a3dda1 |
55
README.fr.md
55
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
|
## 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
|
||||||
|
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -4960,7 +4960,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@arboretum/shared"
|
"@arboretum/shared"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
363
packages/server/src/cli/install.ts
Normal file
363
packages/server/src/cli/install.ts
Normal 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, '&').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) => ` <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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
166
packages/server/test/cli-install.test.ts
Normal file
166
packages/server/test/cli-install.test.ts
Normal 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&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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user