Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6999011d1 | |||
| f446a3dda1 | |||
| 11c45ff060 | |||
| 3358327f74 | |||
| 452584710d |
254
README.fr.md
Normal file
254
README.fr.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="brand/arboretum-logo-on-dark.png" alt="Arboretum" width="300">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Un dashboard web auto-hébergé pour vos worktrees git et les sessions Claude Code qui tournent dessus — depuis n'importe quel appareil.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> · <strong>Français</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Statut : MVP.** Le dashboard worktree-first, la découverte et la reprise de sessions, le cycle de vie des worktrees multi-repo, les états de session en temps réel, le terminal web et la supervision mobile (PWA installable, Web Push quand une session vous attend, valider/refuser sans ouvrir de terminal) sont implémentés et testés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Le problème
|
||||||
|
|
||||||
|
Travailler avec des agents de code IA a changé notre usage de git : une feature = un worktree = une session Claude Code, plusieurs en parallèle. Mais l'outillage n'a pas suivi :
|
||||||
|
|
||||||
|
- `git worktree list` sur plusieurs repos est fastidieux, les worktrees s'accumulent, chacun a besoin de ses `node_modules` et `.env`.
|
||||||
|
- Les sessions Claude Code sont éparpillées : certaines tournent dans des terminaux, d'autres sont reprenables depuis l'historique, sans vue consolidée de *celle qui attend votre intervention*.
|
||||||
|
- Quand vous vous éloignez de votre poste, une session bloquée sur une demande de permission reste bloquée.
|
||||||
|
|
||||||
|
## Ce que fait Arboretum
|
||||||
|
|
||||||
|
Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanleroy/git-arboretum`), servant une interface web utilisable depuis votre ordinateur, téléphone ou tablette :
|
||||||
|
|
||||||
|
- **Dashboard worktree-first, multi-repo** — chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
||||||
|
- **Cycle de vie complet des worktrees** — créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
||||||
|
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante.
|
||||||
|
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur.
|
||||||
|
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; validez ou refusez une demande sans ouvrir de terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- **Node.js ≥ 22.16** — requis, pas seulement recommandé. Arboretum persiste son état avec `node:sqlite` (`DatabaseSync`), natif et stable seulement à partir de cette version. (`.nvmrc` fixe `22`.)
|
||||||
|
- **Le CLI `claude`** sur votre `PATH` si vous voulez qu'Arboretum lance et gère des sessions Claude Code. Arboretum enveloppe le CLI interactif que vous utilisez déjà — installez-le et authentifiez-le comme d'habitude.
|
||||||
|
- Un **dépôt git** (ou plusieurs) que vous voulez gérer.
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
Deux chemins, selon ce que vous voulez :
|
||||||
|
|
||||||
|
- **Juste l'utiliser (la plupart des gens).** Arboretum est un paquet npm publié — vous **n'avez pas besoin de cloner ce dépôt**. Pointez npm vers le registre et lancez-le (ci-dessous). À faire sur la machine où tournent vos sessions Claude Code.
|
||||||
|
- **Lancer depuis les sources.** Ne clonez le dépôt que pour développer Arboretum ou lancer une version non publiée.
|
||||||
|
|
||||||
|
### Le lancer (recommandé)
|
||||||
|
|
||||||
|
Arboretum est publié sur un registre npm Gitea auto-hébergé. Pointez le scope `@johanleroy` dessus une fois par machine — ajoutez à `~/.npmrc` :
|
||||||
|
|
||||||
|
```
|
||||||
|
@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun token nécessaire — le paquet est en lecture publique. Puis lancez-le depuis n'importe où :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @johanleroy/git-arboretum
|
||||||
|
```
|
||||||
|
|
||||||
|
Au premier démarrage, Arboretum affiche un **token d'accès** unique et l'URL à ouvrir :
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ First start — your access token (shown once, store it safely): │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
<votre-token-ici>
|
||||||
|
|
||||||
|
Login at: http://127.0.0.1:7317/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrez l'URL, collez le token pour vous connecter, et c'est parti. Le token est stocké **hashé** — il n'est affiché qu'une seule fois, alors gardez-le en lieu sûr (un gestionnaire de mots de passe). Vous pourrez gérer vos tokens plus tard depuis les **Réglages**.
|
||||||
|
|
||||||
|
`npx` télécharge et lance la dernière version publiée à chaque fois. Pour l'installer une bonne fois — et obtenir la commande `arboretum` sur votre `PATH`, dont se sert le [service d'arrière-plan](#le-faire-tourner-en-service-darrière-plan) —, installez-le plutôt globalement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g @johanleroy/git-arboretum
|
||||||
|
arboretum # identique à la commande npx, depuis le binaire installé
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lancer depuis les sources
|
||||||
|
|
||||||
|
Nécessaire uniquement pour **développer** Arboretum ou lancer une version non publiée — pas pour simplement l'utiliser. Clonez le dépôt, installez les dépendances, buildez, puis démarrez le daemon :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
||||||
|
cd arboretum
|
||||||
|
nvm use # ou assurez-vous d'avoir Node ≥ 22.16
|
||||||
|
npm install
|
||||||
|
npm run build # build shared → server → web (l'ordre compte)
|
||||||
|
node packages/server/dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utiliser Arboretum
|
||||||
|
|
||||||
|
1. **Ajoutez un dépôt.** Depuis le dashboard, enregistrez un repo git local par son chemin. Configurez éventuellement des **hooks post-création** (ex. `npm ci`, `cp ../.env .env`) exécutés automatiquement à chaque création d'un nouveau worktree pour ce repo.
|
||||||
|
2. **Créez ou adoptez des worktrees.** Créez un nouveau worktree + branche en un clic (les hooks s'exécutent pour vous), ou adoptez un worktree créé à la main. Chaque worktree affiche sa branche, son ahead/behind et son nombre de fichiers modifiés.
|
||||||
|
3. **Démarrez ou reprenez une session.** Lancez une session Claude Code sur un worktree, ou reprenez-en une démarrée dans votre terminal — Arboretum découvre les sessions existantes automatiquement et les reprend toujours dans leur répertoire de travail d'origine.
|
||||||
|
4. **Suivez les états en direct.** Chaque session indique si elle est *busy*, *en attente de votre entrée* ou *idle*. Ouvrez le **terminal web** pour interagir directement ; il survit aux déconnexions du navigateur (fermer l'onglet ne tue pas la session).
|
||||||
|
5. **Supervisez depuis votre téléphone.** Installez la PWA, et quand une session bascule en *attente*, vous recevez une notification push. Validez ou refusez la demande directement depuis l'interface de notification — sans terminal.
|
||||||
|
|
||||||
|
## Accès distant depuis votre téléphone
|
||||||
|
|
||||||
|
Arboretum se bind sur `127.0.0.1` par défaut et **refuse** de se binder sur une adresse non-loopback sans dérogation explicite. La façon recommandée (et sûre) de l'atteindre depuis d'autres appareils est **[Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve)** — HTTPS valide, identité tailnet, aucun port ouvert :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Expose le daemon local en HTTPS dans votre tailnet
|
||||||
|
tailscale serve --bg 7317
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis démarrez Arboretum en autorisant l'origine de votre tailnet (le check Origin strict doit la connaître) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @johanleroy/git-arboretum --allow-origin https://<machine>.<tailnet>.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrez `https://<machine>.<tailnet>.ts.net` depuis n'importe quel appareil de votre tailnet. **Web Push exige HTTPS**, donc Tailscale Serve (ou un autre front HTTPS) est aussi ce qui active les notifications mobiles. Sur **iOS**, installez d'abord l'app à l'écran d'accueil, puis autorisez les notifications.
|
||||||
|
|
||||||
|
> ⚠️ Un terminal web, c'est de l'exécution de code à distance **par conception**. N'exposez jamais Arboretum directement sur l'internet public.
|
||||||
|
|
||||||
|
## Le faire tourner en service d'arrière-plan
|
||||||
|
|
||||||
|
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
|
||||||
|
[Unit]
|
||||||
|
Description=Arboretum — git worktree & Claude Code dashboard
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=%h/.local/bin/arboretum --port 7317 --allow-origin https://MACHINE.TAILNET.ts.net
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=10
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
which arboretum # ajustez ExecStart au vrai chemin si besoin
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now arboretum
|
||||||
|
loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte
|
||||||
|
journalctl --user -u arboretum -f # logs
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> 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
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port <n>` | `7317` | Port d'écoute. |
|
||||||
|
| `--bind <addr>` | `127.0.0.1` | Adresse de bind. Une adresse non-loopback est refusée sauf si `--i-know-this-exposes-a-terminal` est défini. |
|
||||||
|
| `--allow-origin <url>` | — | Origine `Origin` autorisée supplémentaire (répétable). Nécessaire pour l'accès Tailscale/HTTPS. |
|
||||||
|
| `--db <path>` | `<data>/arboretum.db` | Chemin de la base SQLite. |
|
||||||
|
| `--vapid-contact <mailto/url>` | `mailto:arboretum@localhost` | Sujet de contact VAPID pour le Web Push. |
|
||||||
|
| `--print-token` | `false` | Indication sur le réaffichage du token (les tokens sont hashés et ne peuvent pas être réaffichés). |
|
||||||
|
| `--i-know-this-exposes-a-terminal` | `false` | Reconnaître le bind sur une adresse non-loopback. **À éviter** — préférez Tailscale Serve. |
|
||||||
|
|
||||||
|
`arboretum install` accepte tous les flags du daemon ci-dessus (propagés tels quels au service), plus :
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|---|---|
|
||||||
|
| `--bin-path <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`).
|
||||||
|
|
||||||
|
## Modèle de sécurité
|
||||||
|
|
||||||
|
Un terminal web, c'est de l'exécution de code à distance *par conception*. Les garde-fous d'Arboretum sont structurants :
|
||||||
|
|
||||||
|
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
||||||
|
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
||||||
|
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Interface web, tout appareil | ✅ | ❌ apps desktop | ✅ | ✅ |
|
||||||
|
| Gestion visuelle des worktrees (multi-repo) | ✅ | ✅ (mono-repo, desktop) | ❌ | ❌ |
|
||||||
|
| Découvre & reprend les sessions de terminal *existantes* | ✅ | ❌ | partiel | ❌ |
|
||||||
|
| 100 % auto-hébergé — zéro trafic via des serveurs tiers | ✅ | ✅ | serveur relais | ❌ relayé via Anthropic |
|
||||||
|
| Linux-first | ✅ | variable | ✅ | l'app desktop n'a pas de build Linux |
|
||||||
|
| Open source | MIT | ❌ / partiel | MIT / AGPL | ❌ |
|
||||||
|
|
||||||
|
Le Remote Control d'Anthropic est excellent pour piloter *une* session depuis votre téléphone. Arboretum est la couche qu'il ne fournit pas : le tableau consolidé et auto-hébergé de tous vos worktrees et sessions, à travers tous vos repos.
|
||||||
|
|
||||||
|
## Une note sur l'usage de Claude
|
||||||
|
|
||||||
|
Arboretum enveloppe le CLI Claude Code **interactif** dans un PTY — la même chose que vous lancez dans votre terminal, affichée dans votre navigateur. Il n'utilise pas l'Agent SDK ni le mode headless. Les politiques d'usage d'Anthropic autour de l'usage programmatique peuvent évoluer ; Arboretum suivra les sorties du CLI et documentera tout impact de façon transparente.
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
Arboretum est un monorepo npm workspaces : `@arboretum/shared` (protocole WS/REST, source de vérité), `@johanleroy/git-arboretum` (le daemon Fastify, le paquet publié) et `@arboretum/web` (la SPA Vue 3).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # build shared → server → web (l'ordre compte)
|
||||||
|
npm run typecheck # tsc -b shared + server
|
||||||
|
npm test # vitest sur les packages
|
||||||
|
npm run dev:server # daemon en watch
|
||||||
|
npm run dev:web # serveur de dev Vite (proxifie /api et /ws vers le daemon sur :7317)
|
||||||
|
```
|
||||||
|
|
||||||
|
Scripts d'acceptation end-to-end (lancez `npm run build` d'abord) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node packages/server/scripts/acceptance-p1.mjs # cœur : daemon + client WS réel
|
||||||
|
node packages/server/scripts/acceptance-p2.mjs # découverte & reprise de sessions
|
||||||
|
node packages/server/scripts/acceptance-p3.mjs # worktrees & corrélation de sessions
|
||||||
|
node packages/server/scripts/acceptance-p4.mjs # Web Push + commande WS `answer`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — voir [LICENSE](LICENSE).
|
||||||
66
README.md
66
README.md
@@ -6,6 +6,10 @@
|
|||||||
A self-hosted web dashboard for your git worktrees and the Claude Code sessions running on them — from any device.
|
A self-hosted web dashboard for your git worktrees and the Claude Code sessions running on them — from any device.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>English</strong> · <a href="README.fr.md">Français</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
**Status: MVP.** The worktree-first dashboard, session discovery & resume, multi-repo worktree lifecycle, live session states, the web terminal, and mobile supervision (installable PWA, Web Push when a session needs you, approve/deny without opening a terminal) are implemented and tested.
|
**Status: MVP.** The worktree-first dashboard, session discovery & resume, multi-repo worktree lifecycle, live session states, the web terminal, and mobile supervision (installable PWA, Web Push when a session needs you, approve/deny without opening a terminal) are implemented and tested.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -20,7 +24,7 @@ Working with AI coding agents changed how we use git: one feature = one worktree
|
|||||||
|
|
||||||
## What Arboretum does
|
## What Arboretum does
|
||||||
|
|
||||||
A single Node.js daemon you run on your dev machine (`npx git-arboretum`), serving a web UI usable from your desktop, phone or tablet:
|
A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arboretum`), serving a web UI usable from your desktop, phone or tablet:
|
||||||
|
|
||||||
- **Worktree-first, multi-repo dashboard** — every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
- **Worktree-first, multi-repo dashboard** — every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
||||||
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
||||||
@@ -38,16 +42,20 @@ A single Node.js daemon you run on your dev machine (`npx git-arboretum`), servi
|
|||||||
|
|
||||||
## 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 private 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`:
|
||||||
|
|
||||||
```
|
```
|
||||||
@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/
|
@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/
|
||||||
//git.lidge.fr/api/packages/johanleroy/npm/:_authToken=<your-gitea-token>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
(The `_authToken` line is only needed if the package is private.) Then run it from anywhere:
|
No token needed — the package is publicly readable. Then run it from anywhere:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @johanleroy/git-arboretum
|
npx @johanleroy/git-arboretum
|
||||||
@@ -67,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
|
||||||
@@ -107,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]
|
||||||
@@ -134,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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -151,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
|
||||||
@@ -159,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.0",
|
"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.0",
|
"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",
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ const rootDir = join(serverDir, '..', '..');
|
|||||||
const readmeSrc = join(rootDir, 'README.md');
|
const readmeSrc = join(rootDir, 'README.md');
|
||||||
if (existsSync(readmeSrc)) {
|
if (existsSync(readmeSrc)) {
|
||||||
const rawBase = 'https://git.lidge.fr/johanleroy/arboretum/raw/branch/main/';
|
const rawBase = 'https://git.lidge.fr/johanleroy/arboretum/raw/branch/main/';
|
||||||
const readme = readFileSync(readmeSrc, 'utf8').replaceAll('src="brand/', `src="${rawBase}brand/`);
|
const srcBase = 'https://git.lidge.fr/johanleroy/arboretum/src/branch/main/';
|
||||||
|
const readme = readFileSync(readmeSrc, 'utf8')
|
||||||
|
.replaceAll('src="brand/', `src="${rawBase}brand/`)
|
||||||
|
.replaceAll('href="README.fr.md"', `href="${srcBase}README.fr.md"`);
|
||||||
writeFileSync(join(serverDir, 'README.md'), readme);
|
writeFileSync(join(serverDir, 'README.md'), readme);
|
||||||
console.log('copy-meta: README.md copié (chemins images réécrits en absolu)');
|
console.log('copy-meta: README.md copié (chemins images + lien FR réécrits en absolu)');
|
||||||
} else {
|
} else {
|
||||||
console.error(`copy-meta: ${readmeSrc} introuvable`);
|
console.error(`copy-meta: ${readmeSrc} introuvable`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
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