feat: cookie Secure conditionnel + sous-commande d'installation de service
Cookie: routes/auth.ts pose `secure` sur le cookie de session quand la requête arrive en HTTPS (x-forwarded-proto), sans trustProxy — durcit le cookie derrière Tailscale Serve sans casser le localhost http. Install: nouveau cli/install.ts + routeur de sous-commandes dans index.ts (install/uninstall/status/serve). Service utilisateur systemd (Linux) ou launchd (macOS), bootstrap du token, --dry-run/--no-enable. Rétrocompat stricte du daemon par défaut (runDaemon extrait). Tests: app.e2e (cookie Secure local vs HTTPS) + cli-install (fonctions pures). 203/203 verts, acceptation P1/P4 vertes. Docs: README.md + README.fr.md (installeur multi-OS, distinction utiliser/cloner, modèle de sécurité durci). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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
|
||||
|
||||
Deux chemins, selon ce que vous voulez :
|
||||
|
||||
- **Juste l'utiliser (la plupart des gens).** Arboretum est un paquet npm publié — vous **n'avez pas besoin de cloner ce dépôt**. Pointez npm vers le registre et lancez-le (ci-dessous). À faire sur la machine où tournent vos sessions Claude Code.
|
||||
- **Lancer depuis les sources.** Ne clonez le dépôt que pour développer Arboretum ou lancer une version non publiée.
|
||||
|
||||
### Le lancer (recommandé)
|
||||
|
||||
Arboretum est publié sur un registre npm Gitea auto-hébergé. Pointez le scope `@johanleroy` dessus une fois par machine — ajoutez à `~/.npmrc` :
|
||||
@@ -70,8 +75,17 @@ Au premier démarrage, Arboretum affiche un **token d'accès** unique et l'URL
|
||||
|
||||
Ouvrez l'URL, collez le token pour vous connecter, et c'est parti. Le token est stocké **hashé** — il n'est affiché qu'une seule fois, alors gardez-le en lieu sûr (un gestionnaire de mots de passe). Vous pourrez gérer vos tokens plus tard depuis les **Réglages**.
|
||||
|
||||
`npx` télécharge et lance la dernière version publiée à chaque fois. Pour l'installer une bonne fois — et obtenir la commande `arboretum` sur votre `PATH`, dont se sert le [service d'arrière-plan](#le-faire-tourner-en-service-darrière-plan) —, installez-le plutôt globalement :
|
||||
|
||||
```bash
|
||||
npm i -g @johanleroy/git-arboretum
|
||||
arboretum # identique à la commande npx, depuis le binaire installé
|
||||
```
|
||||
|
||||
### Lancer depuis les sources
|
||||
|
||||
Nécessaire uniquement pour **développer** Arboretum ou lancer une version non publiée — pas pour simplement l'utiliser. Clonez le dépôt, installez les dépendances, buildez, puis démarrez le daemon :
|
||||
|
||||
```bash
|
||||
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
||||
cd arboretum
|
||||
@@ -110,7 +124,26 @@ Ouvrez `https://<machine>.<tailnet>.ts.net` depuis n'importe quel appareil de vo
|
||||
|
||||
## Le faire tourner en service d'arrière-plan
|
||||
|
||||
Pour un daemon qui survit à la déconnexion et redémarre au boot, lancez-le via un **service systemd utilisateur**. Installez une version figée globalement (`npm i -g @johanleroy/git-arboretum`), puis créez `~/.config/systemd/user/arboretum.service` :
|
||||
Le plus rapide pour faire tourner Arboretum en service qui survit à la déconnexion et redémarre au boot, c'est l'installeur intégré. Installez une version figée globalement, puis lancez `install` — il détecte votre OS, écrit le fichier de service, le démarre et affiche le token unique :
|
||||
|
||||
```bash
|
||||
npm i -g @johanleroy/git-arboretum
|
||||
arboretum install --allow-origin https://MACHINE.TAILNET.ts.net
|
||||
```
|
||||
|
||||
Cela met en place un **service systemd utilisateur** sous Linux (`~/.config/systemd/user/arboretum.service`) ou un **LaunchAgent launchd** sous macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Tous les flags du daemon (`--port`, `--allow-origin`, `--db`, …) sont propagés au service. Gérez-le avec :
|
||||
|
||||
```bash
|
||||
arboretum status # état du service (+ où lire les logs)
|
||||
arboretum uninstall # arrête et supprime le service
|
||||
```
|
||||
|
||||
Les logs vivent dans `journalctl --user -u arboretum -f` (Linux) ou `~/Library/Logs/arboretum/` (macOS). Lancez d'abord `arboretum install --dry-run …` pour afficher le unit/plist et les commandes exactes sans rien modifier.
|
||||
|
||||
<details>
|
||||
<summary>Vous préférez configurer systemd à la main ? (Linux)</summary>
|
||||
|
||||
Créez `~/.config/systemd/user/arboretum.service` :
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
@@ -137,12 +170,15 @@ systemctl --user enable --now arboretum
|
||||
loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte
|
||||
journalctl --user -u arboretum -f # logs
|
||||
```
|
||||
</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
|
||||
|
||||
Toutes les options sont des flags CLI :
|
||||
Commandes : `arboretum` démarre le daemon (par défaut), `arboretum serve` en est un alias explicite, `arboretum install` / `uninstall` / `status` gèrent le service d'arrière-plan, et `arboretum help` affiche l'aide.
|
||||
|
||||
Les options du daemon sont des flags CLI :
|
||||
|
||||
| Flag | Défaut | Description |
|
||||
|---|---|---|
|
||||
@@ -154,6 +190,15 @@ Toutes les options sont des flags CLI :
|
||||
| `--print-token` | `false` | Indication sur le réaffichage du token (les tokens sont hashés et ne peuvent pas être réaffichés). |
|
||||
| `--i-know-this-exposes-a-terminal` | `false` | Reconnaître le bind sur une adresse non-loopback. **À éviter** — préférez Tailscale Serve. |
|
||||
|
||||
`arboretum install` accepte tous les flags du daemon ci-dessus (propagés tels quels au service), plus :
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--bin-path <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é
|
||||
@@ -162,9 +207,9 @@ Un terminal web, c'est de l'exécution de code à distance *par conception*. Les
|
||||
|
||||
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
||||
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
||||
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC. Le login est rate-limité avec backoff exponentiel.
|
||||
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel.
|
||||
|
||||
La façon recommandée d'atteindre Arboretum depuis d'autres appareils est Tailscale Serve (HTTPS valide, identité tailnet, aucun port ouvert). Ne l'exposez jamais directement sur internet.
|
||||
Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet.
|
||||
|
||||
## Ce qui le distingue
|
||||
|
||||
|
||||
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
|
||||
|
||||
Two paths, depending on what you want:
|
||||
|
||||
- **Just use it (most people).** Arboretum is a published npm package — you **don't need to clone this repo**. Point npm at the registry and run it (below). Do this on the machine where your Claude Code sessions run.
|
||||
- **Run from source.** Clone the repo only to hack on Arboretum or run an unreleased build.
|
||||
|
||||
### Run it (recommended)
|
||||
|
||||
Arboretum is published to a self-hosted Gitea npm registry. Point the `@johanleroy` scope at it once per machine — add to `~/.npmrc`:
|
||||
@@ -70,8 +75,17 @@ On first start, Arboretum prints a one-time **access token** and the URL to open
|
||||
|
||||
Open the URL, paste the token to log in, and you're in. The token is stored **hashed** — it is shown only once, so save it somewhere safe (a password manager). You can manage tokens later from **Settings**.
|
||||
|
||||
`npx` fetches and runs the latest published version each time. To install it once — and get the `arboretum` command on your `PATH`, which the [background service](#running-it-as-a-background-service) relies on — install it globally instead:
|
||||
|
||||
```bash
|
||||
npm i -g @johanleroy/git-arboretum
|
||||
arboretum # identical to the npx command, from the installed binary
|
||||
```
|
||||
|
||||
### Run from source
|
||||
|
||||
Only needed to **develop** Arboretum or run an unreleased build — not required just to use it. Clone the repo, install dependencies, build, then start the daemon:
|
||||
|
||||
```bash
|
||||
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
||||
cd arboretum
|
||||
@@ -110,7 +124,26 @@ Open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet. **Web
|
||||
|
||||
## Running it as a background service
|
||||
|
||||
For a daemon that survives logout and restarts on boot, run it under a **systemd user service**. Install a pinned version globally (`npm i -g @johanleroy/git-arboretum`), then create `~/.config/systemd/user/arboretum.service`:
|
||||
The quickest way to run Arboretum as a service that survives logout and restarts on boot is the built-in installer. Install a pinned version globally, then run `install` — it detects your OS, writes the service file, starts it, and prints the one-time token:
|
||||
|
||||
```bash
|
||||
npm i -g @johanleroy/git-arboretum
|
||||
arboretum install --allow-origin https://MACHINE.TAILNET.ts.net
|
||||
```
|
||||
|
||||
This sets up a **systemd user service** on Linux (`~/.config/systemd/user/arboretum.service`) or a **launchd LaunchAgent** on macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Every daemon flag (`--port`, `--allow-origin`, `--db`, …) is propagated to the service. Manage it with:
|
||||
|
||||
```bash
|
||||
arboretum status # service status (+ where to read logs)
|
||||
arboretum uninstall # stop and remove the service
|
||||
```
|
||||
|
||||
Logs live in `journalctl --user -u arboretum -f` (Linux) or `~/Library/Logs/arboretum/` (macOS). Run `arboretum install --dry-run …` first to print the unit/plist and the exact commands without touching anything.
|
||||
|
||||
<details>
|
||||
<summary>Prefer to set up systemd by hand? (Linux)</summary>
|
||||
|
||||
Create `~/.config/systemd/user/arboretum.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
@@ -137,12 +170,15 @@ systemctl --user enable --now arboretum
|
||||
loginctl enable-linger "$USER" # start the service at boot, without an open session
|
||||
journalctl --user -u arboretum -f # logs
|
||||
```
|
||||
</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
|
||||
|
||||
All options are CLI flags:
|
||||
Commands: `arboretum` starts the daemon (the default), `arboretum serve` is an explicit alias, `arboretum install` / `uninstall` / `status` manage the background service, and `arboretum help` prints usage.
|
||||
|
||||
Daemon options are CLI flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
@@ -154,6 +190,15 @@ All options are CLI flags:
|
||||
| `--print-token` | `false` | Hint about token re-printing (tokens are hashed and cannot be re-shown). |
|
||||
| `--i-know-this-exposes-a-terminal` | `false` | Acknowledge binding to a non-loopback address. **Avoid** — prefer Tailscale Serve. |
|
||||
|
||||
`arboretum install` accepts every daemon flag above (propagated verbatim to the service) plus:
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--bin-path <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`).
|
||||
|
||||
## Security model
|
||||
@@ -162,9 +207,9 @@ A web terminal is remote code execution *by design*. Arboretum's guardrails are
|
||||
|
||||
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
||||
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
||||
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload. Login is rate-limited with exponential backoff.
|
||||
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff.
|
||||
|
||||
The recommended way to reach Arboretum from other devices is Tailscale Serve (valid HTTPS, tailnet identity, no open ports). Never expose it directly to the internet.
|
||||
Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet.
|
||||
|
||||
## What makes it different
|
||||
|
||||
|
||||
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 { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from './config.js';
|
||||
import { loadConfig, type Config } from './config.js';
|
||||
import { openDb } from './db/index.js';
|
||||
import { buildApp } from './app.js';
|
||||
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
||||
export async function runDaemon(config: Config): Promise<void> {
|
||||
const db = openDb(config.dbPath);
|
||||
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
|
||||
|
||||
@@ -23,11 +24,7 @@ async function main(): Promise<void> {
|
||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||
if (bootstrapToken) {
|
||||
// Affiché une seule fois : le hash seul est stocké.
|
||||
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
||||
console.log('│ First start — your access token (shown once, store it safely): │');
|
||||
console.log('└──────────────────────────────────────────────────────────────────┘');
|
||||
console.log(`\n ${bootstrapToken}\n`);
|
||||
console.log(` Login at: ${url}/\n`);
|
||||
printTokenBanner(bootstrapToken, url);
|
||||
} else if (config.printToken) {
|
||||
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
|
||||
}
|
||||
@@ -47,6 +44,36 @@ async function main(): Promise<void> {
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
// Routeur de sous-commandes. On inspecte argv[0] AVANT loadConfig (parseArgs strict throw sur
|
||||
// un positionnel inconnu). Aucune sous-commande (ou un flag en tête) → daemon : rétrocompat stricte
|
||||
// de `arboretum`, `arboretum --port 8080`, `npx @johanleroy/git-arboretum --allow-origin …`.
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
switch (cmd) {
|
||||
case 'install':
|
||||
return runInstall(argv.slice(1));
|
||||
case 'uninstall':
|
||||
return runUninstall(argv.slice(1));
|
||||
case 'status':
|
||||
return runStatus(argv.slice(1));
|
||||
case 'serve':
|
||||
return runDaemon(loadConfig(argv.slice(1)));
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
printUsage(pkg.version);
|
||||
return;
|
||||
default:
|
||||
if (cmd && !cmd.startsWith('-')) {
|
||||
console.error(`Unknown command: ${cmd}\n`);
|
||||
printUsage(pkg.version);
|
||||
process.exit(1);
|
||||
}
|
||||
return runDaemon(loadConfig(argv));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||
|
||||
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
||||
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
||||
function isHttpsRequest(req: FastifyRequest): boolean {
|
||||
const xfp = req.headers['x-forwarded-proto'];
|
||||
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
||||
return proto === 'https';
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(
|
||||
app: FastifyInstance,
|
||||
auth: AuthService,
|
||||
@@ -24,6 +33,7 @@ export function registerAuthRoutes(
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: isHttpsRequest(req),
|
||||
maxAge: 30 * 24 * 3600,
|
||||
});
|
||||
const res: LoginResponse = { ok: true, label: ctx.label };
|
||||
@@ -35,8 +45,9 @@ export function registerAuthRoutes(
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/logout', async (_req, reply) => {
|
||||
void reply.clearCookie(auth.cookieName, { path: '/' });
|
||||
app.post('/api/v1/auth/logout', async (req, reply) => {
|
||||
// Les attributs doivent matcher ceux posés au login pour que le navigateur efface bien le cookie.
|
||||
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,9 +91,25 @@ describe('app e2e — auth, origin et sessions', () => {
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe('Strict');
|
||||
// Login local (http, pas de x-forwarded-proto) → pas de Secure, sinon le cookie casserait en localhost.
|
||||
expect(cookie?.secure).toBeFalsy();
|
||||
cookieValue = cookie!.value;
|
||||
});
|
||||
|
||||
it('cookie Secure posé derrière un front HTTPS (x-forwarded-proto)', async () => {
|
||||
const res = await t.bundle.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: { 'x-forwarded-proto': 'https' },
|
||||
payload: { token: t.token },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||||
expect(cookie?.secure).toBe(true);
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe('Strict');
|
||||
});
|
||||
|
||||
it('routes API sans auth → 401', async () => {
|
||||
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
|
||||
const res = await t.bundle.app.inject({ method: 'GET', url });
|
||||
|
||||
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