Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1390bfe06 | |||
| e38b3a5d5e | |||
| e6999011d1 | |||
| f446a3dda1 |
59
README.fr.md
59
README.fr.md
@@ -10,7 +10,7 @@
|
||||
<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.
|
||||
**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, la supervision mobile (PWA installable, Web Push quand une session vous attend, valider/refuser sans ouvrir de terminal) et les groupes de travail (piloter plusieurs repos liés à la fois) sont implémentés et testés.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,6 +31,7 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
|
||||
- **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.
|
||||
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et une lib partagée) dans un groupe nommé pour travailler sur tous à la fois : une vue unifiée de leurs worktrees et sessions, une grille multi-terminaux côte à côte, et une action « feature cross-repo » en un clic qui crée le même worktree de branche (et au besoin une session Claude) dans chaque repo du groupe.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,6 +43,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 +76,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 +125,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 +171,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 +191,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 +208,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
|
||||
|
||||
@@ -202,6 +248,7 @@ node packages/server/scripts/acceptance-p1.mjs # cœur : daemon + client WS r
|
||||
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`
|
||||
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
59
README.md
59
README.md
@@ -10,7 +10,7 @@
|
||||
<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), and work groups (run several related repos at once) are implemented and tested.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,6 +31,7 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
|
||||
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session.
|
||||
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects.
|
||||
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; approve/deny a prompt without opening a terminal.
|
||||
- **Work groups** — bundle related repos (e.g. an API, its web frontend and a shared library) into a named group to work on them at once: a unified view of all their worktrees and sessions, a side-by-side multi-terminal grid, and a one-click "cross-repo feature" that creates the same branch worktree (and optionally a Claude session) across every repo in the group.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,6 +43,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 +76,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 +125,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 +171,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 +191,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 +208,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
|
||||
|
||||
@@ -202,6 +248,7 @@ node packages/server/scripts/acceptance-p1.mjs # core: daemon + real WS client
|
||||
node packages/server/scripts/acceptance-p2.mjs # session discovery & resume
|
||||
node packages/server/scripts/acceptance-p3.mjs # worktrees & session correlation
|
||||
node packages/server/scripts/acceptance-p4.mjs # Web Push + WS `answer` command
|
||||
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -4960,7 +4960,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"bundleDependencies": [
|
||||
"@arboretum/shared"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
146
packages/server/scripts/acceptance-p5.mjs
Normal file
146
packages/server/scripts/acceptance-p5.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
// Acceptation P5 (sans navigateur, sans quota Claude) : groupes de travail.
|
||||
// Vrai daemon + vrai repo git tmp. Couvre : CRUD groupe via REST, broadcast WS group_update/
|
||||
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership
|
||||
// quand le repo est supprimé (PRAGMA foreign_keys = ON).
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const PORT = 7545;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
||||
const repo = join(tmp, 'demo-repo');
|
||||
execFileSync('mkdir', ['-p', repo]);
|
||||
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
||||
git('init', '-b', 'main');
|
||||
git('config', 'user.email', 'test@arboretum.dev');
|
||||
git('config', 'user.name', 'Test');
|
||||
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
|
||||
git('add', '-A');
|
||||
git('commit', '-m', 'init');
|
||||
|
||||
const srv = spawn(
|
||||
'node',
|
||||
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')],
|
||||
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
let srvOut = '';
|
||||
srv.stdout.on('data', (d) => (srvOut += d));
|
||||
srv.stderr.on('data', (d) => (srvOut += d));
|
||||
|
||||
function wsClient(cookie) {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
const state = { msgs: [] };
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (!isBinary) state.msgs.push(JSON.parse(String(data)));
|
||||
});
|
||||
const waitMsg = async (pred, timeout = 8000) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const m = state.msgs.find(pred);
|
||||
if (m) return m;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
|
||||
}
|
||||
|
||||
const j = (path, method, cookie, body) =>
|
||||
fetch(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
await sleep(1500);
|
||||
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||
check('boot + token bootstrap', !!token);
|
||||
|
||||
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
check('login → cookie', login.status === 200);
|
||||
|
||||
const c = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej)));
|
||||
c.send({ type: 'hello', protocol: 1 });
|
||||
await c.waitMsg((m) => m.type === 'hello_ok');
|
||||
c.send({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] });
|
||||
|
||||
// Enregistrement d'un repo (cible de la membership).
|
||||
const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
||||
const repoSummary = (await addRepo.json()).repo;
|
||||
check('POST /repos → 201', addRepo.status === 201 && repoSummary?.valid === true);
|
||||
|
||||
// Création d'un groupe vide via REST → broadcast WS group_update.
|
||||
const created = await j('/api/v1/groups', 'POST', cookie, { label: 'Sprint 42', color: '#4f46e5' });
|
||||
const group = (await created.json()).group;
|
||||
check('POST /groups → 201', created.status === 201 && group?.label === 'Sprint 42' && group?.repoIds.length === 0);
|
||||
const pushedCreate = await c.waitMsg((m) => m.type === 'group_update' && m.group?.id === group.id);
|
||||
check('broadcast WS group_update (création)', !!pushedCreate);
|
||||
|
||||
const list = await (await j('/api/v1/groups', 'GET', cookie)).json();
|
||||
check('GET /groups → groupe présent', list.groups?.some((g) => g.id === group.id));
|
||||
|
||||
// Ajout du repo au groupe → group_update avec repoIds peuplé.
|
||||
const added = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repoSummary.id });
|
||||
const addedBody = await added.json();
|
||||
check('POST /groups/:id/repos → repoIds', added.status === 200 && addedBody.group?.repoIds.includes(repoSummary.id));
|
||||
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
||||
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
||||
|
||||
// Renommage via PATCH.
|
||||
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
||||
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
||||
|
||||
// repoId inexistant → 404.
|
||||
const bad = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: 'does-not-exist' });
|
||||
check('POST repo inexistant → 404', bad.status === 404);
|
||||
|
||||
// Suppression du repo → CASCADE purge la membership.
|
||||
const delRepo = await j(`/api/v1/repos/${repoSummary.id}`, 'DELETE', cookie);
|
||||
check('DELETE /repos/:id → 200', delRepo.status === 200);
|
||||
await sleep(200);
|
||||
const afterCascade = await (await j(`/api/v1/groups/${group.id}`, 'GET', cookie)).json();
|
||||
check('CASCADE : membership purgée à la suppression du repo', afterCascade.group?.repoIds.length === 0);
|
||||
|
||||
// Suppression du groupe → broadcast WS group_removed.
|
||||
const delGroup = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
||||
check('DELETE /groups/:id → 200', delGroup.status === 200);
|
||||
const removed = await c.waitMsg((m) => m.type === 'group_removed' && m.groupId === group.id);
|
||||
check('broadcast WS group_removed', !!removed);
|
||||
const delAgain = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
||||
check('DELETE groupe inconnu → 404', delAgain.status === 404);
|
||||
|
||||
c.ws.close();
|
||||
} catch (err) {
|
||||
check('exception', false, String(err));
|
||||
} finally {
|
||||
srv.kill('SIGTERM');
|
||||
await sleep(1500);
|
||||
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(failed.length === 0 ? '\nACCEPTANCE P5: ALL GREEN' : `\nACCEPTANCE P5: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
|
||||
import { PtyManager } from './core/pty-manager.js';
|
||||
import { DiscoveryService } from './core/discovery-service.js';
|
||||
import { WorktreeManager } from './core/worktree-manager.js';
|
||||
import { GroupManager } from './core/group-manager.js';
|
||||
import { PushService } from './core/push-service.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerGroupRoutes } from './routes/groups.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerFsRoutes } from './routes/fs.js';
|
||||
@@ -35,6 +37,7 @@ export interface AppBundle {
|
||||
manager: PtyManager;
|
||||
discovery: DiscoveryService;
|
||||
worktrees: WorktreeManager;
|
||||
groups: GroupManager;
|
||||
push: PushService;
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
sessionsDir: config.claudeSessionsDir,
|
||||
});
|
||||
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||
const groups = new GroupManager(db);
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
void app.register(fastifyWebsocket, {
|
||||
@@ -91,13 +95,14 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerSessionRoutes(app, manager, discovery);
|
||||
registerRepoRoutes(app, worktrees);
|
||||
registerGroupRoutes(app, groups);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
registerFsRoutes(app);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
registerWsGateway(scoped, manager, discovery, worktrees, serverVersion);
|
||||
registerWsGateway(scoped, manager, discovery, worktrees, groups, serverVersion);
|
||||
});
|
||||
|
||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||
@@ -112,5 +117,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
});
|
||||
}
|
||||
|
||||
return { app, auth, manager, discovery, worktrees, push };
|
||||
return { app, auth, manager, discovery, worktrees, groups, push };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
201
packages/server/src/core/group-manager.ts
Normal file
201
packages/server/src/core/group-manager.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many).
|
||||
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici —
|
||||
// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId.
|
||||
// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone.
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { GroupSummary } from '@arboretum/shared';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
const LABEL_MAX = 100;
|
||||
const DESCRIPTION_MAX = 2000;
|
||||
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
interface GroupRow {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GroupManagerEvents {
|
||||
group_update: [GroupSummary];
|
||||
group_removed: [string];
|
||||
}
|
||||
|
||||
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes (cf. sendManagerError). */
|
||||
function httpError(statusCode: number, code: string, message: string): Error {
|
||||
return Object.assign(new Error(message), { statusCode, code });
|
||||
}
|
||||
|
||||
/** Valide un label : non vide après trim, borné. */
|
||||
function normLabel(label: unknown): string {
|
||||
if (typeof label !== 'string') throw httpError(400, 'BAD_REQUEST', 'label is required');
|
||||
const v = label.trim();
|
||||
if (v === '') throw httpError(400, 'BAD_REQUEST', 'label must not be empty');
|
||||
if (v.length > LABEL_MAX) throw httpError(400, 'BAD_REQUEST', `label must be at most ${LABEL_MAX} characters`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Normalise une description : vide/absente → null, bornée sinon. */
|
||||
function normDescription(description: string | null | undefined): string | null {
|
||||
if (description === null || description === undefined) return null;
|
||||
const v = description.trim();
|
||||
if (v === '') return null;
|
||||
if (v.length > DESCRIPTION_MAX) throw httpError(400, 'BAD_REQUEST', `description must be at most ${DESCRIPTION_MAX} characters`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Normalise une couleur : vide/absente → null, doit être un hex `#rrggbb` sinon. */
|
||||
function normColor(color: string | null | undefined): string | null {
|
||||
if (color === null || color === undefined) return null;
|
||||
const v = color.trim();
|
||||
if (v === '') return null;
|
||||
if (!COLOR_RE.test(v)) throw httpError(400, 'BAD_REQUEST', 'color must be a hex string like #4f46e5');
|
||||
return v;
|
||||
}
|
||||
|
||||
export class GroupManager extends EventEmitter<GroupManagerEvents> {
|
||||
constructor(private readonly db: Db) {
|
||||
super();
|
||||
}
|
||||
|
||||
private getGroupRow(id: string): GroupRow | null {
|
||||
return (this.db.prepare('SELECT * FROM groups WHERE id = ?').get(id) as unknown as GroupRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private repoIdsFor(groupId: string): string[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT repo_id FROM group_repos WHERE group_id = ? ORDER BY position ASC, created_at ASC')
|
||||
.all(groupId) as Array<{ repo_id: string }>;
|
||||
return rows.map((r) => r.repo_id);
|
||||
}
|
||||
|
||||
private rowToSummary(row: GroupRow): GroupSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
color: row.color,
|
||||
repoIds: this.repoIdsFor(row.id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Garde-fou : le repo doit exister (meilleur message que de laisser la FK lever une contrainte opaque). */
|
||||
private assertRepoExists(repoId: string): void {
|
||||
if (typeof repoId !== 'string' || repoId === '') throw httpError(400, 'BAD_REQUEST', 'repoId is required');
|
||||
const exists = this.db.prepare('SELECT 1 FROM repos WHERE id = ?').get(repoId);
|
||||
if (!exists) throw httpError(404, 'REPO_NOT_FOUND', 'No repo with this id');
|
||||
}
|
||||
|
||||
/** Position d'insertion suivante dans un groupe (append en queue). */
|
||||
private nextPosition(groupId: string): number {
|
||||
const row = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM group_repos WHERE group_id = ?').get(groupId) as {
|
||||
pos: number;
|
||||
};
|
||||
return row.pos;
|
||||
}
|
||||
|
||||
listGroups(): GroupSummary[] {
|
||||
const rows = this.db.prepare('SELECT * FROM groups ORDER BY position ASC, created_at ASC').all() as unknown as GroupRow[];
|
||||
return rows.map((r) => this.rowToSummary(r));
|
||||
}
|
||||
|
||||
getGroup(id: string): GroupSummary {
|
||||
const row = this.getGroupRow(id);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||
return this.rowToSummary(row);
|
||||
}
|
||||
|
||||
createGroup(opts: { label: string; description?: string; color?: string; repoIds?: string[] }): GroupSummary {
|
||||
const label = normLabel(opts.label);
|
||||
const description = normDescription(opts.description);
|
||||
const color = normColor(opts.color);
|
||||
const repoIds = opts.repoIds ?? [];
|
||||
if (!Array.isArray(repoIds)) throw httpError(400, 'BAD_REQUEST', 'repoIds must be an array');
|
||||
for (const repoId of repoIds) this.assertRepoExists(repoId);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = randomUUID();
|
||||
const position = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM groups').get() as { pos: number };
|
||||
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
this.db
|
||||
.prepare('INSERT INTO groups (id, label, description, color, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(id, label, description, color, position.pos, now, now);
|
||||
const insertRepo = this.db.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)');
|
||||
let pos = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const repoId of repoIds) {
|
||||
if (seen.has(repoId)) continue;
|
||||
seen.add(repoId);
|
||||
insertRepo.run(id, repoId, pos++, now);
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const summary = this.getGroup(id);
|
||||
this.emit('group_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
updateGroup(id: string, patch: { label?: string; description?: string | null; color?: string | null }): GroupSummary {
|
||||
const row = this.getGroupRow(id);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||
if (patch.label !== undefined) row.label = normLabel(patch.label);
|
||||
if (patch.description !== undefined) row.description = normDescription(patch.description);
|
||||
if (patch.color !== undefined) row.color = normColor(patch.color);
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare('UPDATE groups SET label = ?, description = ?, color = ?, updated_at = ? WHERE id = ?')
|
||||
.run(row.label, row.description, row.color, now, id);
|
||||
const summary = this.getGroup(id);
|
||||
this.emit('group_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
deleteGroup(id: string): boolean {
|
||||
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
||||
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
||||
if (res.changes === 0) return false;
|
||||
this.emit('group_removed', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
addRepo(groupId: string, repoId: string): GroupSummary {
|
||||
if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||
this.assertRepoExists(repoId);
|
||||
const now = new Date().toISOString();
|
||||
// INSERT OR IGNORE → idempotent (PRIMARY KEY (group_id, repo_id)).
|
||||
this.db
|
||||
.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(groupId, repoId, this.nextPosition(groupId), now);
|
||||
this.touch(groupId, now);
|
||||
const summary = this.getGroup(groupId);
|
||||
this.emit('group_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
removeRepo(groupId: string, repoId: string): GroupSummary {
|
||||
if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||
// DELETE no-op si absent → idempotent.
|
||||
this.db.prepare('DELETE FROM group_repos WHERE group_id = ? AND repo_id = ?').run(groupId, repoId);
|
||||
this.touch(groupId, new Date().toISOString());
|
||||
const summary = this.getGroup(groupId);
|
||||
this.emit('group_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private touch(groupId: string, now: string): void {
|
||||
this.db.prepare('UPDATE groups SET updated_at = ? WHERE id = ?').run(now, groupId);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,31 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// P5 — groupes de travail. Un groupe = collection de repos (many-to-many).
|
||||
// Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager
|
||||
// et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb).
|
||||
id: 5,
|
||||
sql: `
|
||||
CREATE TABLE groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE group_repos (
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, repo_id)
|
||||
);
|
||||
CREATE INDEX idx_group_repos_repo ON group_repos(repo_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
86
packages/server/src/routes/groups.ts
Normal file
86
packages/server/src/routes/groups.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type {
|
||||
AddRepoRequest,
|
||||
CreateGroupRequest,
|
||||
GroupResponse,
|
||||
GroupsListResponse,
|
||||
UpdateGroupRequest,
|
||||
} from '@arboretum/shared';
|
||||
import type { GroupManager } from '../core/group-manager.js';
|
||||
import { sendManagerError } from './repos.js';
|
||||
|
||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void {
|
||||
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||
|
||||
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
try {
|
||||
return reply.send({ group: gm.getGroup(id) } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/groups', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateGroupRequest> | null;
|
||||
if (!body || typeof body.label !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label is required' } });
|
||||
}
|
||||
try {
|
||||
const group = gm.createGroup({
|
||||
label: body.label,
|
||||
...(body.description !== undefined ? { description: body.description } : {}),
|
||||
...(body.color !== undefined ? { color: body.color } : {}),
|
||||
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
||||
});
|
||||
return reply.status(201).send({ group } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/v1/groups/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = (req.body as Partial<UpdateGroupRequest> | null) ?? {};
|
||||
try {
|
||||
const group = gm.updateGroup(id, {
|
||||
...(body.label !== undefined ? { label: body.label } : {}),
|
||||
...(body.description !== undefined ? { description: body.description } : {}),
|
||||
...(body.color !== undefined ? { color: body.color } : {}),
|
||||
});
|
||||
return reply.send({ group } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/groups/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!gm.deleteGroup(id)) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
||||
}
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/v1/groups/:id/repos', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<AddRepoRequest> | null;
|
||||
if (!body || typeof body.repoId !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'repoId is required' } });
|
||||
}
|
||||
try {
|
||||
return reply.send({ group: gm.addRepo(id, body.repoId) } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/groups/:id/repos/:repoId', async (req, reply) => {
|
||||
const { id, repoId } = req.params as { id: string; repoId: string };
|
||||
try {
|
||||
return reply.send({ group: gm.removeRepo(id, repoId) } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
encodeBinaryFrame,
|
||||
parseClientMessage,
|
||||
type GroupSummary,
|
||||
type RepoSummary,
|
||||
type ServerMessage,
|
||||
type SessionSummary,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||
import type { DiscoveryService } from '../core/discovery-service.js';
|
||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||
import type { GroupManager } from '../core/group-manager.js';
|
||||
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
@@ -26,6 +28,7 @@ export function registerWsGateway(
|
||||
manager: PtyManager,
|
||||
discovery: DiscoveryService,
|
||||
worktrees: WorktreeManager,
|
||||
groups: GroupManager,
|
||||
serverVersion: string,
|
||||
): void {
|
||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||
@@ -35,6 +38,7 @@ export function registerWsGateway(
|
||||
let helloDone = false;
|
||||
let subscribedSessions = false;
|
||||
let subscribedWorktrees = false;
|
||||
let subscribedGroups = false;
|
||||
let alive = true;
|
||||
|
||||
const send = (msg: ServerMessage): void => {
|
||||
@@ -66,6 +70,12 @@ export function registerWsGateway(
|
||||
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
|
||||
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
|
||||
};
|
||||
const onGroupUpdate = (group: GroupSummary): void => {
|
||||
if (subscribedGroups) send({ type: 'group_update', group });
|
||||
};
|
||||
const onGroupRemoved = (groupId: string): void => {
|
||||
if (subscribedGroups) send({ type: 'group_removed', groupId });
|
||||
};
|
||||
manager.on('session_update', onSessionUpdate);
|
||||
manager.on('session_exit', onSessionExit);
|
||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||
@@ -73,6 +83,8 @@ export function registerWsGateway(
|
||||
worktrees.on('repo_removed', onRepoRemoved);
|
||||
worktrees.on('worktree_update', onWorktreeUpdate);
|
||||
worktrees.on('worktree_removed', onWorktreeRemoved);
|
||||
groups.on('group_update', onGroupUpdate);
|
||||
groups.on('group_removed', onGroupRemoved);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!alive) {
|
||||
@@ -111,6 +123,7 @@ export function registerWsGateway(
|
||||
case 'sub': {
|
||||
subscribedSessions = msg.topics.includes('sessions');
|
||||
subscribedWorktrees = msg.topics.includes('worktrees');
|
||||
subscribedGroups = msg.topics.includes('groups');
|
||||
return;
|
||||
}
|
||||
case 'attach': {
|
||||
@@ -202,6 +215,8 @@ export function registerWsGateway(
|
||||
worktrees.off('repo_removed', onRepoRemoved);
|
||||
worktrees.off('worktree_update', onWorktreeUpdate);
|
||||
worktrees.off('worktree_removed', onWorktreeRemoved);
|
||||
groups.off('group_update', onGroupUpdate);
|
||||
groups.off('group_removed', onGroupRemoved);
|
||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||
channels.clear();
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
100
packages/server/test/group-manager.test.ts
Normal file
100
packages/server/test/group-manager.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||
import { GroupManager } from '../src/core/group-manager.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
/** Insère un repo minimal directement (le GroupManager n'a besoin que de repos.id). */
|
||||
function insertRepo(db: Db, id: string, path: string): void {
|
||||
db.prepare(
|
||||
"INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, NULL, '[]', 0, ?)",
|
||||
).run(id, path, id, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('GroupManager', () => {
|
||||
let db: Db;
|
||||
let gm: GroupManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openDb(':memory:');
|
||||
insertRepo(db, 'repo-a', '/tmp/a');
|
||||
insertRepo(db, 'repo-b', '/tmp/b');
|
||||
gm = new GroupManager(db);
|
||||
});
|
||||
|
||||
it('createGroup : groupe vide, persisté, événement group_update émis', () => {
|
||||
const spy = vi.fn();
|
||||
gm.on('group_update', spy);
|
||||
const g = gm.createGroup({ label: 'Sprint 42' });
|
||||
expect(g).toMatchObject({ label: 'Sprint 42', description: null, color: null, repoIds: [] });
|
||||
expect(gm.listGroups()).toHaveLength(1);
|
||||
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: g.id }));
|
||||
});
|
||||
|
||||
it('createGroup : repoIds initiaux ordonnés et dédoublonnés', () => {
|
||||
const g = gm.createGroup({ label: 'Eco', repoIds: ['repo-b', 'repo-a', 'repo-b'] });
|
||||
expect(g.repoIds).toEqual(['repo-b', 'repo-a']);
|
||||
});
|
||||
|
||||
it('createGroup : repoId inexistant → 404', () => {
|
||||
expect(() => gm.createGroup({ label: 'X', repoIds: ['nope'] })).toThrow(
|
||||
expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }),
|
||||
);
|
||||
expect(gm.listGroups()).toHaveLength(0); // rollback de la transaction
|
||||
});
|
||||
|
||||
it('createGroup : validations (label vide / trop long, couleur invalide)', () => {
|
||||
expect(() => gm.createGroup({ label: ' ' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
expect(() => gm.createGroup({ label: 'x'.repeat(101) })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
expect(() => gm.createGroup({ label: 'X', color: 'red' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
expect(gm.createGroup({ label: 'X', color: '#4f46e5' }).color).toBe('#4f46e5');
|
||||
});
|
||||
|
||||
it('getGroup : id inconnu → 404', () => {
|
||||
expect(() => gm.getGroup('nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||||
});
|
||||
|
||||
it('updateGroup : modifie label/description/color et bump updatedAt', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
const updated = gm.updateGroup(g.id, { label: 'B', description: 'hello', color: '#000000' });
|
||||
expect(updated).toMatchObject({ label: 'B', description: 'hello', color: '#000000' });
|
||||
expect(gm.updateGroup(g.id, { description: '' }).description).toBeNull(); // '' → null
|
||||
});
|
||||
|
||||
it('addRepo : idempotent (PRIMARY KEY), émet group_update', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
const spy = vi.fn();
|
||||
gm.on('group_update', spy);
|
||||
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']);
|
||||
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); // pas de doublon
|
||||
expect(gm.addRepo(g.id, 'repo-b').repoIds).toEqual(['repo-a', 'repo-b']);
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('addRepo : groupe ou repo inexistant → 404', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
expect(() => gm.addRepo('nope', 'repo-a')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||||
expect(() => gm.addRepo(g.id, 'nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }));
|
||||
});
|
||||
|
||||
it('removeRepo : idempotent (retrait d’un repo absent = no-op)', () => {
|
||||
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a'] });
|
||||
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]);
|
||||
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); // déjà absent → succès
|
||||
});
|
||||
|
||||
it('deleteGroup : true + group_removed ; id inconnu → false', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
const spy = vi.fn();
|
||||
gm.on('group_removed', spy);
|
||||
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||
expect(spy).toHaveBeenCalledWith(g.id);
|
||||
expect(gm.deleteGroup(g.id)).toBe(false);
|
||||
expect(gm.listGroups()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('CASCADE : supprimer un repo purge la membership (PRAGMA foreign_keys = ON)', () => {
|
||||
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a', 'repo-b'] });
|
||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-a', 'repo-b']);
|
||||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Types REST partagés (préfixe /api/v1).
|
||||
import type { PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js';
|
||||
import type { GroupSummary, PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js';
|
||||
|
||||
export interface ApiError {
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
@@ -91,6 +91,30 @@ export interface AdoptWorktreeRequest {
|
||||
preTrust?: boolean;
|
||||
}
|
||||
|
||||
// ---- Groupes de travail (P5) ----
|
||||
export interface GroupsListResponse {
|
||||
groups: GroupSummary[];
|
||||
}
|
||||
export interface GroupResponse {
|
||||
group: GroupSummary;
|
||||
}
|
||||
export interface CreateGroupRequest {
|
||||
label: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
/** ids de repos initiaux (défaut : []). */
|
||||
repoIds?: string[];
|
||||
}
|
||||
export interface UpdateGroupRequest {
|
||||
label?: string;
|
||||
/** null pour effacer. */
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
export interface AddRepoRequest {
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
|
||||
@@ -160,6 +160,22 @@ export interface WorktreeSummary {
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
|
||||
// ---- Groupes de travail (P5) ----
|
||||
// Un groupe regroupe plusieurs repos pour piloter des sessions Claude en simultané sur
|
||||
// plusieurs worktrees. Membership légère : seule la liste d'ids de repos est persistée ;
|
||||
// les repos/worktrees/sessions du groupe sont dérivés par filtrage sur `repoId` côté client.
|
||||
export interface GroupSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
/** couleur d'accent UI (hex `#rrggbb`) ou null. */
|
||||
color: string | null;
|
||||
/** ids de repos membres, ordonnés par leur position dans le groupe. */
|
||||
repoIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---- Messages client → serveur ----
|
||||
export type ClientMessage =
|
||||
| { type: 'hello'; protocol: number }
|
||||
@@ -172,7 +188,7 @@ export type ClientMessage =
|
||||
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||
| { type: 'ack'; channel: number; bytes: number }
|
||||
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
||||
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups'> }
|
||||
| { type: 'ping' };
|
||||
|
||||
// ---- Messages serveur → client ----
|
||||
@@ -187,6 +203,8 @@ export type ServerMessage =
|
||||
| { type: 'repo_removed'; repoId: string }
|
||||
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
||||
| { type: 'worktree_removed'; repoId: string; path: string }
|
||||
| { type: 'group_update'; group: GroupSummary }
|
||||
| { type: 'group_removed'; groupId: string }
|
||||
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||
| { type: 'pong' };
|
||||
|
||||
@@ -244,8 +262,8 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||
: null;
|
||||
case 'sub':
|
||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees')
|
||||
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees'> }
|
||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')
|
||||
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> }
|
||||
: null;
|
||||
case 'ping':
|
||||
return { type: 'ping' };
|
||||
|
||||
@@ -62,7 +62,7 @@ function assertInvariants(msg: ClientMessage): void {
|
||||
expect(msg.bytes).toBeGreaterThanOrEqual(0);
|
||||
break;
|
||||
case 'sub':
|
||||
expect(msg.topics.every((t) => t === 'sessions')).toBe(true);
|
||||
expect(msg.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')).toBe(true);
|
||||
break;
|
||||
case 'ping':
|
||||
break;
|
||||
@@ -117,9 +117,14 @@ describe('parseClientMessage — cas valides', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||
});
|
||||
|
||||
it('sub avec topics sessions/worktrees (et tableau vide accepté)', () => {
|
||||
it('sub avec topics sessions/worktrees/groups (et tableau vide accepté)', () => {
|
||||
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
||||
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
|
||||
expect(parseClientMessage('{"type":"sub","topics":["groups"]}')).toEqual({ type: 'sub', topics: ['groups'] });
|
||||
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees","groups"]}')).toEqual({
|
||||
type: 'sub',
|
||||
topics: ['sessions', 'worktrees', 'groups'],
|
||||
});
|
||||
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||
});
|
||||
|
||||
|
||||
122
packages/web/src/components/CrossRepoFeatureModal.vue
Normal file
122
packages/web/src/components/CrossRepoFeatureModal.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||
</header>
|
||||
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.branchLabel') }}
|
||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
|
||||
</label>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.baseRefLabel') }}
|
||||
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.startLabel') }}
|
||||
<select v-model="startSession" class="input">
|
||||
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.reposLabel') }}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
v-for="repo in repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
||||
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
||||
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
||||
{{ statusText(repo.id) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
||||
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
||||
</button>
|
||||
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
||||
{{ t('crossRepo.retryFailed') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RepoSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
||||
|
||||
const props = defineProps<{ repos: RepoSummary[] }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
|
||||
const branch = ref('');
|
||||
const newBranch = ref(true);
|
||||
const baseRef = ref('');
|
||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
||||
const results = ref<Record<string, CrossRepoResult>>({});
|
||||
const running = ref(false);
|
||||
|
||||
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
||||
|
||||
function statusClass(repoId: string): string {
|
||||
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
||||
}
|
||||
function statusText(repoId: string): string {
|
||||
const r = results.value[repoId];
|
||||
if (!r) return '';
|
||||
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
||||
}
|
||||
|
||||
async function run(repoIds: string[]): Promise<void> {
|
||||
if (repoIds.length === 0) return;
|
||||
running.value = true;
|
||||
for (const id of repoIds) delete results.value[id];
|
||||
try {
|
||||
await groups.createCrossRepoFeature(
|
||||
repoIds,
|
||||
{
|
||||
branch: branch.value.trim(),
|
||||
newBranch: newBranch.value,
|
||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||
startSession: startSession.value,
|
||||
},
|
||||
(result) => {
|
||||
results.value = { ...results.value, [result.repoId]: result };
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
void run([...selectedRepoIds.value]);
|
||||
}
|
||||
function retryFailed(): void {
|
||||
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
||||
}
|
||||
</script>
|
||||
39
packages/web/src/components/TerminalCell.vue
Normal file
39
packages/web/src/components/TerminalCell.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<!-- focus-within → anneau visuel : montre quelle cellule reçoit la frappe clavier -->
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b] focus-within:ring-2 focus-within:ring-sky-600">
|
||||
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||
<SessionStateBadge :session="session" />
|
||||
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||
</header>
|
||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||
<div class="min-h-0 flex-1">
|
||||
<TerminalView :session-id="session.id" mode="interactive" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
import DialogPrompt from './DialogPrompt.vue';
|
||||
import TerminalView from './TerminalView.vue';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary }>();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||
const title = computed(() => {
|
||||
const wt = worktrees.worktrees.find((w) => w.path === props.session.cwd);
|
||||
if (wt) {
|
||||
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
||||
const branch = wt.branch ?? wt.head.slice(0, 7);
|
||||
return repo ? `${repo.label} · ${branch}` : branch;
|
||||
}
|
||||
return props.session.cwd.split('/').filter(Boolean).pop() ?? props.session.cwd;
|
||||
});
|
||||
|
||||
const isWaiting = computed(() => props.session.live && props.session.activity === 'waiting' && !!props.session.dialog);
|
||||
</script>
|
||||
28
packages/web/src/components/TerminalGrid.vue
Normal file
28
packages/web/src/components/TerminalGrid.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
|
||||
<template v-else>
|
||||
<p v-if="sessions.length > MAX_CELLS" class="text-xs text-amber-400">
|
||||
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
||||
</p>
|
||||
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
||||
Sur mobile la grille retombe à une colonne (empilement). -->
|
||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<TerminalCell v-for="s in shown" :key="s.id" :session="s" class="h-80" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import TerminalCell from './TerminalCell.vue';
|
||||
|
||||
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const MAX_CELLS = 6;
|
||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||
</script>
|
||||
@@ -61,6 +61,54 @@ export default {
|
||||
dashboard: {
|
||||
title: 'Worktrees',
|
||||
allSessions: 'All sessions',
|
||||
groups: 'Groups',
|
||||
},
|
||||
groups: {
|
||||
title: 'Groups',
|
||||
new: 'New group',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
nameLabel: 'Group name',
|
||||
namePlaceholder: 'e.g. Payments stack',
|
||||
colorLabel: 'Color',
|
||||
reposLabel: 'Repositories',
|
||||
empty: 'No group yet — create one above.',
|
||||
noRepos: 'No repository selected.',
|
||||
noReposInGroup: 'No repository in this group yet — add some below.',
|
||||
noReposRegistered: 'No repository registered yet — add some from the dashboard first.',
|
||||
open: 'Open',
|
||||
edit: 'Edit repositories',
|
||||
editDone: 'Done',
|
||||
remove: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
repoCount: 'no repo | 1 repo | {n} repos',
|
||||
dashboard: 'Dashboard',
|
||||
missingRepo: 'Repository unavailable',
|
||||
removeFromGroup: 'Remove from group',
|
||||
sessionsActive: 'no active session | 1 active session | {n} active sessions',
|
||||
viewList: 'Repos',
|
||||
viewGrid: 'Terminals',
|
||||
gridEmpty: 'No active session in this group.',
|
||||
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
|
||||
newFeature: 'New cross-repo feature',
|
||||
},
|
||||
crossRepo: {
|
||||
title: 'New cross-repo feature',
|
||||
intro: 'Create the same worktree across the selected repos, in one action.',
|
||||
branchLabel: 'Branch name',
|
||||
branchPlaceholder: 'feature/…',
|
||||
newBranch: 'create branch',
|
||||
baseRefLabel: 'Base ref (optional)',
|
||||
startLabel: 'Start session',
|
||||
startNone: 'no session',
|
||||
reposLabel: 'Apply to',
|
||||
create: 'Create across {n} repos',
|
||||
creating: 'Creating…',
|
||||
retryFailed: 'Retry failed',
|
||||
close: 'Close',
|
||||
pending: 'pending…',
|
||||
ok: 'created',
|
||||
error: 'failed',
|
||||
},
|
||||
repos: {
|
||||
add: 'Add repo',
|
||||
|
||||
@@ -63,6 +63,54 @@ const fr: typeof en = {
|
||||
dashboard: {
|
||||
title: 'Worktrees',
|
||||
allSessions: 'Toutes les sessions',
|
||||
groups: 'Groupes',
|
||||
},
|
||||
groups: {
|
||||
title: 'Groupes',
|
||||
new: 'Nouveau groupe',
|
||||
create: 'Créer',
|
||||
creating: 'Création…',
|
||||
nameLabel: 'Nom du groupe',
|
||||
namePlaceholder: 'ex. Stack paiements',
|
||||
colorLabel: 'Couleur',
|
||||
reposLabel: 'Dépôts',
|
||||
empty: 'Aucun groupe — créez-en un ci-dessus.',
|
||||
noRepos: 'Aucun dépôt sélectionné.',
|
||||
noReposInGroup: 'Aucun dépôt dans ce groupe — ajoutez-en ci-dessous.',
|
||||
noReposRegistered: 'Aucun dépôt enregistré — ajoutez-en d’abord depuis le tableau de bord.',
|
||||
open: 'Ouvrir',
|
||||
edit: 'Modifier les dépôts',
|
||||
editDone: 'Terminé',
|
||||
remove: 'Supprimer',
|
||||
confirmDelete: 'Confirmer',
|
||||
repoCount: 'aucun dépôt | 1 dépôt | {n} dépôts',
|
||||
dashboard: 'Tableau de bord',
|
||||
missingRepo: 'Dépôt indisponible',
|
||||
removeFromGroup: 'Retirer du groupe',
|
||||
sessionsActive: 'aucune session active | 1 session active | {n} sessions actives',
|
||||
viewList: 'Dépôts',
|
||||
viewGrid: 'Terminaux',
|
||||
gridEmpty: 'Aucune session active dans ce groupe.',
|
||||
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
|
||||
newFeature: 'Nouvelle feature cross-repo',
|
||||
},
|
||||
crossRepo: {
|
||||
title: 'Nouvelle feature cross-repo',
|
||||
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.',
|
||||
branchLabel: 'Nom de branche',
|
||||
branchPlaceholder: 'feature/…',
|
||||
newBranch: 'créer la branche',
|
||||
baseRefLabel: 'Réf. de base (optionnel)',
|
||||
startLabel: 'Démarrer une session',
|
||||
startNone: 'aucune session',
|
||||
reposLabel: 'Appliquer à',
|
||||
create: 'Créer dans {n} dépôts',
|
||||
creating: 'Création…',
|
||||
retryFailed: 'Réessayer les échecs',
|
||||
close: 'Fermer',
|
||||
pending: 'en cours…',
|
||||
ok: 'créé',
|
||||
error: 'échec',
|
||||
},
|
||||
repos: {
|
||||
add: 'Ajouter un repo',
|
||||
|
||||
@@ -34,5 +34,6 @@ async function request<T>(path: string, method: string, body?: unknown): Promise
|
||||
export const api = {
|
||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
||||
patch: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PATCH', body),
|
||||
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TerminalSink {
|
||||
|
||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
||||
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
||||
|
||||
export interface AttachOptions {
|
||||
sessionId: string;
|
||||
@@ -116,6 +117,7 @@ export class WsClient {
|
||||
private awaitingAttached: Attachment[] = [];
|
||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
@@ -156,10 +158,11 @@ export class WsClient {
|
||||
}
|
||||
|
||||
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
||||
private activeTopics(): Array<'sessions' | 'worktrees'> {
|
||||
const t: Array<'sessions' | 'worktrees'> = [];
|
||||
private activeTopics(): Array<'sessions' | 'worktrees' | 'groups'> {
|
||||
const t: Array<'sessions' | 'worktrees' | 'groups'> = [];
|
||||
if (this.sessionListeners.size > 0) t.push('sessions');
|
||||
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
||||
if (this.groupListeners.size > 0) t.push('groups');
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -187,6 +190,16 @@ export class WsClient {
|
||||
};
|
||||
}
|
||||
|
||||
subscribeGroups(listener: (e: GroupEvent) => void): () => void {
|
||||
this.groupListeners.add(listener);
|
||||
this.connect();
|
||||
this.sendSub();
|
||||
return () => {
|
||||
this.groupListeners.delete(listener);
|
||||
this.sendSub();
|
||||
};
|
||||
}
|
||||
|
||||
sendControl(msg: ClientMessage): void {
|
||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
@@ -380,6 +393,11 @@ export class WsClient {
|
||||
for (const cb of this.worktreeListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'group_update':
|
||||
case 'group_removed': {
|
||||
for (const cb of this.groupListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
if (msg.channel !== undefined) {
|
||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||
|
||||
@@ -8,6 +8,8 @@ export const router = createRouter({
|
||||
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') },
|
||||
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
||||
{ path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue') },
|
||||
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
});
|
||||
|
||||
175
packages/web/src/stores/groups.ts
Normal file
175
packages/web/src/stores/groups.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type {
|
||||
CreateGroupRequest,
|
||||
CreateWorktreeRequest,
|
||||
GroupResponse,
|
||||
GroupsListResponse,
|
||||
GroupSummary,
|
||||
RepoSummary,
|
||||
SessionSummary,
|
||||
UpdateGroupRequest,
|
||||
WorktreeSummary,
|
||||
} from '@arboretum/shared';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||||
import { useWorktreesStore } from './worktrees';
|
||||
|
||||
/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */
|
||||
export type CrossRepoRequest = Omit<CreateWorktreeRequest, 'path'>;
|
||||
|
||||
/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */
|
||||
export interface CrossRepoResult {
|
||||
repoId: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const useGroupsStore = defineStore('groups', () => {
|
||||
const groups = ref<GroupSummary[]>([]);
|
||||
const selectedGroupId = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function upsert(group: GroupSummary): void {
|
||||
const idx = groups.value.findIndex((g) => g.id === group.id);
|
||||
if (idx >= 0) groups.value.splice(idx, 1, group);
|
||||
else groups.value.push(group);
|
||||
}
|
||||
function removeLocal(id: string): void {
|
||||
groups.value = groups.value.filter((g) => g.id !== id);
|
||||
if (selectedGroupId.value === id) selectedGroupId.value = null;
|
||||
}
|
||||
function onEvent(e: GroupEvent): void {
|
||||
if (e.type === 'group_update') upsert(e.group);
|
||||
else removeLocal(e.groupId);
|
||||
}
|
||||
|
||||
function byId(id: string): GroupSummary | undefined {
|
||||
return groups.value.find((g) => g.id === id);
|
||||
}
|
||||
|
||||
// ---- getters dérivés : réutilisent le store worktrees par filtrage repoId (zéro refetch) ----
|
||||
function reposInGroup(groupId: string): RepoSummary[] {
|
||||
const g = byId(groupId);
|
||||
if (!g) return [];
|
||||
const wt = useWorktreesStore();
|
||||
return g.repoIds
|
||||
.map((id) => wt.repos.find((r) => r.id === id))
|
||||
.filter((r): r is RepoSummary => r !== undefined);
|
||||
}
|
||||
function worktreesInGroup(groupId: string): WorktreeSummary[] {
|
||||
const g = byId(groupId);
|
||||
if (!g) return [];
|
||||
const ids = new Set(g.repoIds);
|
||||
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||||
}
|
||||
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||||
return worktreesInGroup(groupId).flatMap((w) => w.sessions);
|
||||
}
|
||||
|
||||
async function fetchGroups(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const res = await api.get<GroupsListResponse>('/api/v1/groups');
|
||||
groups.value = res.groups;
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createGroup(req: CreateGroupRequest): Promise<GroupSummary> {
|
||||
const res = await api.post<GroupResponse>('/api/v1/groups', req);
|
||||
upsert(res.group);
|
||||
return res.group;
|
||||
}
|
||||
async function updateGroup(id: string, patch: UpdateGroupRequest): Promise<GroupSummary> {
|
||||
const res = await api.patch<GroupResponse>(`/api/v1/groups/${id}`, patch);
|
||||
upsert(res.group);
|
||||
return res.group;
|
||||
}
|
||||
async function deleteGroup(id: string): Promise<void> {
|
||||
await api.delete<{ ok: true }>(`/api/v1/groups/${id}`);
|
||||
removeLocal(id);
|
||||
}
|
||||
async function addRepoToGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||||
const res = await api.post<GroupResponse>(`/api/v1/groups/${groupId}/repos`, { repoId });
|
||||
upsert(res.group);
|
||||
return res.group;
|
||||
}
|
||||
async function removeRepoFromGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||||
const res = await api.delete<GroupResponse>(`/api/v1/groups/${groupId}/repos/${repoId}`);
|
||||
upsert(res.group);
|
||||
return res.group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le même worktree (et éventuellement une session) dans chaque repo, en une action.
|
||||
* Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère
|
||||
* les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo.
|
||||
*/
|
||||
async function createCrossRepoFeature(
|
||||
repoIds: string[],
|
||||
req: CrossRepoRequest,
|
||||
onProgress?: (result: CrossRepoResult) => void,
|
||||
): Promise<CrossRepoResult[]> {
|
||||
const wt = useWorktreesStore();
|
||||
return Promise.all(
|
||||
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||||
try {
|
||||
const res = await wt.createWorktree(repoId, req);
|
||||
const result: CrossRepoResult = {
|
||||
repoId,
|
||||
status: 'ok',
|
||||
worktreePath: res.worktree.path,
|
||||
...(res.session ? { sessionId: res.session.id } : {}),
|
||||
};
|
||||
onProgress?.(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const result: CrossRepoResult = {
|
||||
repoId,
|
||||
status: 'error',
|
||||
message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
onProgress?.(result);
|
||||
return result;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function startRealtime(): void {
|
||||
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
||||
}
|
||||
function stopRealtime(): void {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
selectedGroupId,
|
||||
loading,
|
||||
loadError,
|
||||
byId,
|
||||
reposInGroup,
|
||||
worktreesInGroup,
|
||||
sessionsInGroup,
|
||||
fetchGroups,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
addRepoToGroup,
|
||||
removeRepoFromGroup,
|
||||
createCrossRepoFeature,
|
||||
startRealtime,
|
||||
stopRealtime,
|
||||
};
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('dashboard.title') }}</h1>
|
||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'groups' }" class="btn">{{ t('dashboard.groups') }}</RouterLink>
|
||||
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
||||
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
||||
<button
|
||||
|
||||
149
packages/web/src/views/GroupView.vue
Normal file
149
packages/web/src/views/GroupView.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||
<header class="flex flex-wrap items-center gap-3">
|
||||
<RouterLink :to="{ name: 'groups' }" class="text-sm text-zinc-400 hover:text-zinc-200">← {{ t('groups.title') }}</RouterLink>
|
||||
<h1 v-if="group" class="flex items-center gap-2 text-lg font-semibold text-zinc-100">
|
||||
<span class="h-3 w-3 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||
{{ group.label }}
|
||||
</h1>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button v-if="group" class="btn text-xs" :class="editingRepos ? 'border-sky-700 text-sky-300' : ''" @click="editingRepos = !editingRepos">
|
||||
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
|
||||
</button>
|
||||
<button class="btn text-xs" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">{{ t('groups.newFeature') }}</button>
|
||||
<div class="flex overflow-hidden rounded border border-zinc-800">
|
||||
<button class="px-2 py-1 text-xs" :class="view === 'list' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'" @click="view = 'list'">
|
||||
{{ t('groups.viewList') }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-xs" :class="view === 'grid' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'" @click="view = 'grid'">
|
||||
{{ t('groups.viewGrid') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn text-xs" :disabled="worktrees.loading" @click="refresh">{{ t('sessions.refresh') }}</button>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="!group" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
|
||||
<div v-if="editingRepos" class="flex flex-col gap-1 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-xs text-zinc-400">
|
||||
{{ t('groups.reposLabel') }}
|
||||
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="repo in worktrees.repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<input type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
|
||||
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
|
||||
<template v-if="view === 'list'">
|
||||
<p v-if="groupRepos.length === 0 && missingRepoIds.length === 0" class="text-sm text-zinc-500">{{ t('groups.noReposInGroup') }}</p>
|
||||
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
|
||||
<div
|
||||
v-for="id in missingRepoIds"
|
||||
:key="id"
|
||||
class="flex items-center gap-2 rounded-lg border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
|
||||
>
|
||||
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
|
||||
<button class="btn text-xs" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- mode grille : terminaux côte à côte -->
|
||||
<TerminalGrid v-else :sessions="activeGroupSessions" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<CrossRepoFeatureModal
|
||||
v-if="showFeatureModal && group"
|
||||
:repos="groupRepos"
|
||||
@close="showFeatureModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
import RepoSection from '../components/RepoSection.vue';
|
||||
import TerminalGrid from '../components/TerminalGrid.vue';
|
||||
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
const groupId = computed(() => String(route.params.id));
|
||||
const group = computed(() => groups.byId(groupId.value));
|
||||
const view = ref<'list' | 'grid'>('list');
|
||||
const editingRepos = ref(false);
|
||||
const showFeatureModal = ref(false);
|
||||
|
||||
const groupRepos = computed(() => groups.reposInGroup(groupId.value));
|
||||
|
||||
// repoIds du groupe absents du store worktrees (repo supprimé mais membership pas encore purgée côté UI).
|
||||
const missingRepoIds = computed(() => {
|
||||
const g = group.value;
|
||||
if (!g) return [];
|
||||
const known = new Set(worktrees.repos.map((r) => r.id));
|
||||
return g.repoIds.filter((id) => !known.has(id));
|
||||
});
|
||||
|
||||
// sessions vivantes et attachables des repos du groupe, en version live (store sessions).
|
||||
const activeGroupSessions = computed<SessionSummary[]>(() => {
|
||||
const byId = new Map<string, SessionSummary>();
|
||||
for (const snap of groups.sessionsInGroup(groupId.value)) {
|
||||
const live = sessions.sessions.find((x) => x.id === snap.id) ?? snap;
|
||||
if (live.live && live.attachable) byId.set(live.id, live);
|
||||
}
|
||||
return [...byId.values()];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void groups.fetchGroups();
|
||||
void worktrees.fetchAll();
|
||||
void sessions.fetchSessions();
|
||||
groups.startRealtime();
|
||||
worktrees.startRealtime();
|
||||
sessions.startRealtime();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
groups.stopRealtime();
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
});
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
|
||||
}
|
||||
|
||||
async function toggleRepo(repoId: string): Promise<void> {
|
||||
const g = group.value;
|
||||
if (!g) return;
|
||||
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
|
||||
else await groups.addRepoToGroup(g.id, repoId);
|
||||
}
|
||||
|
||||
async function removeRepo(repoId: string): Promise<void> {
|
||||
const g = group.value;
|
||||
if (g) await groups.removeRepoFromGroup(g.id, repoId);
|
||||
}
|
||||
</script>
|
||||
157
packages/web/src/views/GroupsListView.vue
Normal file
157
packages/web/src/views/GroupsListView.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||
<header class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('groups.title') }}</h1>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'dashboard' }" class="btn">{{ t('groups.dashboard') }}</RouterLink>
|
||||
<button class="btn" :disabled="groups.loading" @click="refresh">{{ t('sessions.refresh') }}</button>
|
||||
<LanguageSwitcher />
|
||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
|
||||
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" @submit.prevent="onCreate">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('groups.nameLabel') }}
|
||||
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('groups.reposLabel') }}
|
||||
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="repo in worktrees.repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
|
||||
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="creating || newLabel.trim() === ''">
|
||||
{{ creating ? t('groups.creating') : t('groups.new') }}
|
||||
</button>
|
||||
<span v-if="createError" class="text-sm text-red-400">{{ createError }}</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p v-if="groups.loadError" class="text-sm text-red-400">{{ groups.loadError }}</p>
|
||||
<p v-else-if="groups.loading && groups.groups.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="groups.groups.length === 0" class="text-sm text-zinc-500">{{ t('groups.empty') }}</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<article
|
||||
v-for="group in groups.groups"
|
||||
:key="group.id"
|
||||
class="flex items-center gap-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: group.color ?? '#52525b' }"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
|
||||
{{ group.label }}
|
||||
</RouterLink>
|
||||
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
|
||||
<span v-if="activeCount(group.id) > 0" class="badge bg-emerald-950 text-emerald-400">
|
||||
{{ t('groups.sessionsActive', activeCount(group.id)) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="btn text-xs">{{ t('groups.open') }}</RouterLink>
|
||||
<template v-if="confirmingDelete === group.id">
|
||||
<button class="btn-danger text-xs" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</button>
|
||||
<button class="btn text-xs" @click="confirmingDelete = null">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn-danger text-xs" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
const newLabel = ref('');
|
||||
const selectedRepoIds = ref<string[]>([]);
|
||||
const creating = ref(false);
|
||||
const createError = ref<string | null>(null);
|
||||
const confirmingDelete = ref<string | null>(null);
|
||||
|
||||
// nombre de sessions vivantes corrélées aux repos du groupe (badge d'aperçu).
|
||||
function activeCount(groupId: string): number {
|
||||
return groups.sessionsInGroup(groupId).filter((s) => s.live).length;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void groups.fetchGroups();
|
||||
void worktrees.fetchAll();
|
||||
void sessions.fetchSessions();
|
||||
groups.startRealtime();
|
||||
worktrees.startRealtime();
|
||||
sessions.startRealtime();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
groups.stopRealtime();
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
});
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
|
||||
}
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
creating.value = true;
|
||||
createError.value = null;
|
||||
try {
|
||||
await groups.createGroup({ label: newLabel.value.trim(), repoIds: [...selectedRepoIds.value] });
|
||||
newLabel.value = '';
|
||||
selectedRepoIds.value = [];
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id: string): Promise<void> {
|
||||
confirmingDelete.value = null;
|
||||
try {
|
||||
await groups.deleteGroup(id);
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
groups.stopRealtime();
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
await auth.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user