Compare commits
10 Commits
c177eeea07
...
9d6ed0a2a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6ed0a2a5 | ||
|
|
1cf4d29655 | ||
|
|
fbbfafae9b | ||
|
|
28b9283825 | ||
|
|
9f7c13dddb | ||
|
|
8b41140af7 | ||
|
|
04583ea25a | ||
|
|
c224abe108 | ||
|
|
33a41e7a30 | ||
|
|
bad1230a21 |
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,11 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
*.log
|
packages/server/public/
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Local data & secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
*.db-*
|
*.db-*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editors & OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code / agent tooling (local only — do not commit)
|
||||||
|
CLAUDE.md
|
||||||
|
.claude/
|
||||||
|
.remember/
|
||||||
|
|
||||||
|
# Spike scratch output
|
||||||
spikes/**/tmp/
|
spikes/**/tmp/
|
||||||
spikes/**/captures/
|
spikes/**/captures/
|
||||||
packages/server/public/
|
|
||||||
|
|||||||
79
CLAUDE.md
79
CLAUDE.md
@@ -1,79 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Projet
|
|
||||||
|
|
||||||
Arboretum : un daemon Node.js unique (`npx git-arboretum`) qui sert un dashboard web pour piloter les worktrees git et les sessions Claude Code qui tournent dessus, depuis n'importe quel appareil. Pré-MVP. L'étape **P1** (colonne vertébrale : monorepo, protocole WS, daemon, front, tests, CI) est terminée ; voir les `VERDICT.md` dans `spikes/` pour les décisions techniques actées avant implémentation.
|
|
||||||
|
|
||||||
Les commentaires du code et la doc interne sont en **français** ; le README et les messages utilisateur en anglais. Conserver cette convention.
|
|
||||||
|
|
||||||
## Commandes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build # build shared → server → web (l'ordre compte, voir ci-dessous)
|
|
||||||
npm run typecheck # tsc -b shared + server UNIQUEMENT (le web se typecheck via vue-tsc dans son build)
|
|
||||||
npm test # vitest run sur packages/*/test/**/*.test.ts
|
|
||||||
npx vitest run packages/server/test/ring-buffer.test.ts # un seul fichier
|
|
||||||
npx vitest run -t "flow control" # par nom de test
|
|
||||||
npm run dev:server # daemon en watch (tsc -b --watch + node --watch)
|
|
||||||
npm run dev:web # Vite ; proxifie /api et /ws vers le daemon sur :7317 (cf. note Origin)
|
|
||||||
npm run pack # build + npm pack du tarball git-arboretum
|
|
||||||
|
|
||||||
node packages/server/scripts/acceptance-p1.mjs # acceptation E2E P1 (daemon réel + client WS réel)
|
|
||||||
node packages/server/scripts/acceptance-p2.mjs # acceptation E2E P2 (découverte/reprise : faux ~/.claude + faux binaire claude)
|
|
||||||
```
|
|
||||||
|
|
||||||
L'acceptation exige `npm run build` au préalable (elle lance `dist/index.js`) et utilise la commande `bash` plutôt que `claude` pour ne pas consommer de quota.
|
|
||||||
|
|
||||||
**Node >= 22.16 est requis** (pas seulement recommandé) : la persistance utilise `node:sqlite` (`DatabaseSync`), natif et stable seulement à partir de cette version. `.nvmrc` = 22.
|
|
||||||
|
|
||||||
## Monorepo (npm workspaces)
|
|
||||||
|
|
||||||
- **`packages/shared`** (`@arboretum/shared`) — source unique de vérité du protocole WS (`protocol.ts`) et des types REST (`api.ts`), importée par le serveur ET le front. C'est une **dépendance runtime non publiée** : la CI pack les deux tarballs (`@arboretum/shared` + `git-arboretum`) et les installe ensemble.
|
|
||||||
- **`packages/server`** (`git-arboretum`) — le daemon Fastify, **le seul paquet publié sur npm** (bin `arboretum`). Embarque la SPA buildée dans `public/` via le hook `prepack` (`scripts/copy-web.mjs`).
|
|
||||||
- **`packages/web`** (`@arboretum/web`) — SPA Vue 3 (Pinia, vue-router, xterm.js, Tailwind 4), privée.
|
|
||||||
|
|
||||||
Ordre de build imposé : `shared` se construit en premier (projets TS `composite` avec references) ; le front doit être buildé avant `prepack` du serveur pour être embarqué.
|
|
||||||
|
|
||||||
## Architecture serveur
|
|
||||||
|
|
||||||
`buildApp()` (`packages/server/src/app.ts`) câble tout : `AuthService`, `LoginRateLimiter`, `PtyManager`, les routes REST, la gateway WS, et le service statique de la SPA. `index.ts` est le bin (parse config, ouvre la db, écoute, gère le bootstrap token et le drain au SIGTERM/SIGINT).
|
|
||||||
|
|
||||||
### Modèle de sécurité (central — ne pas affaiblir)
|
|
||||||
Un terminal web est de l'exécution de code à distance par conception. Les garde-fous sont structurants :
|
|
||||||
- Bind `127.0.0.1` par défaut ; `config.ts` **refuse** tout bind non-loopback sans `--i-know-this-exposes-a-terminal`. Accès distant recommandé via Tailscale Serve.
|
|
||||||
- Un hook `preValidation` global authentifie **toute** requête `/api/**` et `/ws`, et applique un **check Origin strict** quand l'en-tête est présent (anti cross-site WS hijacking — le cookie `SameSite=Strict` ne couvre pas les upgrades). Les routes statiques sont publiques ; les routes publiques explicites portent `config: { public: true }` (ex. login).
|
|
||||||
- Tokens stockés **hashés** (sha256), comparaison en temps constant. Le bootstrap token n'est affiché qu'une fois au premier démarrage. Le cookie de session est un payload signé HMAC. Le rate-limit du login est **global** (pas par IP : derrière Tailscale tout arrive de 127.0.0.1) avec backoff exponentiel.
|
|
||||||
|
|
||||||
### Protocole WebSocket (`packages/shared/src/protocol.ts`)
|
|
||||||
Une **seule connexion multiplexée par client**, plusieurs canaux (channels) :
|
|
||||||
- Contrôle = frames **texte JSON** ; sortie terminal = frames **binaires** `[type u8][channel u32le][payload]`. Un chunk PTY peut couper un caractère UTF-8 en frontière de frame → le décodage est délégué à `xterm.write(Uint8Array)`, jamais au transport.
|
|
||||||
- **Flow control par watermarks** (`FLOW`). Invariant anti-deadlock à préserver absolument : `ACK_EVERY_BYTES <= LOW_WATERMARK`. Le PTY n'est mis en pause que quand **tous** les clients interactifs dépassent `HIGH` ; les observers ne freinent jamais le flux. Un client trop en retard (`LAGGING_BYTES`) est coupé puis resynchronisé.
|
|
||||||
- Handshake `hello`/`hello_ok` négocie `PROTOCOL_VERSION`. `parseClientMessage` valide/sanitise tout message entrant (bornes sur dims, longueurs) — toute nouvelle commande client doit y être validée.
|
|
||||||
|
|
||||||
### PtyManager (`packages/server/src/core/pty-manager.ts`)
|
|
||||||
Lance `claude` (ou `bash`) dans node-pty. Sessions vivantes en mémoire (`Map`) ; l'historique est persisté dans la table `sessions` (sqlite). Chaque session a un `RingBuffer` (2 Mo) ; à l'attach, le client reçoit un **resync** = reset terminal + replay de la queue du ring (`REPLAY_TAIL_BYTES`). Outil mono-utilisateur : tout client interactif peut écrire, les observers sont read-only. `kill` envoie SIGTERM puis SIGKILL après un délai de grâce.
|
|
||||||
|
|
||||||
### claude-launcher (`packages/server/src/core/claude-launcher.ts`)
|
|
||||||
Couture volontairement abstraite : Arboretum enveloppe le **CLI `claude` interactif** dans un PTY (pas l'Agent SDK, pas le mode headless). La commande `bash` sert aux tests sans quota. C'est ici que se brancherait un éventuel plan B.
|
|
||||||
|
|
||||||
### Détails fastify à connaître
|
|
||||||
- La route `/ws` est enregistrée **après** le plugin `@fastify/websocket` dans un contexte encapsulé (`app.register(async (scoped) => ...)`) ; sinon le handler reçoit la signature REST `(request, reply)` au lieu de `(socket, req)`.
|
|
||||||
- `notFoundHandler` renvoie l'`index.html` de la SPA pour tout ce qui n'est pas `/api/` ou `/ws` (routing client-side).
|
|
||||||
|
|
||||||
### Base de données (`packages/server/src/db/index.ts`)
|
|
||||||
`node:sqlite` en mode WAL. Migrations idempotentes versionnées dans le tableau `MIGRATIONS` + table `schema_migrations`. Tables : `settings`, `auth_tokens`, `sessions`.
|
|
||||||
|
|
||||||
## Architecture web
|
|
||||||
|
|
||||||
SPA Vue 3. Un **singleton `wsClient`** (`src/lib/ws-client.ts`) gère l'unique connexion : handshake, reconnexion avec backoff, ré-attache des terminaux ouverts, corrélation FIFO des `attached`, et le **flow control par ACK côté client** (compteur d'octets réellement traités par xterm, remis à zéro à chaque resync, avec un ACK traînant débouncé anti-deadlock). En dev, `vite.config.ts` proxifie `/api` et `/ws` vers le daemon en **réécrivant l'en-tête Origin** vers celle du daemon (sinon le check Origin strict rejette l'origine de Vite). Le garde de routeur vérifie la session via `GET /api/v1/auth/me`.
|
|
||||||
|
|
||||||
## Spikes & roadmap
|
|
||||||
|
|
||||||
Les `spikes/sN-*/VERDICT.md` documentent des décisions qui contraignent l'implémentation à venir, notamment :
|
|
||||||
- **`--resume` doit toujours s'exécuter dans le cwd d'origine** de la session (lu dans le JSONL).
|
|
||||||
- La **vivacité** d'une session se déduit de `pid` + `procStart`, **jamais** de la présence du fichier registre.
|
|
||||||
- La distinction d'états fins (busy / waiting / idle) du futur `claude-adapter` (P3) nécessite une lecture d'écran via `@xterm/headless` (le strip ANSI naïf mange les espaces) + le registre `~/.claude/sessions`.
|
|
||||||
|
|
||||||
Les états de session du protocole sont aujourd'hui un sous-ensemble P1 (`starting`/`running`/`exited`) à étendre en P3.
|
|
||||||
125
README.md
125
README.md
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
> A self-hosted web dashboard for your git worktrees and the Claude Code sessions running on them — from any device.
|
> A self-hosted web dashboard for your git worktrees and the Claude Code sessions running on them — from any device.
|
||||||
|
|
||||||
**Status: early development (pre-MVP).** The design study and architecture are complete; implementation is in progress. Not usable yet.
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The problem
|
## The problem
|
||||||
|
|
||||||
@@ -20,7 +22,101 @@ A single Node.js daemon you run on your dev machine (`npx git-arboretum`), servi
|
|||||||
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
||||||
- **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.
|
- **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.
|
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects.
|
||||||
- **Supervision from your phone** *(post-MVP)* — PWA with push notifications when a session needs you, approve/deny without opening a terminal.
|
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; approve/deny a prompt without opening a terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Node.js ≥ 22.16** — required, not just recommended. Arboretum persists state with `node:sqlite` (`DatabaseSync`), which is native and stable only from this version. (`.nvmrc` pins `22`.)
|
||||||
|
- **The `claude` CLI** on your `PATH` if you want Arboretum to launch and manage Claude Code sessions. Arboretum wraps the interactive CLI you already use — install and authenticate it as usual.
|
||||||
|
- A **git** repository (or several) you want to manage.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Run it (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx git-arboretum
|
||||||
|
```
|
||||||
|
|
||||||
|
On first start, Arboretum prints a one-time **access token** and the URL to open:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ First start — your access token (shown once, store it safely): │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
<your-token-here>
|
||||||
|
|
||||||
|
Login at: http://127.0.0.1:7317/
|
||||||
|
```
|
||||||
|
|
||||||
|
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**.
|
||||||
|
|
||||||
|
### Run from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
||||||
|
cd arboretum
|
||||||
|
nvm use # or ensure Node ≥ 22.16
|
||||||
|
npm install
|
||||||
|
npm run build # builds shared → server → web (order matters)
|
||||||
|
node packages/server/dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Arboretum
|
||||||
|
|
||||||
|
1. **Add a repository.** From the dashboard, register a local git repo by its path. Optionally configure **post-create hooks** (e.g. `npm ci`, `cp ../.env .env`) that run automatically every time you create a new worktree for that repo.
|
||||||
|
2. **Create or adopt worktrees.** Spin up a new worktree + branch in one click (hooks run for you), or adopt a worktree you created by hand. Each worktree shows its branch, ahead/behind, and dirty-file count.
|
||||||
|
3. **Start or resume a session.** Launch a Claude Code session on a worktree, or resume one that was started in your terminal — Arboretum discovers existing sessions automatically and always resumes them in their original working directory.
|
||||||
|
4. **Watch the live states.** Each session reports whether it's *busy*, *waiting for your input*, or *idle*. Open the **web terminal** to interact directly; it survives browser disconnects (closing the tab does not kill the session).
|
||||||
|
5. **Supervise from your phone.** Install the PWA, and when a session flips to *waiting* you get a push notification. Approve or deny the prompt straight from the notification UI — no terminal required.
|
||||||
|
|
||||||
|
## Remote access from your phone
|
||||||
|
|
||||||
|
Arboretum binds to `127.0.0.1` by default and **refuses** to bind to a non-loopback address without an explicit override. The recommended (and safe) way to reach it from other devices is **[Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve)** — valid HTTPS, tailnet identity, no open ports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Expose the local daemon over HTTPS inside your tailnet
|
||||||
|
tailscale serve --bg 7317
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start Arboretum allowing your tailnet origin (the strict Origin check needs to know about it):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx git-arboretum --allow-origin https://<machine>.<tailnet>.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet. **Web Push requires HTTPS**, so Tailscale Serve (or another HTTPS front) is also what enables mobile notifications. On **iOS**, install the app to your home screen first, then allow notifications.
|
||||||
|
|
||||||
|
> ⚠️ A web terminal is remote code execution **by design**. Never expose Arboretum directly to the public internet.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All options are CLI flags:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port <n>` | `7317` | Port to listen on. |
|
||||||
|
| `--bind <addr>` | `127.0.0.1` | Bind address. Non-loopback is refused unless `--i-know-this-exposes-a-terminal` is set. |
|
||||||
|
| `--allow-origin <url>` | — | Additional allowed `Origin` (repeatable). Needed for Tailscale/HTTPS access. |
|
||||||
|
| `--db <path>` | `<data>/arboretum.db` | SQLite database path. |
|
||||||
|
| `--vapid-contact <mailto/url>` | `mailto:arboretum@localhost` | VAPID contact subject for Web Push. |
|
||||||
|
| `--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. |
|
||||||
|
|
||||||
|
State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum` (default `~/.local/share/arboretum`).
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
A web terminal is remote code execution *by design*. Arboretum's guardrails are structural:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## What makes it different
|
## What makes it different
|
||||||
|
|
||||||
@@ -35,14 +131,31 @@ A single Node.js daemon you run on your dev machine (`npx git-arboretum`), servi
|
|||||||
|
|
||||||
Anthropic's Remote Control is great at piloting *one* session from your phone. Arboretum is the layer it doesn't provide: the consolidated, self-hosted board of all your worktrees and sessions across all your repos.
|
Anthropic's Remote Control is great at piloting *one* session from your phone. Arboretum is the layer it doesn't provide: the consolidated, self-hosted board of all your worktrees and sessions across all your repos.
|
||||||
|
|
||||||
## Security model
|
|
||||||
|
|
||||||
A web terminal is remote code execution *by design*. Arboretum binds to `127.0.0.1` by default, authenticates every request **and** every WebSocket upgrade with revocable tokens, and strictly checks the `Origin` header. The recommended way to reach it from other devices is [Tailscale Serve](docs/tailscale.md) (valid HTTPS, tailnet identity, no open ports). Never expose it directly to the internet.
|
|
||||||
|
|
||||||
## A note on Claude usage
|
## A note on Claude usage
|
||||||
|
|
||||||
Arboretum wraps the **interactive** Claude Code CLI in a PTY — the same thing you run in your terminal, displayed in your browser. It does not use the Agent SDK or headless mode. Anthropic's usage policies around programmatic use may evolve; Arboretum will track CLI releases and document any impact transparently.
|
Arboretum wraps the **interactive** Claude Code CLI in a PTY — the same thing you run in your terminal, displayed in your browser. It does not use the Agent SDK or headless mode. Anthropic's usage policies around programmatic use may evolve; Arboretum will track CLI releases and document any impact transparently.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Arboretum is an npm-workspaces monorepo: `@arboretum/shared` (WS/REST protocol, source of truth), `git-arboretum` (the Fastify daemon, the published package), and `@arboretum/web` (the Vue 3 SPA).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # build shared → server → web (order matters)
|
||||||
|
npm run typecheck # tsc -b shared + server
|
||||||
|
npm test # vitest across packages
|
||||||
|
npm run dev:server # daemon in watch mode
|
||||||
|
npm run dev:web # Vite dev server (proxies /api and /ws to the daemon on :7317)
|
||||||
|
```
|
||||||
|
|
||||||
|
End-to-end acceptance scripts (run `npm run build` first):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
234
package-lock.json
generated
234
package-lock.json
generated
@@ -880,6 +880,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/source-map": {
|
||||||
|
"version": "0.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||||
|
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.25"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -1873,6 +1886,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -2217,6 +2240,15 @@
|
|||||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/headless": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
@@ -2232,6 +2264,30 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
@@ -2272,6 +2328,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -2360,6 +2428,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
@@ -2396,6 +2470,21 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/cac": {
|
"node_modules/cac": {
|
||||||
"version": "6.7.14",
|
"version": "6.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
@@ -2439,6 +2528,15 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -2503,7 +2601,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -2590,6 +2687,15 @@
|
|||||||
"stream-shift": "^1.0.2"
|
"stream-shift": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -2944,6 +3050,15 @@
|
|||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -2964,6 +3079,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -3080,6 +3208,27 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -3438,6 +3587,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
@@ -3487,7 +3642,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
@@ -3984,6 +4138,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/secure-json-parse": {
|
"node_modules/secure-json-parse": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||||
@@ -4127,6 +4287,31 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map-support": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"source-map": "^0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-support/node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/speakingurl": {
|
"node_modules/speakingurl": {
|
||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
@@ -4266,6 +4451,27 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/terser": {
|
||||||
|
"version": "5.48.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
|
||||||
|
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
|
"acorn": "^8.15.0",
|
||||||
|
"commander": "^2.20.0",
|
||||||
|
"source-map-support": "~0.5.20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"terser": "bin/terser"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thread-stream": {
|
"node_modules/thread-stream": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
@@ -4674,6 +4880,25 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -4743,12 +4968,15 @@
|
|||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||||
"fastify": "^5.0.0"
|
"@xterm/headless": "^6.0.0",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"arboretum": "dist/index.js"
|
"arboretum": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.0"
|
"@types/ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -27,9 +27,12 @@
|
|||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||||
"fastify": "^5.0.0"
|
"@xterm/headless": "^6.0.0",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.0"
|
"@types/ws": "^8.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
packages/server/scripts/acceptance-p3.mjs
Normal file
141
packages/server/scripts/acceptance-p3.mjs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P3 (sans navigateur, sans quota Claude) : worktrees multi-repo + dashboard.
|
||||||
|
// Vrai daemon + vrai repo git tmp. Couvre : enregistrement repo, création worktree avec hook
|
||||||
|
// post-create, broadcast WS worktree_update, corrélation worktree↔session (bash), suppression.
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname, basename } 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 = 7543;
|
||||||
|
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-p3-'));
|
||||||
|
const repo = join(tmp, 'demo-repo');
|
||||||
|
const wtPath = join(tmp, 'demo-repo-wt-feat');
|
||||||
|
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'] });
|
||||||
|
|
||||||
|
// Enregistrement du repo (avec un hook post-create).
|
||||||
|
const addRepo = await j('/api/v1/repos', 'POST', cookie, {
|
||||||
|
path: repo,
|
||||||
|
postCreateHooks: [{ id: 'h1', label: 'touch', run: 'touch hook-ok.txt', enabled: true }],
|
||||||
|
});
|
||||||
|
const repoSummary = (await addRepo.json()).repo;
|
||||||
|
check('POST /repos → 201 (repo valide)', addRepo.status === 201 && repoSummary?.valid === true);
|
||||||
|
|
||||||
|
const wlist0 = await (await j('/api/v1/worktrees', 'GET', cookie)).json();
|
||||||
|
check('GET /worktrees → main worktree présent', wlist0.worktrees.some((w) => w.isMain && w.branch === 'main'));
|
||||||
|
|
||||||
|
// Création d'un worktree + hook + session bash.
|
||||||
|
const created = await j(`/api/v1/repos/${repoSummary.id}/worktrees`, 'POST', cookie, {
|
||||||
|
branch: 'feat',
|
||||||
|
newBranch: true,
|
||||||
|
runHooks: true,
|
||||||
|
startSession: 'bash',
|
||||||
|
});
|
||||||
|
const createdBody = await created.json();
|
||||||
|
check('POST worktree → 201', created.status === 201 && createdBody.worktree?.branch === 'feat');
|
||||||
|
check('hook post-create exécuté', createdBody.hookResults?.[0]?.exitCode === 0 && existsSync(join(wtPath, 'hook-ok.txt')));
|
||||||
|
check('startSession bash → session managée', createdBody.session?.command === 'bash');
|
||||||
|
|
||||||
|
const pushed = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.branch === 'feat');
|
||||||
|
check('broadcast WS worktree_update', !!pushed);
|
||||||
|
|
||||||
|
// Corrélation worktree ↔ session par cwd.
|
||||||
|
await sleep(300);
|
||||||
|
const wlist1 = await (await j('/api/v1/worktrees', 'GET', cookie)).json();
|
||||||
|
const feat = wlist1.worktrees.find((w) => w.branch === 'feat');
|
||||||
|
check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live));
|
||||||
|
|
||||||
|
// Suppression forcée (une session vit dans le worktree).
|
||||||
|
const refused = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=false`, 'DELETE', cookie);
|
||||||
|
check('delete sans force (session live) → 409', refused.status === 409);
|
||||||
|
const del = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=true`, 'DELETE', cookie);
|
||||||
|
check('delete force → 200', del.status === 200);
|
||||||
|
const removed = await c.waitMsg((m) => m.type === 'worktree_removed', 8000);
|
||||||
|
check('broadcast WS worktree_removed', !!removed);
|
||||||
|
|
||||||
|
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 P3: ALL GREEN' : `\nACCEPTANCE P3: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
135
packages/server/scripts/acceptance-p4.mjs
Normal file
135
packages/server/scripts/acceptance-p4.mjs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P4 (sans navigateur, sans quota Claude) : Web Push + commande WS `answer`.
|
||||||
|
// Vrai daemon. Couvre : garde auth + Origin sur les routes push, clé VAPID exposée, subscribe
|
||||||
|
// idempotent / malformé / unsubscribe, et la commande `answer` (rejets INVALID_ANSWER /
|
||||||
|
// NOT_CONTROLLING — la validation fine `select` vit dans les tests vitest sur fixtures).
|
||||||
|
import { spawn } 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 = 7544;
|
||||||
|
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-p4-'));
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
const j = (path, method, cookie, body) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Origin: ORIGIN, ...(cookie ? { Cookie: cookie } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500);
|
||||||
|
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||||
|
check('boot + token bootstrap', !!token);
|
||||||
|
|
||||||
|
// Garde d'auth globale : route push sans cookie → 401.
|
||||||
|
const noAuth = await j('/api/v1/push/vapid-public-key', 'GET', '');
|
||||||
|
check('GET vapid-public-key sans cookie → 401', noAuth.status === 401);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Check Origin strict : origine non autorisée (même avec cookie) → 403.
|
||||||
|
const badOrigin = await fetch(`${ORIGIN}/api/v1/push/vapid-public-key`, { headers: { Origin: 'http://evil.example', Cookie: cookie } });
|
||||||
|
check('Origin invalide → 403', badOrigin.status === 403);
|
||||||
|
|
||||||
|
// Clé VAPID publique exposée (sûre).
|
||||||
|
const vapid = await j('/api/v1/push/vapid-public-key', 'GET', cookie);
|
||||||
|
const vapidBody = await vapid.json();
|
||||||
|
check('GET vapid-public-key → 200 + clé non triviale', vapid.status === 200 && typeof vapidBody.key === 'string' && vapidBody.key.length > 20);
|
||||||
|
|
||||||
|
// Abonnement : 201, UPSERT idempotent, rejet du malformé, désabonnement.
|
||||||
|
const sub = { endpoint: 'https://push.example/endpoint-1', keys: { p256dh: 'BPp256dhKeyDummy', auth: 'authDummy' } };
|
||||||
|
const s1 = await j('/api/v1/push/subscribe', 'POST', cookie, sub);
|
||||||
|
check('POST subscribe → 201', s1.status === 201);
|
||||||
|
const s2 = await j('/api/v1/push/subscribe', 'POST', cookie, sub);
|
||||||
|
check('subscribe idempotent (même endpoint) → 201', s2.status === 201);
|
||||||
|
const badSub = await j('/api/v1/push/subscribe', 'POST', cookie, { endpoint: 'x' });
|
||||||
|
check('subscribe sans keys → 400', badSub.status === 400);
|
||||||
|
const uns = await j('/api/v1/push/unsubscribe', 'POST', cookie, { endpoint: sub.endpoint });
|
||||||
|
check('POST unsubscribe → 200', uns.status === 200);
|
||||||
|
|
||||||
|
// Commande WS `answer` : rejets sur une session bash managée (pas d'état waiting) et en observer.
|
||||||
|
const created = await j('/api/v1/sessions', 'POST', cookie, { cwd: tmp, command: 'bash' });
|
||||||
|
const sess = (await created.json()).session;
|
||||||
|
check('POST /sessions bash → 201', created.status === 201 && sess?.command === 'bash');
|
||||||
|
|
||||||
|
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: 'attach', sessionId: sess.id, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
const att = await c.waitMsg((m) => m.type === 'attached');
|
||||||
|
check('attach interactive (contrôleur)', !!att && att.controlling === true);
|
||||||
|
|
||||||
|
c.send({ type: 'answer', channel: att.channel, action: 'deny' });
|
||||||
|
const invalid = await c.waitMsg((m) => m.type === 'error' && m.code === 'INVALID_ANSWER');
|
||||||
|
check('answer sur session non-waiting → INVALID_ANSWER', !!invalid);
|
||||||
|
|
||||||
|
c.send({ type: 'attach', sessionId: sess.id, mode: 'observer', cols: 80, rows: 24 });
|
||||||
|
const obs = await c.waitMsg((m) => m.type === 'attached' && m.mode === 'observer');
|
||||||
|
check('attach observer (read-only)', !!obs && obs.controlling === false);
|
||||||
|
c.send({ type: 'answer', channel: obs.channel, action: 'deny' });
|
||||||
|
const notCtrl = await c.waitMsg((m) => m.type === 'error' && m.code === 'NOT_CONTROLLING' && m.channel === obs.channel);
|
||||||
|
check('answer en observer → NOT_CONTROLLING', !!notCtrl);
|
||||||
|
|
||||||
|
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 P4: ALL GREEN' : `\nACCEPTANCE P4: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -10,8 +10,13 @@ import type { Db } from './db/index.js';
|
|||||||
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
||||||
import { PtyManager } from './core/pty-manager.js';
|
import { PtyManager } from './core/pty-manager.js';
|
||||||
import { DiscoveryService } from './core/discovery-service.js';
|
import { DiscoveryService } from './core/discovery-service.js';
|
||||||
|
import { WorktreeManager } from './core/worktree-manager.js';
|
||||||
|
import { PushService } from './core/push-service.js';
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
|
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||||
|
import { registerPushRoutes } from './routes/push.js';
|
||||||
import { registerWsGateway } from './ws/gateway.js';
|
import { registerWsGateway } from './ws/gateway.js';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -28,18 +33,22 @@ export interface AppBundle {
|
|||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
manager: PtyManager;
|
manager: PtyManager;
|
||||||
discovery: DiscoveryService;
|
discovery: DiscoveryService;
|
||||||
|
worktrees: WorktreeManager;
|
||||||
|
push: PushService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||||
const auth = new AuthService(db);
|
const auth = new AuthService(db);
|
||||||
const limiter = new LoginRateLimiter();
|
const limiter = new LoginRateLimiter();
|
||||||
const manager = new PtyManager(db, config.claudeSessionsDir);
|
const push = new PushService(db, config.vapidContact);
|
||||||
|
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||||
const discovery = new DiscoveryService({
|
const discovery = new DiscoveryService({
|
||||||
ptyManager: manager,
|
ptyManager: manager,
|
||||||
projectsDir: config.claudeProjectsDir,
|
projectsDir: config.claudeProjectsDir,
|
||||||
sessionsDir: config.claudeSessionsDir,
|
sessionsDir: config.claudeSessionsDir,
|
||||||
});
|
});
|
||||||
|
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
@@ -80,10 +89,13 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
|
|
||||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
|
registerRepoRoutes(app, worktrees);
|
||||||
|
registerWorktreeRoutes(app, worktrees);
|
||||||
|
registerPushRoutes(app, push);
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// 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).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
void app.register(async (scoped) => {
|
||||||
registerWsGateway(scoped, manager, discovery, serverVersion);
|
registerWsGateway(scoped, manager, discovery, worktrees, serverVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||||
@@ -98,5 +110,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { app, auth, manager, discovery };
|
return { app, auth, manager, discovery, worktrees, push };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface Config {
|
|||||||
claudeProjectsDir: string;
|
claudeProjectsDir: string;
|
||||||
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
||||||
claudeSessionsDir: string;
|
claudeSessionsDir: string;
|
||||||
|
/** sujet VAPID des notifications Web Push (mailto: ou URL). */
|
||||||
|
vapidContact: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(argv = process.argv.slice(2)): Config {
|
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||||
@@ -29,6 +31,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
||||||
// racine de l'install Claude (~/.claude par défaut) — surchargée par les tests d'acceptation.
|
// racine de l'install Claude (~/.claude par défaut) — surchargée par les tests d'acceptation.
|
||||||
'claude-home': { type: 'string' },
|
'claude-home': { type: 'string' },
|
||||||
|
// sujet VAPID des notifications push (contact requis par la spec Web Push).
|
||||||
|
'vapid-contact': { type: 'string' },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
});
|
});
|
||||||
@@ -55,5 +59,6 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
printToken: values['print-token'] ?? false,
|
printToken: values['print-token'] ?? false,
|
||||||
claudeProjectsDir: join(claudeHome, 'projects'),
|
claudeProjectsDir: join(claudeHome, 'projects'),
|
||||||
claudeSessionsDir: join(claudeHome, 'sessions'),
|
claudeSessionsDir: join(claudeHome, 'sessions'),
|
||||||
|
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
90
packages/server/src/core/claude-adapter.ts
Normal file
90
packages/server/src/core/claude-adapter.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// claude-adapter : déduit l'état fin d'une session (busy / waiting / idle) + le dialogue typé.
|
||||||
|
// Source PRIMAIRE = le registre ~/.claude/sessions (status stable inter-versions) ; l'écran reconstruit
|
||||||
|
// (@xterm/headless) sert uniquement à TYPER le dialogue et à couvrir le cas Trust (qui précède le
|
||||||
|
// registre). Un tracker par session vive claude, alimenté par le flux PTY (pas de re-replay du ring).
|
||||||
|
import type { SessionActivity, SessionDialog } from '@arboretum/shared';
|
||||||
|
import { ScreenReader } from './screen-reader.js';
|
||||||
|
import { classifyDialog } from './dialog-classifier.js';
|
||||||
|
import { findByPid } from './session-registry.js';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 200; // coalescence des rafales d'output avant ré-évaluation
|
||||||
|
const POLL_MS = 700; // capte les transitions busy↔idle qui n'émettent pas d'output discriminant
|
||||||
|
|
||||||
|
export interface AdapterState {
|
||||||
|
activity: SessionActivity | null;
|
||||||
|
waitingFor: string | null;
|
||||||
|
dialog: SessionDialog | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: AdapterState = { activity: null, waitingFor: null, dialog: null };
|
||||||
|
|
||||||
|
function sameState(a: AdapterState, b: AdapterState): boolean {
|
||||||
|
return a.activity === b.activity && a.waitingFor === b.waitingFor && JSON.stringify(a.dialog) === JSON.stringify(b.dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionActivityTracker {
|
||||||
|
private readonly reader = new ScreenReader(120, 32);
|
||||||
|
private state: AdapterState = EMPTY;
|
||||||
|
private debounce: NodeJS.Timeout | null = null;
|
||||||
|
private readonly poll: NodeJS.Timeout;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pid: number,
|
||||||
|
private readonly sessionsDir: string,
|
||||||
|
private readonly onChange: () => void,
|
||||||
|
) {
|
||||||
|
this.poll = setInterval(() => this.evaluate(), POLL_MS);
|
||||||
|
this.poll.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
feed(chunk: Uint8Array): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
void this.reader.feed(chunk);
|
||||||
|
if (this.debounce) return;
|
||||||
|
this.debounce = setTimeout(() => {
|
||||||
|
this.debounce = null;
|
||||||
|
this.evaluate();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
this.debounce.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
if (!this.disposed) this.reader.resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): AdapterState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Évalue l'état courant (debounce → snapshot fiable). Public pour les tests déterministes. */
|
||||||
|
evaluate(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
const reg = findByPid(this.sessionsDir, this.pid);
|
||||||
|
const next = this.derive(reg?.status ?? null, reg?.waitingFor ?? null);
|
||||||
|
if (!sameState(this.state, next)) {
|
||||||
|
this.state = next;
|
||||||
|
this.onChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private derive(status: 'busy' | 'idle' | 'waiting' | null, waitingFor: string | null): AdapterState {
|
||||||
|
if (status === 'busy') return { activity: 'busy', waitingFor: null, dialog: null };
|
||||||
|
if (status === 'idle') return { activity: 'idle', waitingFor: null, dialog: null };
|
||||||
|
if (status === 'waiting') {
|
||||||
|
const c = classifyDialog(this.reader.snapshotLines());
|
||||||
|
return { activity: 'waiting', waitingFor, dialog: c ? { kind: c.kind, waitingFor, options: c.options } : null };
|
||||||
|
}
|
||||||
|
// Pas (encore) de registre : seul le dialogue Trust le précède (spike S1).
|
||||||
|
const c = classifyDialog(this.reader.snapshotLines());
|
||||||
|
if (c?.kind === 'trust') return { activity: 'waiting', waitingFor: 'trust', dialog: { kind: 'trust', waitingFor: 'trust', options: c.options } };
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
if (this.debounce) clearTimeout(this.debounce);
|
||||||
|
clearInterval(this.poll);
|
||||||
|
this.reader.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/server/src/core/claude-trust.ts
Normal file
41
packages/server/src/core/claude-trust.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Pré-trust programmatique (spike S3) : écrit projects["<worktree>"].hasTrustDialogAccepted = true
|
||||||
|
// dans ~/.claude.json pour éviter le dialogue Trust au 1er lancement de `claude` dans un worktree neuf.
|
||||||
|
// Écriture ATOMIQUE (tmp + rename). Le chemin est injectable pour les tests (jamais le vrai HOME).
|
||||||
|
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export function claudeConfigPath(): string {
|
||||||
|
return join(homedir(), '.claude.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque un worktree comme déjà approuvé. Tolérant : fichier absent → on le crée ; fichier corrompu
|
||||||
|
* → on ABANDONNE le pré-trust (on ne l'écrase pas, pour ne pas perdre la config existante). Retourne
|
||||||
|
* true si le pré-trust a été écrit, false sinon (l'appelant continue : ce n'est pas bloquant).
|
||||||
|
*/
|
||||||
|
export function preTrustProject(worktreePath: string, filePath = claudeConfigPath()): boolean {
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return false; // contenu inattendu : on n'écrase pas
|
||||||
|
data = parsed as Record<string, unknown>;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false; // corrompu/illisible : abandon
|
||||||
|
data = {}; // fichier absent : on part d'un objet vide
|
||||||
|
}
|
||||||
|
const projects =
|
||||||
|
typeof data.projects === 'object' && data.projects !== null
|
||||||
|
? (data.projects as Record<string, Record<string, unknown>>)
|
||||||
|
: {};
|
||||||
|
projects[worktreePath] = { ...(projects[worktreePath] ?? {}), hasTrustDialogAccepted: true };
|
||||||
|
data.projects = projects;
|
||||||
|
try {
|
||||||
|
const tmp = `${filePath}.arb-tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
||||||
|
renameSync(tmp, filePath); // rename atomique : jamais de fichier à moitié écrit
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/server/src/core/dialog-classifier.ts
Normal file
46
packages/server/src/core/dialog-classifier.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) — fonctions PURES.
|
||||||
|
// L'écran sert à TYPER le dialogue et extraire ses options ; l'état (waiting vrai/faux) vient du
|
||||||
|
// registre (source primaire). Tolérant aux variations de rendu inter-versions (texte aplati + regex).
|
||||||
|
import type { DialogKind, DialogOption } from '@arboretum/shared';
|
||||||
|
|
||||||
|
export interface ClassifiedDialog {
|
||||||
|
kind: DialogKind;
|
||||||
|
options: DialogOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option numérotée, éventuellement préfixée du curseur ❯ : « ❯ 1. Yes », « 2) No »…
|
||||||
|
const OPTION_RE = /^\s*(❯)?\s*(\d+)[.)]\s+(.*\S)\s*$/;
|
||||||
|
|
||||||
|
export function parseOptions(lines: string[]): DialogOption[] {
|
||||||
|
const out: DialogOption[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = OPTION_RE.exec(line);
|
||||||
|
if (m && m[2] && m[3]) out.push({ n: Number(m[2]), label: m[3].trim(), selected: Boolean(m[1]) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le dialogue typé visible à l'écran, ou null si aucun. `trust` et `question` priment sur
|
||||||
|
* `permission` (un écran AskUserQuestion contient aussi « Esc to cancel »). Un écran numéroté non
|
||||||
|
* typé est traité en `permission` générique (best-effort — le fallback reste le terminal web).
|
||||||
|
*/
|
||||||
|
export function classifyDialog(lines: string[]): ClassifiedDialog | null {
|
||||||
|
const text = lines.join('\n');
|
||||||
|
const flat = text.replace(/\s+/g, '').toLowerCase();
|
||||||
|
const options = parseOptions(lines);
|
||||||
|
|
||||||
|
const isTrust = flat.includes('trust') && (text.includes('❯') || flat.includes('entertoconfirm') || flat.includes('trustthisfolder'));
|
||||||
|
const isQuestion = flat.includes('entertoselect') || flat.includes('tonavigate') || text.includes('↑/↓');
|
||||||
|
const isPermission = flat.includes('doyouwant') || flat.includes('esctocancel') || flat.includes('tabtoamend');
|
||||||
|
const isPlan = flat.includes('readytocode') || flat.includes('wouldyouliketoproceed');
|
||||||
|
|
||||||
|
let kind: DialogKind | null = null;
|
||||||
|
if (isTrust) kind = 'trust';
|
||||||
|
else if (isQuestion) kind = 'question';
|
||||||
|
else if (isPlan) kind = 'plan';
|
||||||
|
else if (isPermission) kind = 'permission';
|
||||||
|
else if (options.length > 0) kind = 'permission';
|
||||||
|
|
||||||
|
return kind ? { kind, options } : null;
|
||||||
|
}
|
||||||
205
packages/server/src/core/git.ts
Normal file
205
packages/server/src/core/git.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Couche git sûre : tout passe par execFile (JAMAIS de shell), arguments en tableau, `--` avant
|
||||||
|
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
import type { WorktreeGitStatus } from '@arboretum/shared';
|
||||||
|
|
||||||
|
const GIT_TIMEOUT_MS = 10_000;
|
||||||
|
const GIT_MAX_BUFFER = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface GitError extends Error {
|
||||||
|
stderr?: string;
|
||||||
|
code?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(cwd: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolveP, reject) => {
|
||||||
|
execFile(
|
||||||
|
'git',
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
timeout: GIT_TIMEOUT_MS,
|
||||||
|
maxBuffer: GIT_MAX_BUFFER,
|
||||||
|
// GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une
|
||||||
|
// session claude active) ; LC_ALL=C : sortie stable pour le parsing.
|
||||||
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
|
||||||
|
},
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
(err as GitError).stderr = String(stderr);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolveP(stdout.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
prunable: boolean;
|
||||||
|
bare: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse la sortie `git worktree list --porcelain` (testable isolément, sans repo réel). */
|
||||||
|
export function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
|
||||||
|
const out: ParsedWorktree[] = [];
|
||||||
|
let cur: Partial<ParsedWorktree> | null = null;
|
||||||
|
const flush = (): void => {
|
||||||
|
if (cur?.path) {
|
||||||
|
out.push({
|
||||||
|
path: cur.path,
|
||||||
|
head: cur.head ?? null,
|
||||||
|
branch: cur.branch ?? null,
|
||||||
|
detached: cur.detached ?? false,
|
||||||
|
locked: cur.locked ?? false,
|
||||||
|
prunable: cur.prunable ?? false,
|
||||||
|
bare: cur.bare ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cur = null;
|
||||||
|
};
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
if (line === '') {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sp = line.indexOf(' ');
|
||||||
|
const key = sp === -1 ? line : line.slice(0, sp);
|
||||||
|
const val = sp === -1 ? '' : line.slice(sp + 1);
|
||||||
|
switch (key) {
|
||||||
|
case 'worktree':
|
||||||
|
flush();
|
||||||
|
cur = { path: val };
|
||||||
|
break;
|
||||||
|
case 'HEAD':
|
||||||
|
if (cur) cur.head = val;
|
||||||
|
break;
|
||||||
|
case 'branch':
|
||||||
|
if (cur) cur.branch = val.replace(/^refs\/heads\//, '');
|
||||||
|
break;
|
||||||
|
case 'detached':
|
||||||
|
if (cur) cur.detached = true;
|
||||||
|
break;
|
||||||
|
case 'bare':
|
||||||
|
if (cur) cur.bare = true;
|
||||||
|
break;
|
||||||
|
case 'locked':
|
||||||
|
if (cur) cur.locked = true;
|
||||||
|
break;
|
||||||
|
case 'prunable':
|
||||||
|
if (cur) cur.prunable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refuse les noms de branche dangereux (défense en profondeur ; git valide déjà côté lui). */
|
||||||
|
export function isValidBranchName(name: string): boolean {
|
||||||
|
return (
|
||||||
|
/^[A-Za-z0-9._/-]+$/.test(name) &&
|
||||||
|
!name.startsWith('-') &&
|
||||||
|
!name.startsWith('/') &&
|
||||||
|
!name.endsWith('/') &&
|
||||||
|
!name.endsWith('.lock') &&
|
||||||
|
!name.includes('..') &&
|
||||||
|
!name.includes('//')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chemin absolu sans segment `..` après résolution (évite l'échappement d'arborescence). */
|
||||||
|
export function isSafeAbsolutePath(p: string): boolean {
|
||||||
|
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
|
||||||
|
export async function isRepo(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const top = (await git(path, ['rev-parse', '--show-toplevel'])).trim();
|
||||||
|
return resolve(top) === resolve(path);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branche par défaut (origin/HEAD) en nom court, ou null si indéterminée. */
|
||||||
|
export async function defaultBranch(repoPath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const ref = (await git(repoPath, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim();
|
||||||
|
return ref.replace(/^refs\/remotes\/origin\//, '') || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
|
||||||
|
return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État git d'un worktree : ahead/behind vs upstream + nombre de fichiers modifiés. */
|
||||||
|
export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitStatus> {
|
||||||
|
let upstream: string | null = null;
|
||||||
|
let ahead = 0;
|
||||||
|
let behind = 0;
|
||||||
|
try {
|
||||||
|
upstream = (await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim() || null;
|
||||||
|
} catch {
|
||||||
|
upstream = null;
|
||||||
|
}
|
||||||
|
if (upstream) {
|
||||||
|
try {
|
||||||
|
// left = commits de l'upstream absents de HEAD (behind) ; right = HEAD non poussés (ahead).
|
||||||
|
const out = (await git(worktreePath, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).trim();
|
||||||
|
const [left, right] = out.split(/\s+/);
|
||||||
|
behind = Number(left) || 0;
|
||||||
|
ahead = Number(right) || 0;
|
||||||
|
} catch {
|
||||||
|
/* refs inaccessibles : on laisse 0/0 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dirtyCount = 0;
|
||||||
|
try {
|
||||||
|
const status = await git(worktreePath, ['status', '--porcelain=v1', '--untracked-files=all']);
|
||||||
|
dirtyCount = status.split('\n').filter((l) => l.trim() !== '').length;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return { ahead, behind, dirtyCount, upstream };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addWorktree(
|
||||||
|
repoPath: string,
|
||||||
|
opts: { path: string; branch: string; newBranch: boolean; baseRef?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const args = ['worktree', 'add'];
|
||||||
|
if (opts.newBranch) args.push('-b', opts.branch);
|
||||||
|
args.push('--', opts.path);
|
||||||
|
if (opts.newBranch) {
|
||||||
|
if (opts.baseRef) args.push(opts.baseRef);
|
||||||
|
} else {
|
||||||
|
args.push(opts.branch);
|
||||||
|
}
|
||||||
|
await git(repoPath, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
|
||||||
|
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneWorktrees(repoPath: string): Promise<void> {
|
||||||
|
await git(repoPath, ['worktree', 'prune']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true si l'erreur git d'un `worktree remove` est due à des changements non sauvegardés. */
|
||||||
|
export function isDirtyWorktreeError(err: unknown): boolean {
|
||||||
|
const msg = `${(err as GitError)?.stderr ?? ''} ${(err as Error)?.message ?? ''}`;
|
||||||
|
return /contains modified or untracked files|is dirty|use --force/i.test(msg);
|
||||||
|
}
|
||||||
@@ -2,16 +2,20 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { existsSync, statSync } from 'node:fs';
|
import { existsSync, statSync } from 'node:fs';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
||||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
|
||||||
import { RingBuffer } from './ring-buffer.js';
|
import { RingBuffer } from './ring-buffer.js';
|
||||||
import { buildSpawnSpec } from './claude-launcher.js';
|
import { buildSpawnSpec } from './claude-launcher.js';
|
||||||
import { findByPid } from './session-registry.js';
|
import { findByPid } from './session-registry.js';
|
||||||
|
import { SessionActivityTracker } from './claude-adapter.js';
|
||||||
|
import type { PushService } from './push-service.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
|
|
||||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||||
const KILL_GRACE_MS = 5000;
|
const KILL_GRACE_MS = 5000;
|
||||||
|
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
|
||||||
|
const NOTIFY_DEBOUNCE_MS = 1500;
|
||||||
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
||||||
const CLAUDE_ID_POLL_MS = 400;
|
const CLAUDE_ID_POLL_MS = 400;
|
||||||
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||||
@@ -44,6 +48,12 @@ interface ManagedSession {
|
|||||||
killTimer: NodeJS.Timeout | null;
|
killTimer: NodeJS.Timeout | null;
|
||||||
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
||||||
claudeSessionId: string | null;
|
claudeSessionId: string | null;
|
||||||
|
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||||
|
tracker: SessionActivityTracker | null;
|
||||||
|
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
||||||
|
prevActivity: SessionActivity | null;
|
||||||
|
/** timer de notif push débouncée (annulé si la session quitte `waiting` avant l'échéance). */
|
||||||
|
notifyTimer: NodeJS.Timeout | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PtyManagerEvents {
|
export interface PtyManagerEvents {
|
||||||
@@ -57,6 +67,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly db: Db,
|
private readonly db: Db,
|
||||||
private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
|
private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
|
||||||
|
private readonly push: PushService | null = null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -90,7 +101,19 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
exited: null,
|
exited: null,
|
||||||
killTimer: null,
|
killTimer: null,
|
||||||
claudeSessionId: null,
|
claudeSessionId: null,
|
||||||
|
tracker: null,
|
||||||
|
prevActivity: null,
|
||||||
|
notifyTimer: null,
|
||||||
};
|
};
|
||||||
|
// Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
|
||||||
|
if (command === 'claude') {
|
||||||
|
session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
|
||||||
|
if (session.exited) return;
|
||||||
|
const summary = this.summarize(session);
|
||||||
|
this.maybeNotify(session, summary.activity ?? null);
|
||||||
|
this.emit('session_update', summary);
|
||||||
|
});
|
||||||
|
}
|
||||||
this.live.set(id, session);
|
this.live.set(id, session);
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
||||||
@@ -212,7 +235,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const hasController = [...s.clients].some((c) => c.controlling);
|
const hasController = [...s.clients].some((c) => c.controlling);
|
||||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
binding.controlling = binding.mode === 'interactive' && !hasController;
|
||||||
s.clients.add(binding);
|
s.clients.add(binding);
|
||||||
if (binding.controlling) s.proc.resize(cols, rows);
|
if (binding.controlling) {
|
||||||
|
s.proc.resize(cols, rows);
|
||||||
|
s.tracker?.resize(cols, rows);
|
||||||
|
}
|
||||||
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
||||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||||
binding.sentBytes = 0;
|
binding.sentBytes = 0;
|
||||||
@@ -243,10 +269,41 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
|
||||||
|
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
|
||||||
|
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) — protocole acté spike S3.
|
||||||
|
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
|
||||||
|
* - 'deny' : refus universel (Esc).
|
||||||
|
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
|
||||||
|
*/
|
||||||
|
answer(
|
||||||
|
sessionId: string,
|
||||||
|
binding: ClientBinding,
|
||||||
|
action: 'select' | 'confirm' | 'deny',
|
||||||
|
optionN?: number,
|
||||||
|
): 'ok' | 'not_controlling' | 'gone' | 'invalid' {
|
||||||
|
const s = this.live.get(sessionId);
|
||||||
|
if (!s || s.exited) return 'gone';
|
||||||
|
if (binding.mode !== 'interactive') return 'not_controlling';
|
||||||
|
const act = s.tracker?.snapshot();
|
||||||
|
if (action === 'select') {
|
||||||
|
// L'option doit exister dans le dialogue courant (anti-frappe fantôme mobile).
|
||||||
|
if (!act?.dialog?.options.some((o) => o.n === optionN)) return 'invalid';
|
||||||
|
s.proc.write(`${optionN}\r`);
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre — S1).
|
||||||
|
if (act?.activity !== 'waiting') return 'invalid';
|
||||||
|
s.proc.write(action === 'deny' ? '\x1b' : '\r');
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
|
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
|
||||||
const s = this.live.get(sessionId);
|
const s = this.live.get(sessionId);
|
||||||
if (!s || s.exited || !binding.controlling) return;
|
if (!s || s.exited || !binding.controlling) return;
|
||||||
s.proc.resize(cols, rows);
|
s.proc.resize(cols, rows);
|
||||||
|
s.tracker?.resize(cols, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
|
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
|
||||||
@@ -264,8 +321,39 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
// ---- interne ----
|
// ---- interne ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
|
||||||
|
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
|
||||||
|
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
|
||||||
|
*/
|
||||||
|
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
|
||||||
|
const prev = s.prevActivity;
|
||||||
|
s.prevActivity = next;
|
||||||
|
if (!this.push) return;
|
||||||
|
if (next === 'waiting' && prev !== 'waiting') {
|
||||||
|
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
||||||
|
s.notifyTimer = setTimeout(() => {
|
||||||
|
s.notifyTimer = null;
|
||||||
|
const act = s.tracker?.snapshot();
|
||||||
|
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
|
||||||
|
void this.push?.notify({
|
||||||
|
sessionId: s.id,
|
||||||
|
title: basename(s.cwd) || s.cwd,
|
||||||
|
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
|
||||||
|
kind: act.dialog?.kind ?? null,
|
||||||
|
url: `/sessions/${s.id}`,
|
||||||
|
});
|
||||||
|
}, NOTIFY_DEBOUNCE_MS);
|
||||||
|
s.notifyTimer.unref();
|
||||||
|
} else if (next !== 'waiting' && s.notifyTimer) {
|
||||||
|
clearTimeout(s.notifyTimer);
|
||||||
|
s.notifyTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleOutput(s: ManagedSession, chunk: Buffer): void {
|
private handleOutput(s: ManagedSession, chunk: Buffer): void {
|
||||||
s.ring.write(chunk);
|
s.ring.write(chunk);
|
||||||
|
s.tracker?.feed(chunk);
|
||||||
for (const c of s.clients) {
|
for (const c of s.clients) {
|
||||||
if (c.lagging) continue;
|
if (c.lagging) continue;
|
||||||
c.sendOutput(chunk);
|
c.sendOutput(chunk);
|
||||||
@@ -302,7 +390,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||||
s.exited = { exitCode, signal };
|
s.exited = { exitCode, signal };
|
||||||
|
s.tracker?.dispose();
|
||||||
|
s.tracker = null;
|
||||||
if (s.killTimer) clearTimeout(s.killTimer);
|
if (s.killTimer) clearTimeout(s.killTimer);
|
||||||
|
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
||||||
for (const c of s.clients) c.onDetached('session_exit');
|
for (const c of s.clients) c.onDetached('session_exit');
|
||||||
@@ -313,6 +404,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private summarize(s: ManagedSession): SessionSummary {
|
private summarize(s: ManagedSession): SessionSummary {
|
||||||
|
const act = s.tracker?.snapshot();
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
cwd: s.cwd,
|
cwd: s.cwd,
|
||||||
@@ -330,7 +422,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
|
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
|
||||||
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
|
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
|
||||||
attachable: !s.exited,
|
attachable: !s.exited,
|
||||||
registryStatus: null, // P2 : statut fin des managées via claude-adapter (P3-B)
|
// statut brut du registre dérivé de l'activité fine (P3-B) ; null pour bash.
|
||||||
|
registryStatus: act?.activity ?? null,
|
||||||
|
activity: act?.activity ?? null,
|
||||||
|
waitingFor: act?.waitingFor ?? null,
|
||||||
|
dialog: act?.dialog ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
packages/server/src/core/push-service.ts
Normal file
116
packages/server/src/core/push-service.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// PushService : notifications Web Push (VAPID). Clés VAPID générées une fois au bootstrap
|
||||||
|
// et stockées dans `settings` (comme le server_secret de l'auth) ; abonnements liés au token
|
||||||
|
// d'auth (token_id). Quand une session managée passe en `waiting`, pty-manager appelle notify().
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||||
|
|
||||||
|
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
||||||
|
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const webpush = require('web-push') as typeof import('web-push');
|
||||||
|
|
||||||
|
export interface PushSubscriptionInput {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge utile JSON poussée au service worker (cf. packages/web/src/sw.ts). */
|
||||||
|
export interface PushPayload {
|
||||||
|
sessionId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
kind: string | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubRow {
|
||||||
|
id: string;
|
||||||
|
endpoint: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envoi d'une notif à un abonnement — injectable pour les tests ; défaut = web-push réel. */
|
||||||
|
export type PushSender = (
|
||||||
|
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||||
|
payload: string,
|
||||||
|
options: unknown,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
export class PushService {
|
||||||
|
private readonly vapidPublic: string;
|
||||||
|
private readonly vapidPrivate: string;
|
||||||
|
private readonly send: PushSender;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly contact: string = 'mailto:arboretum@localhost',
|
||||||
|
sender?: PushSender,
|
||||||
|
) {
|
||||||
|
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
||||||
|
let pub = getSetting(db, 'vapid_public');
|
||||||
|
let priv = getSetting(db, 'vapid_private');
|
||||||
|
if (!pub || !priv) {
|
||||||
|
const keys = webpush.generateVAPIDKeys();
|
||||||
|
pub = keys.publicKey;
|
||||||
|
priv = keys.privateKey;
|
||||||
|
setSetting(db, 'vapid_public', pub);
|
||||||
|
setSetting(db, 'vapid_private', priv);
|
||||||
|
}
|
||||||
|
this.vapidPublic = pub;
|
||||||
|
this.vapidPrivate = priv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clé publique VAPID — sûre à exposer (applicationServerKey côté navigateur). */
|
||||||
|
publicKey(): string {
|
||||||
|
return this.vapidPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre (ou ré-associe) un abonnement, lié au token authentifié. */
|
||||||
|
subscribe(tokenId: string, sub: PushSubscriptionInput, userAgent: string | null): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO push_subscriptions (id, token_id, endpoint, p256dh, auth, user_agent, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(endpoint) DO UPDATE SET
|
||||||
|
token_id = excluded.token_id, p256dh = excluded.p256dh,
|
||||||
|
auth = excluded.auth, user_agent = excluded.user_agent`,
|
||||||
|
)
|
||||||
|
.run(randomUUID(), tokenId, sub.endpoint, sub.keys.p256dh, sub.keys.auth, userAgent, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(endpoint: string): void {
|
||||||
|
this.db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return (this.db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get() as { n: number }).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pousse une notification à TOUS les abonnements (un seul utilisateur, plusieurs appareils).
|
||||||
|
* Un abonnement expiré (404/410 Gone) est purgé ; les autres erreurs sont ignorées (best-effort,
|
||||||
|
* un appareil injoignable ne doit pas bloquer les autres).
|
||||||
|
*/
|
||||||
|
async notify(payload: PushPayload): Promise<void> {
|
||||||
|
const rows = this.db.prepare('SELECT id, endpoint, p256dh, auth FROM push_subscriptions').all() as unknown as SubRow[];
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const options = {
|
||||||
|
vapidDetails: { subject: this.contact, publicKey: this.vapidPublic, privateKey: this.vapidPrivate },
|
||||||
|
TTL: 60,
|
||||||
|
};
|
||||||
|
await Promise.all(
|
||||||
|
rows.map(async (r) => {
|
||||||
|
try {
|
||||||
|
await this.send({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }, body, options);
|
||||||
|
this.db.prepare('UPDATE push_subscriptions SET last_ok_at = ? WHERE id = ?').run(new Date().toISOString(), r.id);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { statusCode?: number }).statusCode;
|
||||||
|
if (code === 404 || code === 410) this.db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(r.id);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/server/src/core/screen-reader.ts
Normal file
45
packages/server/src/core/screen-reader.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
|
||||||
|
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
|
||||||
|
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import type { Terminal as XtermTerminal } from '@xterm/headless';
|
||||||
|
|
||||||
|
// @xterm/headless est publié en CommonJS : sous Node ESM natif l'import nommé échoue
|
||||||
|
// (cjs-module-lexer ne détecte pas l'export). On charge via require, typé par l'import type.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { Terminal } = require('@xterm/headless') as { Terminal: typeof XtermTerminal };
|
||||||
|
|
||||||
|
export class ScreenReader {
|
||||||
|
private readonly term: XtermTerminal;
|
||||||
|
|
||||||
|
constructor(cols = 120, rows = 40) {
|
||||||
|
this.term = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alimente le terminal avec un chunk PTY brut (la frontière UTF-8 et les séquences ANSI coupées
|
||||||
|
* sont gérées par xterm). La promesse se résout quand le chunk a été parsé (snapshot fiable ensuite).
|
||||||
|
*/
|
||||||
|
feed(chunk: Uint8Array | string): Promise<void> {
|
||||||
|
return new Promise((resolve) => this.term.write(chunk as Uint8Array, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
this.term.resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lignes visibles du viewport courant, espaces préservés (trim à droite uniquement). */
|
||||||
|
snapshotLines(): string[] {
|
||||||
|
const buf = this.term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let y = 0; y < this.term.rows; y++) {
|
||||||
|
const line = buf.getLine(buf.baseY + y);
|
||||||
|
lines.push(line ? line.translateToString(true) : '');
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.term.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
339
packages/server/src/core/worktree-manager.ts
Normal file
339
packages/server/src/core/worktree-manager.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
// Gestion des repos enregistrés et de leurs worktrees git.
|
||||||
|
// Source de vérité des worktrees = git (dérivés à la volée + cache court par repo) ; seuls les repos
|
||||||
|
// sont persistés. Corrélation worktree ↔ sessions par cwd. Mutations sérialisées par repo.
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import type {
|
||||||
|
HookRunResult,
|
||||||
|
PostCreateHook,
|
||||||
|
RepoSummary,
|
||||||
|
SessionSummary,
|
||||||
|
WorktreeGitStatus,
|
||||||
|
WorktreeSummary,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import type { PtyManager } from './pty-manager.js';
|
||||||
|
import { DiscoveryService, mergeSessions } from './discovery-service.js';
|
||||||
|
import { preTrustProject } from './claude-trust.js';
|
||||||
|
import {
|
||||||
|
addWorktree,
|
||||||
|
defaultBranch,
|
||||||
|
isDirtyWorktreeError,
|
||||||
|
isRepo,
|
||||||
|
isSafeAbsolutePath,
|
||||||
|
isValidBranchName,
|
||||||
|
listWorktrees,
|
||||||
|
pruneWorktrees,
|
||||||
|
removeWorktree,
|
||||||
|
worktreeStatus,
|
||||||
|
type ParsedWorktree,
|
||||||
|
} from './git.js';
|
||||||
|
|
||||||
|
const FACTS_TTL_MS = 2500;
|
||||||
|
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
const HOOK_OUTPUT_MAX = 64 * 1024;
|
||||||
|
|
||||||
|
interface RepoRow {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
default_branch: string | null;
|
||||||
|
post_create_hooks: string;
|
||||||
|
pre_trust: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeManagerEvents {
|
||||||
|
repo_update: [RepoSummary];
|
||||||
|
repo_removed: [string];
|
||||||
|
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
|
||||||
|
worktree_removed: [{ repoId: string; path: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes. */
|
||||||
|
function httpError(statusCode: number, code: string, message: string): Error {
|
||||||
|
return Object.assign(new Error(message), { statusCode, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHooks(json: string): PostCreateHook[] {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(json) as unknown;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr.filter(
|
||||||
|
(h): h is PostCreateHook =>
|
||||||
|
!!h && typeof h.id === 'string' && typeof h.label === 'string' && typeof h.run === 'string' && typeof h.enabled === 'boolean',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
||||||
|
return new Promise((resolveP) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
execFile(
|
||||||
|
'bash',
|
||||||
|
['-lc', hook.run],
|
||||||
|
{ cwd, timeout: HOOK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, env: process.env },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
const output = `${stdout ?? ''}${stderr ?? ''}`.slice(-HOOK_OUTPUT_MAX);
|
||||||
|
const e = err as (Error & { code?: number | string }) | null;
|
||||||
|
resolveP({
|
||||||
|
hookId: hook.id,
|
||||||
|
label: hook.label,
|
||||||
|
exitCode: e ? (typeof e.code === 'number' ? e.code : 1) : 0,
|
||||||
|
output,
|
||||||
|
durationMs: Date.now() - t0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||||
|
private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>();
|
||||||
|
private readonly locks = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly ptyManager: PtyManager,
|
||||||
|
private readonly discovery: DiscoveryService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- repos ----
|
||||||
|
|
||||||
|
private getRepoRow(id: string): RepoRow | null {
|
||||||
|
return (this.db.prepare('SELECT * FROM repos WHERE id = ?').get(id) as unknown as RepoRow | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rowToSummary(row: RepoRow): Promise<RepoSummary> {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
path: row.path,
|
||||||
|
label: row.label,
|
||||||
|
defaultBranch: row.default_branch,
|
||||||
|
postCreateHooks: parseHooks(row.post_create_hooks),
|
||||||
|
preTrust: row.pre_trust === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
valid: await isRepo(row.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepos(): Promise<RepoSummary[]> {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
|
||||||
|
return Promise.all(rows.map((r) => this.rowToSummary(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
||||||
|
const path = opts.path;
|
||||||
|
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
|
||||||
|
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
|
||||||
|
const existing = this.db.prepare('SELECT id FROM repos WHERE path = ?').get(path) as { id: string } | undefined;
|
||||||
|
if (existing) throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
|
||||||
|
const row: RepoRow = {
|
||||||
|
id: randomUUID(),
|
||||||
|
path,
|
||||||
|
label: opts.label?.trim() || basename(path),
|
||||||
|
default_branch: await defaultBranch(path),
|
||||||
|
post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []),
|
||||||
|
pre_trust: opts.preTrust ? 1 : 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.db
|
||||||
|
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at);
|
||||||
|
const summary = await this.rowToSummary(row);
|
||||||
|
this.emit('repo_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
||||||
|
const row = this.getRepoRow(id);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
||||||
|
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
||||||
|
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ? WHERE id = ?')
|
||||||
|
.run(row.label, row.post_create_hooks, row.pre_trust, id);
|
||||||
|
const summary = await this.rowToSummary(row);
|
||||||
|
this.emit('repo_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRepo(id: string): boolean {
|
||||||
|
const res = this.db.prepare('DELETE FROM repos WHERE id = ?').run(id);
|
||||||
|
if (res.changes === 0) return false;
|
||||||
|
this.factsCache.delete(id);
|
||||||
|
this.emit('repo_removed', id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- worktrees ----
|
||||||
|
|
||||||
|
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
|
||||||
|
private sessionsForCwd(path: string): SessionSummary[] {
|
||||||
|
const rp = resolve(path);
|
||||||
|
return mergeSessions(this.ptyManager.list(), this.discovery.list()).filter((s) => resolve(s.cwd) === rp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary {
|
||||||
|
return {
|
||||||
|
repoId,
|
||||||
|
path: w.path,
|
||||||
|
branch: w.branch,
|
||||||
|
head: w.head ?? '',
|
||||||
|
detached: w.detached,
|
||||||
|
locked: w.locked,
|
||||||
|
prunable: w.prunable,
|
||||||
|
isMain: resolve(w.path) === resolve(repoPath),
|
||||||
|
git: status,
|
||||||
|
sessions: this.sessionsForCwd(w.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async repoFacts(row: RepoRow, noCache = false): Promise<Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>> {
|
||||||
|
const cached = this.factsCache.get(row.id);
|
||||||
|
if (!noCache && cached && Date.now() - cached.at < FACTS_TTL_MS) return cached.facts;
|
||||||
|
const parsed = (await listWorktrees(row.path)).filter((w) => !w.bare);
|
||||||
|
const facts = await Promise.all(parsed.map(async (w) => ({ w, status: await worktreeStatus(w.path) })));
|
||||||
|
this.factsCache.set(row.id, { facts, at: Date.now() });
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepoWorktrees(repoId: string, noCache = false): Promise<WorktreeSummary[]> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) return [];
|
||||||
|
const facts = await this.repoFacts(row, noCache);
|
||||||
|
return facts.map(({ w, status }) => this.toSummary(row.id, row.path, w, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAllWorktrees(): Promise<WorktreeSummary[]> {
|
||||||
|
const rows = this.db.prepare('SELECT id FROM repos ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||||
|
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id)));
|
||||||
|
return lists.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sérialise les mutations d'un même repo (évite les courses sur .git/worktrees). */
|
||||||
|
private withLock<T>(repoId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = this.locks.get(repoId) ?? Promise.resolve();
|
||||||
|
const next = prev.then(fn, fn);
|
||||||
|
this.locks.set(
|
||||||
|
repoId,
|
||||||
|
next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findWorktree(row: RepoRow, path: string): Promise<ParsedWorktree | null> {
|
||||||
|
const rp = resolve(path);
|
||||||
|
return (await listWorktrees(row.path)).find((w) => resolve(w.path) === rp) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitWorktree(row: RepoRow, path: string): Promise<WorktreeSummary | null> {
|
||||||
|
const w = await this.findWorktree(row, path);
|
||||||
|
if (!w) return null;
|
||||||
|
const summary = this.toSummary(row.id, row.path, w, await worktreeStatus(w.path));
|
||||||
|
this.emit('worktree_update', { repoId: row.id, worktree: summary });
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorktree(
|
||||||
|
repoId: string,
|
||||||
|
req: { branch: string; newBranch: boolean; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null },
|
||||||
|
): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null }> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`);
|
||||||
|
const path = req.path ?? join(dirname(row.path), `${basename(row.path)}-wt-${req.branch.replace(/\//g, '-')}`);
|
||||||
|
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
|
||||||
|
if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`);
|
||||||
|
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
try {
|
||||||
|
await addWorktree(row.path, { path, branch: req.branch, newBranch: req.newBranch, ...(req.baseRef ? { baseRef: req.baseRef } : {}) });
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
|
||||||
|
if (req.preTrust ?? row.pre_trust === 1) preTrustProject(path);
|
||||||
|
|
||||||
|
const hookResults: HookRunResult[] = [];
|
||||||
|
if (req.runHooks ?? true) {
|
||||||
|
for (const hook of parseHooks(row.post_create_hooks)) {
|
||||||
|
if (hook.enabled) hookResults.push(await runHook(path, hook));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: SessionSummary | null = null;
|
||||||
|
if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession });
|
||||||
|
|
||||||
|
const worktree = (await this.emitWorktree(row, path)) ?? this.toSummary(row.id, row.path, { path, head: null, branch: req.branch, detached: false, locked: false, prunable: false, bare: false }, { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
|
||||||
|
return { worktree, hookResults, session };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const w = await this.findWorktree(row, req.path);
|
||||||
|
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||||
|
|
||||||
|
if (req.preTrust ?? row.pre_trust === 1) preTrustProject(w.path);
|
||||||
|
const hookResults: HookRunResult[] = [];
|
||||||
|
if (req.runHooks) {
|
||||||
|
for (const hook of parseHooks(row.post_create_hooks)) {
|
||||||
|
if (hook.enabled) hookResults.push(await runHook(w.path, hook));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const worktree = (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
|
return { worktree, hookResults };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const w = await this.findWorktree(row, path);
|
||||||
|
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||||
|
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
|
||||||
|
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
|
||||||
|
if (!force && this.sessionsForCwd(w.path).some((s) => s.live)) {
|
||||||
|
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
|
||||||
|
}
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
try {
|
||||||
|
await removeWorktree(row.path, w.path, force);
|
||||||
|
} catch (err) {
|
||||||
|
if (!force && isDirtyWorktreeError(err)) {
|
||||||
|
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — pass force to delete anyway');
|
||||||
|
}
|
||||||
|
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
this.emit('worktree_removed', { repoId, path: w.path });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prune(repoId: string): Promise<void> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
const before = await listWorktrees(row.path);
|
||||||
|
await pruneWorktrees(row.path);
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
const after = new Set((await listWorktrees(row.path)).map((w) => resolve(w.path)));
|
||||||
|
for (const w of before) {
|
||||||
|
if (!after.has(resolve(w.path))) this.emit('worktree_removed', { repoId, path: w.path });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,38 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_sessions_claude_session_id ON sessions(claude_session_id);
|
CREATE INDEX idx_sessions_claude_session_id ON sessions(claude_session_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// P3 — repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés).
|
||||||
|
id: 3,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE repos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
default_branch TEXT,
|
||||||
|
post_create_hooks TEXT NOT NULL DEFAULT '[]',
|
||||||
|
pre_trust INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// P4 — abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings.
|
||||||
|
id: 4,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token_id TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_ok_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
35
packages/server/src/routes/push.ts
Normal file
35
packages/server/src/routes/push.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Routes Web Push (P4). Toutes sous l'auth globale (preValidation) : aucune route publique.
|
||||||
|
// L'abonnement est lié au token authentifié (req.authContext.tokenId).
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
||||||
|
import type { PushService } from '../core/push-service.js';
|
||||||
|
|
||||||
|
export function registerPushRoutes(app: FastifyInstance, push: PushService): void {
|
||||||
|
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
||||||
|
|
||||||
|
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
||||||
|
const body = req.body as Partial<PushSubscribeRequest> | null;
|
||||||
|
if (!body || typeof body.endpoint !== 'string' || !body.keys || typeof body.keys.p256dh !== 'string' || typeof body.keys.auth !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint and keys{p256dh,auth} are required' } });
|
||||||
|
}
|
||||||
|
// garanti non-null : la route est protégée par le preValidation global.
|
||||||
|
const tokenId = req.authContext!.tokenId;
|
||||||
|
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
|
||||||
|
return reply.status(201).send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/push/unsubscribe', async (req, reply) => {
|
||||||
|
const body = req.body as Partial<PushUnsubscribeRequest> | null;
|
||||||
|
if (!body || typeof body.endpoint !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
||||||
|
}
|
||||||
|
push.unsubscribe(body.endpoint);
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification de test (diagnostic mobile) : pousse vers tous les abonnements de l'utilisateur.
|
||||||
|
app.post('/api/v1/push/test', async (_req, reply) => {
|
||||||
|
await push.notify({ sessionId: 'test', title: 'Arboretum', body: 'Push notifications are working.', kind: null, url: '/' });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
56
packages/server/src/routes/repos.ts
Normal file
56
packages/server/src/routes/repos.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import type { CreateRepoRequest, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
|
||||||
|
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
||||||
|
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
|
const e = err as { statusCode?: number; code?: string; message?: string };
|
||||||
|
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
||||||
|
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
||||||
|
|
||||||
|
app.post('/api/v1/repos', async (req, reply) => {
|
||||||
|
const body = req.body as Partial<CreateRepoRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path (absolute) is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const repo = await wt.addRepo({
|
||||||
|
path: body.path,
|
||||||
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
});
|
||||||
|
const res: RepoResponse = { repo };
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/v1/repos/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<UpdateRepoRequest> | null) ?? {};
|
||||||
|
try {
|
||||||
|
const repo = await wt.updateRepo(id, {
|
||||||
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
});
|
||||||
|
const res: RepoResponse = { repo };
|
||||||
|
return reply.send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/repos/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
if (!wt.removeRepo(id)) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No repo with this id' } });
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
88
packages/server/src/routes/worktrees.ts
Normal file
88
packages/server/src/routes/worktrees.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type {
|
||||||
|
AdoptWorktreeRequest,
|
||||||
|
CreateWorktreeRequest,
|
||||||
|
CreateWorktreeResponse,
|
||||||
|
WorktreeResponse,
|
||||||
|
WorktreesListResponse,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
|
export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
||||||
|
app.get('/api/v1/worktrees', async (): Promise<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
|
||||||
|
|
||||||
|
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
return { worktrees: await wt.listRepoWorktrees(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/repos/:id/worktrees', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<CreateWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.branch !== 'string' || body.branch.trim() === '') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch is required' } });
|
||||||
|
}
|
||||||
|
if (body.startSession != null && body.startSession !== 'claude' && body.startSession !== 'bash') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'startSession must be claude, bash or null' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await wt.createWorktree(id, {
|
||||||
|
branch: body.branch,
|
||||||
|
newBranch: body.newBranch !== false, // défaut : créer la branche
|
||||||
|
...(body.baseRef !== undefined ? { baseRef: body.baseRef } : {}),
|
||||||
|
...(body.path !== undefined ? { path: body.path } : {}),
|
||||||
|
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
...(body.startSession !== undefined ? { startSession: body.startSession } : {}),
|
||||||
|
});
|
||||||
|
const res: CreateWorktreeResponse = out;
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<AdoptWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await wt.adoptWorktree(id, {
|
||||||
|
path: body.path,
|
||||||
|
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
});
|
||||||
|
const res: WorktreeResponse & { hookResults: typeof out.hookResults } = { worktree: out.worktree, hookResults: out.hookResults };
|
||||||
|
return reply.send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/prune', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
try {
|
||||||
|
await wt.prune(id);
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const query = req.query as { path?: string; force?: string };
|
||||||
|
if (typeof query.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path query param is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await wt.deleteWorktree(id, query.path, query.force === 'true');
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
encodeBinaryFrame,
|
encodeBinaryFrame,
|
||||||
parseClientMessage,
|
parseClientMessage,
|
||||||
|
type RepoSummary,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
|
type WorktreeSummary,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||||
import type { DiscoveryService } from '../core/discovery-service.js';
|
import type { DiscoveryService } from '../core/discovery-service.js';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
|
||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ export function registerWsGateway(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
manager: PtyManager,
|
manager: PtyManager,
|
||||||
discovery: DiscoveryService,
|
discovery: DiscoveryService,
|
||||||
|
worktrees: WorktreeManager,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
): void {
|
): void {
|
||||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||||
@@ -30,6 +34,7 @@ export function registerWsGateway(
|
|||||||
let nextChannel = 1;
|
let nextChannel = 1;
|
||||||
let helloDone = false;
|
let helloDone = false;
|
||||||
let subscribedSessions = false;
|
let subscribedSessions = false;
|
||||||
|
let subscribedWorktrees = false;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
const send = (msg: ServerMessage): void => {
|
const send = (msg: ServerMessage): void => {
|
||||||
@@ -49,9 +54,25 @@ export function registerWsGateway(
|
|||||||
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
||||||
if (subscribedSessions) send({ type: 'session_update', session });
|
if (subscribedSessions) send({ type: 'session_update', session });
|
||||||
};
|
};
|
||||||
|
const onRepoUpdate = (repo: RepoSummary): void => {
|
||||||
|
if (subscribedWorktrees) send({ type: 'repo_update', repo });
|
||||||
|
};
|
||||||
|
const onRepoRemoved = (repoId: string): void => {
|
||||||
|
if (subscribedWorktrees) send({ type: 'repo_removed', repoId });
|
||||||
|
};
|
||||||
|
const onWorktreeUpdate = (e: { repoId: string; worktree: WorktreeSummary }): void => {
|
||||||
|
if (subscribedWorktrees) send({ type: 'worktree_update', ...e });
|
||||||
|
};
|
||||||
|
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
|
||||||
|
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
|
||||||
|
};
|
||||||
manager.on('session_update', onSessionUpdate);
|
manager.on('session_update', onSessionUpdate);
|
||||||
manager.on('session_exit', onSessionExit);
|
manager.on('session_exit', onSessionExit);
|
||||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||||
|
worktrees.on('repo_update', onRepoUpdate);
|
||||||
|
worktrees.on('repo_removed', onRepoRemoved);
|
||||||
|
worktrees.on('worktree_update', onWorktreeUpdate);
|
||||||
|
worktrees.on('worktree_removed', onWorktreeRemoved);
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
if (!alive) {
|
if (!alive) {
|
||||||
@@ -89,6 +110,7 @@ export function registerWsGateway(
|
|||||||
}
|
}
|
||||||
case 'sub': {
|
case 'sub': {
|
||||||
subscribedSessions = msg.topics.includes('sessions');
|
subscribedSessions = msg.topics.includes('sessions');
|
||||||
|
subscribedWorktrees = msg.topics.includes('worktrees');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'attach': {
|
case 'attach': {
|
||||||
@@ -139,6 +161,22 @@ export function registerWsGateway(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'answer': {
|
||||||
|
const st = channels.get(msg.channel);
|
||||||
|
if (!st) {
|
||||||
|
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = manager.answer(st.sessionId, st.binding, msg.action, msg.optionN);
|
||||||
|
if (r === 'not_controlling') {
|
||||||
|
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
|
||||||
|
} else if (r === 'gone') {
|
||||||
|
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
|
||||||
|
} else if (r === 'invalid') {
|
||||||
|
send({ type: 'error', code: 'INVALID_ANSWER', message: 'No such option in the current dialog', channel: msg.channel });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'resize': {
|
case 'resize': {
|
||||||
const st = channels.get(msg.channel);
|
const st = channels.get(msg.channel);
|
||||||
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
|
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
|
||||||
@@ -160,6 +198,10 @@ export function registerWsGateway(
|
|||||||
manager.off('session_update', onSessionUpdate);
|
manager.off('session_update', onSessionUpdate);
|
||||||
manager.off('session_exit', onSessionExit);
|
manager.off('session_exit', onSessionExit);
|
||||||
discovery.off('discovery_update', onDiscoveryUpdate);
|
discovery.off('discovery_update', onDiscoveryUpdate);
|
||||||
|
worktrees.off('repo_update', onRepoUpdate);
|
||||||
|
worktrees.off('repo_removed', onRepoRemoved);
|
||||||
|
worktrees.off('worktree_update', onWorktreeUpdate);
|
||||||
|
worktrees.off('worktree_removed', onWorktreeRemoved);
|
||||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||||
channels.clear();
|
channels.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
74
packages/server/test/claude-adapter.test.ts
Normal file
74
packages/server/test/claude-adapter.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { SessionActivityTracker } from '../src/core/claude-adapter.js';
|
||||||
|
|
||||||
|
const PID = 999_999;
|
||||||
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function writeRegistry(dir: string, status: string, waitingFor?: string): void {
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, `${PID}.json`),
|
||||||
|
JSON.stringify({ pid: PID, procStart: '123', sessionId: 'sid', cwd: '/x', status, ...(waitingFor ? { waitingFor } : {}) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionActivityTracker', () => {
|
||||||
|
let dir: string;
|
||||||
|
let tracker: SessionActivityTracker;
|
||||||
|
let changes: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-adapter-'));
|
||||||
|
changes = 0;
|
||||||
|
tracker = new SessionActivityTracker(PID, dir, () => {
|
||||||
|
changes++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
tracker.dispose();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registre busy → idle : transitions d’activité + onChange sur changement uniquement', () => {
|
||||||
|
writeRegistry(dir, 'busy');
|
||||||
|
tracker.evaluate();
|
||||||
|
expect(tracker.snapshot().activity).toBe('busy');
|
||||||
|
expect(changes).toBe(1);
|
||||||
|
|
||||||
|
tracker.evaluate(); // rien n'a changé
|
||||||
|
expect(changes).toBe(1);
|
||||||
|
|
||||||
|
writeRegistry(dir, 'idle');
|
||||||
|
tracker.evaluate();
|
||||||
|
expect(tracker.snapshot().activity).toBe('idle');
|
||||||
|
expect(changes).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registre waiting + écran permission → activity waiting, dialog typé permission', async () => {
|
||||||
|
await tracker['reader'].feed('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\nEsc to cancel · Tab to amend\r\n');
|
||||||
|
await sleep(30);
|
||||||
|
writeRegistry(dir, 'waiting', 'permission prompt');
|
||||||
|
tracker.evaluate();
|
||||||
|
const s = tracker.snapshot();
|
||||||
|
expect(s.activity).toBe('waiting');
|
||||||
|
expect(s.waitingFor).toBe('permission prompt');
|
||||||
|
expect(s.dialog?.kind).toBe('permission');
|
||||||
|
expect(s.dialog?.options.find((o) => o.n === 1)).toMatchObject({ selected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pas de registre + écran Trust → waiting/trust (le Trust précède le registre)', async () => {
|
||||||
|
await tracker['reader'].feed('\x1b[2J\x1b[HDo you trust the files in this folder?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n');
|
||||||
|
await sleep(30);
|
||||||
|
tracker.evaluate();
|
||||||
|
const s = tracker.snapshot();
|
||||||
|
expect(s.activity).toBe('waiting');
|
||||||
|
expect(s.dialog?.kind).toBe('trust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pas de registre + pas de dialogue → état vide (inconnu)', () => {
|
||||||
|
tracker.evaluate();
|
||||||
|
expect(tracker.snapshot()).toMatchObject({ activity: null, dialog: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
39
packages/server/test/claude-trust.test.ts
Normal file
39
packages/server/test/claude-trust.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { preTrustProject } from '../src/core/claude-trust.js';
|
||||||
|
|
||||||
|
describe('preTrustProject', () => {
|
||||||
|
let dir: string;
|
||||||
|
let cfg: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-trust-'));
|
||||||
|
cfg = join(dir, '.claude.json');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crée le fichier s’il est absent et marque le worktree approuvé', () => {
|
||||||
|
expect(preTrustProject('/home/u/proj-wt-x', cfg)).toBe(true);
|
||||||
|
const data = JSON.parse(readFileSync(cfg, 'utf8'));
|
||||||
|
expect(data.projects['/home/u/proj-wt-x'].hasTrustDialogAccepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('préserve la config existante (autres clés et projets)', () => {
|
||||||
|
writeFileSync(cfg, JSON.stringify({ theme: 'dark', projects: { '/other': { foo: 1 } } }));
|
||||||
|
expect(preTrustProject('/home/u/new', cfg)).toBe(true);
|
||||||
|
const data = JSON.parse(readFileSync(cfg, 'utf8'));
|
||||||
|
expect(data.theme).toBe('dark');
|
||||||
|
expect(data.projects['/other']).toEqual({ foo: 1 });
|
||||||
|
expect(data.projects['/home/u/new'].hasTrustDialogAccepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fichier corrompu → abandon SANS écraser (retourne false)', () => {
|
||||||
|
writeFileSync(cfg, '{ this is not json');
|
||||||
|
expect(preTrustProject('/home/u/x', cfg)).toBe(false);
|
||||||
|
expect(readFileSync(cfg, 'utf8')).toBe('{ this is not json'); // intact
|
||||||
|
expect(existsSync(`${cfg}.arb-tmp`)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/server/test/dialog-detection.test.ts
Normal file
115
packages/server/test/dialog-detection.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import type { DialogKind } from '@arboretum/shared';
|
||||||
|
import { ScreenReader } from '../src/core/screen-reader.js';
|
||||||
|
import { classifyDialog, parseOptions, type ClassifiedDialog } from '../src/core/dialog-classifier.js';
|
||||||
|
|
||||||
|
const capturesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'spikes', 's3-tui', 'captures');
|
||||||
|
|
||||||
|
/** Rejoue un flux PTY brut par segments (comme l'adapter en prod) et collecte écran+dialogue détecté. */
|
||||||
|
async function replay(fixture: string): Promise<Array<{ dialog: ClassifiedDialog; text: string }>> {
|
||||||
|
const data = readFileSync(join(capturesDir, fixture));
|
||||||
|
const reader = new ScreenReader(120, 40);
|
||||||
|
const seen: Array<{ dialog: ClassifiedDialog; text: string }> = [];
|
||||||
|
const STEP = 512;
|
||||||
|
for (let i = 0; i < data.length; i += STEP) {
|
||||||
|
await reader.feed(data.subarray(i, i + STEP)); // xterm gère les séquences coupées en frontière
|
||||||
|
const lines = reader.snapshotLines();
|
||||||
|
const d = classifyDialog(lines);
|
||||||
|
if (d) seen.push({ dialog: d, text: lines.join('\n') });
|
||||||
|
}
|
||||||
|
reader.dispose();
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScreenReader (@xterm/headless) — anti strip-ANSI naïf', () => {
|
||||||
|
it('préserve les espaces du dialogue (« Do you want to create », pas « Doyouwant »)', async () => {
|
||||||
|
const seen = await replay('perm-write2.raw.log');
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
expect(seen.some((s) => /Do you want to create/.test(s.text))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyDialog — unitaire (lignes synthétiques)', () => {
|
||||||
|
it('trust', () => {
|
||||||
|
const d = classifyDialog(['Do you trust the files in this folder?', '❯ 1. Yes, I trust this folder', '2. No, exit']);
|
||||||
|
expect(d?.kind).toBe('trust');
|
||||||
|
expect(d?.options).toHaveLength(2);
|
||||||
|
expect(d?.options[0]).toMatchObject({ n: 1, selected: true });
|
||||||
|
});
|
||||||
|
it('permission', () => {
|
||||||
|
const d = classifyDialog(['Do you want to create s3-edit.txt?', '❯ 1. Yes', '3. No', 'Esc to cancel · Tab to amend']);
|
||||||
|
expect(d?.kind).toBe('permission');
|
||||||
|
});
|
||||||
|
it('question (prime sur permission malgré « Esc to cancel »)', () => {
|
||||||
|
const d = classifyDialog(['☐ Couleur', '❯ 1. Rouge', '2. Bleu', 'Enter to select · ↑/↓ to navigate · Esc to cancel']);
|
||||||
|
expect(d?.kind).toBe('question');
|
||||||
|
});
|
||||||
|
it('aucun dialogue → null', () => {
|
||||||
|
expect(classifyDialog(['just some output', 'no options here'])).toBeNull();
|
||||||
|
});
|
||||||
|
it('parseOptions tolère « ❯ 2) Label » et trim', () => {
|
||||||
|
expect(parseOptions([' ❯ 2) Yes, allow all '])).toEqual([{ n: 2, label: 'Yes, allow all', selected: true }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('détection sur fixtures réelles S3 (replay headless)', () => {
|
||||||
|
it('perm-write2 → permission avec options Yes/…/No, option 1 sélectionnée', async () => {
|
||||||
|
const perms = (await replay('perm-write2.raw.log')).filter((s) => s.dialog.kind === 'permission');
|
||||||
|
expect(perms.length).toBeGreaterThan(0);
|
||||||
|
const withOpts = perms.find((s) => s.dialog.options.length >= 2);
|
||||||
|
expect(withOpts).toBeDefined();
|
||||||
|
const labels = withOpts!.dialog.options.map((o) => o.label).join(' | ');
|
||||||
|
expect(labels).toMatch(/Yes/);
|
||||||
|
expect(labels).toMatch(/No/);
|
||||||
|
expect(withOpts!.dialog.options.find((o) => o.n === 1)?.selected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ask2 → question avec une option « Rouge »', async () => {
|
||||||
|
const qs = (await replay('ask2.raw.log')).filter((s) => s.dialog.kind === 'question');
|
||||||
|
expect(qs.length).toBeGreaterThan(0);
|
||||||
|
expect(qs.some((s) => s.dialog.options.some((o) => /Rouge/.test(o.label)))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trust → dialogue trust détecté', async () => {
|
||||||
|
const seen = await replay('trust.raw.log');
|
||||||
|
expect(seen.some((s) => s.dialog.kind === 'trust')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Campagne de fiabilité P4-C : chaque type de dialogue ciblé par la supervision mobile doit être
|
||||||
|
// classifié de façon fiable (cf. « reste à faire P4 » du verdict S3 : couvrir le refus Esc et le plan).
|
||||||
|
describe('campagne de fiabilité P4-C — tous les types de dialogue', () => {
|
||||||
|
const realCaptures: Array<{ fixture: string; kind: DialogKind }> = [
|
||||||
|
{ fixture: 'trust.raw.log', kind: 'trust' },
|
||||||
|
{ fixture: 'perm-write2.raw.log', kind: 'permission' },
|
||||||
|
{ fixture: 'perm-bash2.raw.log', kind: 'permission' },
|
||||||
|
{ fixture: 'ask2.raw.log', kind: 'question' },
|
||||||
|
];
|
||||||
|
for (const c of realCaptures) {
|
||||||
|
it(`${c.fixture} → ${c.kind} détecté (capture réelle)`, async () => {
|
||||||
|
const seen = await replay(c.fixture);
|
||||||
|
expect(seen.some((s) => s.dialog.kind === c.kind)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('refus par Esc (deny-esc2) : un dialogue est bien affiché avant l’annulation', async () => {
|
||||||
|
// un dialogue détecté = l'utilisateur peut répondre « deny » (Esc) depuis le mobile sans terminal.
|
||||||
|
const seen = await replay('deny-esc2.raw.log');
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plan (synthétique — pas de capture réelle) : « Would you like to proceed? »', () => {
|
||||||
|
const d = classifyDialog([
|
||||||
|
'Here is my implementation plan:',
|
||||||
|
' - step one',
|
||||||
|
'❯ 1. Yes, proceed',
|
||||||
|
'2. No, keep planning',
|
||||||
|
'Would you like to proceed?',
|
||||||
|
]);
|
||||||
|
expect(d?.kind).toBe('plan');
|
||||||
|
expect(d?.options.find((o) => o.n === 1)).toMatchObject({ selected: true, label: 'Yes, proceed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
129
packages/server/test/git.test.ts
Normal file
129
packages/server/test/git.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it, afterEach } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve, basename, dirname } from 'node:path';
|
||||||
|
import {
|
||||||
|
parseWorktreePorcelain,
|
||||||
|
isValidBranchName,
|
||||||
|
isSafeAbsolutePath,
|
||||||
|
isRepo,
|
||||||
|
listWorktrees,
|
||||||
|
worktreeStatus,
|
||||||
|
addWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
pruneWorktrees,
|
||||||
|
isDirtyWorktreeError,
|
||||||
|
} from '../src/core/git.js';
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTmpRepo(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-git-'));
|
||||||
|
dirs.push(dir);
|
||||||
|
const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||||
|
run('init', '-b', 'main');
|
||||||
|
run('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
run('config', 'user.name', 'Test');
|
||||||
|
writeFileSync(join(dir, 'README.md'), '# test\n');
|
||||||
|
run('add', '-A');
|
||||||
|
run('commit', '-m', 'init');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseWorktreePorcelain', () => {
|
||||||
|
it('parse le bloc principal, détaché, locked et prunable', () => {
|
||||||
|
const out = [
|
||||||
|
'worktree /repo',
|
||||||
|
'HEAD abc123',
|
||||||
|
'branch refs/heads/main',
|
||||||
|
'',
|
||||||
|
'worktree /repo-wt-x',
|
||||||
|
'HEAD def456',
|
||||||
|
'detached',
|
||||||
|
'locked reason here',
|
||||||
|
'',
|
||||||
|
'worktree /repo-wt-gone',
|
||||||
|
'HEAD 000',
|
||||||
|
'prunable gitdir file points to non-existent location',
|
||||||
|
'', // bloc final terminé par une ligne vide
|
||||||
|
].join('\n');
|
||||||
|
const wts = parseWorktreePorcelain(out);
|
||||||
|
expect(wts).toHaveLength(3);
|
||||||
|
expect(wts[0]).toMatchObject({ path: '/repo', branch: 'main', detached: false });
|
||||||
|
expect(wts[1]).toMatchObject({ path: '/repo-wt-x', detached: true, locked: true, branch: null });
|
||||||
|
expect(wts[2]).toMatchObject({ path: '/repo-wt-gone', prunable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolère un bloc final sans ligne vide', () => {
|
||||||
|
const wts = parseWorktreePorcelain('worktree /a\nHEAD x\nbranch refs/heads/dev');
|
||||||
|
expect(wts).toEqual([
|
||||||
|
{ path: '/a', head: 'x', branch: 'dev', detached: false, locked: false, prunable: false, bare: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('isValidBranchName', () => {
|
||||||
|
expect(isValidBranchName('feature/foo-1.2')).toBe(true);
|
||||||
|
expect(isValidBranchName('-foo')).toBe(false);
|
||||||
|
expect(isValidBranchName('a..b')).toBe(false);
|
||||||
|
expect(isValidBranchName('a/')).toBe(false);
|
||||||
|
expect(isValidBranchName('x.lock')).toBe(false);
|
||||||
|
expect(isValidBranchName('a b')).toBe(false);
|
||||||
|
});
|
||||||
|
it('isSafeAbsolutePath', () => {
|
||||||
|
expect(isSafeAbsolutePath('/home/u/proj-wt-x')).toBe(true);
|
||||||
|
expect(isSafeAbsolutePath('relative/x')).toBe(false);
|
||||||
|
expect(isSafeAbsolutePath('/home/u/../etc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opérations git (repo tmp réel)', () => {
|
||||||
|
it('isRepo : racine vs non-repo', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
expect(await isRepo(repo)).toBe(true);
|
||||||
|
const notRepo = mkdtempSync(join(tmpdir(), 'arb-nogit-'));
|
||||||
|
dirs.push(notRepo);
|
||||||
|
expect(await isRepo(notRepo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add → list → status (dirty) → remove (refus dirty puis force)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await addWorktree(repo, { path: wtPath, branch: 'feat', newBranch: true });
|
||||||
|
|
||||||
|
const list = await listWorktrees(repo);
|
||||||
|
const wt = list.find((w) => resolve(w.path) === resolve(wtPath));
|
||||||
|
expect(wt?.branch).toBe('feat');
|
||||||
|
|
||||||
|
// worktree propre
|
||||||
|
expect((await worktreeStatus(wtPath)).dirtyCount).toBe(0);
|
||||||
|
// un fichier non suivi → dirty
|
||||||
|
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n');
|
||||||
|
expect((await worktreeStatus(wtPath)).dirtyCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// remove sans --force refusé (worktree sale)
|
||||||
|
let dirtyErr: unknown;
|
||||||
|
await removeWorktree(repo, wtPath, false).catch((e) => (dirtyErr = e));
|
||||||
|
expect(dirtyErr).toBeDefined();
|
||||||
|
expect(isDirtyWorktreeError(dirtyErr)).toBe(true);
|
||||||
|
|
||||||
|
// remove --force réussit
|
||||||
|
await removeWorktree(repo, wtPath, true);
|
||||||
|
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prune retire un worktree dont le dossier a disparu', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
|
||||||
|
await addWorktree(repo, { path: wtPath, branch: 'gone', newBranch: true });
|
||||||
|
rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
|
||||||
|
await pruneWorktrees(repo);
|
||||||
|
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||||
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
||||||
|
import type { PushPayload, PushService } from '../src/core/push-service.js';
|
||||||
import { openDb, type Db } from '../src/db/index.js';
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
@@ -73,6 +74,8 @@ type BindingSpies = ClientBinding & {
|
|||||||
onControlChanged: Mock<(controlling: boolean) => void>;
|
onControlChanged: Mock<(controlling: boolean) => void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
let channelSeq = 1;
|
let channelSeq = 1;
|
||||||
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
||||||
return {
|
return {
|
||||||
@@ -290,6 +293,97 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('answer (P4-A)', () => {
|
||||||
|
it('select → "N\\r" si l’option existe, deny → Esc ; rejets gone/not_controlling/invalid', async () => {
|
||||||
|
const sessDir = mkdtempSync(join(tmpdir(), 'arb-answer-'));
|
||||||
|
const m = new PtyManager(db, sessDir);
|
||||||
|
try {
|
||||||
|
const summary = m.spawn({ cwd, command: 'claude' });
|
||||||
|
const p = lastPty();
|
||||||
|
// écran : dialogue de permission à 2 options (la 1re est sélectionnée ❯)
|
||||||
|
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\nEsc to cancel\r\n');
|
||||||
|
writeFileSync(
|
||||||
|
join(sessDir, `${p.pid}.json`),
|
||||||
|
JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status: 'waiting', waitingFor: 'permission prompt' }),
|
||||||
|
);
|
||||||
|
await sleep(260); // laisse le screen reader + le debounce (200ms) du tracker établir l’état
|
||||||
|
|
||||||
|
const a = makeBinding('interactive');
|
||||||
|
m.attach(summary.id, a, 80, 24);
|
||||||
|
p.write.mockClear();
|
||||||
|
|
||||||
|
// option inexistante → invalid (anti-frappe fantôme), aucun write
|
||||||
|
expect(m.answer(summary.id, a, 'select', 9)).toBe('invalid');
|
||||||
|
expect(p.write).not.toHaveBeenCalled();
|
||||||
|
// option existante → "1\r"
|
||||||
|
expect(m.answer(summary.id, a, 'select', 1)).toBe('ok');
|
||||||
|
expect(p.write).toHaveBeenCalledWith('1\r');
|
||||||
|
// deny → Esc
|
||||||
|
expect(m.answer(summary.id, a, 'deny')).toBe('ok');
|
||||||
|
expect(p.write).toHaveBeenCalledWith('\x1b');
|
||||||
|
|
||||||
|
// observer read-only
|
||||||
|
const obs = makeBinding('observer');
|
||||||
|
m.attach(summary.id, obs, 80, 24);
|
||||||
|
expect(m.answer(summary.id, obs, 'deny')).toBe('not_controlling');
|
||||||
|
// session inconnue → gone
|
||||||
|
expect(m.answer('nope', a, 'deny')).toBe('gone');
|
||||||
|
} finally {
|
||||||
|
m.shutdown();
|
||||||
|
rmSync(sessDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session sans état waiting (bash, pas de tracker) → invalid', () => {
|
||||||
|
const { summary, pty } = spawnBash();
|
||||||
|
const a = makeBinding('interactive');
|
||||||
|
manager.attach(summary.id, a, 80, 24);
|
||||||
|
pty.write.mockClear();
|
||||||
|
expect(manager.answer(summary.id, a, 'deny')).toBe('invalid');
|
||||||
|
expect(manager.answer(summary.id, a, 'select', 1)).toBe('invalid');
|
||||||
|
expect(pty.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('push trigger (P4-B)', () => {
|
||||||
|
it('une notif sur le front montant busy→waiting, après le debounce ; pas de notif en restant busy', async () => {
|
||||||
|
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-'));
|
||||||
|
const notifies: PushPayload[] = [];
|
||||||
|
const fakePush = {
|
||||||
|
notify: async (p: PushPayload) => {
|
||||||
|
notifies.push(p);
|
||||||
|
},
|
||||||
|
} as unknown as PushService;
|
||||||
|
const m = new PtyManager(db, sessDir, fakePush);
|
||||||
|
try {
|
||||||
|
const summary = m.spawn({ cwd, command: 'claude' });
|
||||||
|
const p = lastPty();
|
||||||
|
const regFile = join(sessDir, `${p.pid}.json`);
|
||||||
|
const writeReg = (status: string, waitingFor?: string): void =>
|
||||||
|
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status, ...(waitingFor ? { waitingFor } : {}) }));
|
||||||
|
|
||||||
|
// busy : front montant vers busy, pas de notif
|
||||||
|
writeReg('busy');
|
||||||
|
p.emitData('working…');
|
||||||
|
await sleep(260);
|
||||||
|
expect(notifies).toHaveLength(0);
|
||||||
|
|
||||||
|
// waiting + écran permission : front montant vers waiting → planifie la notif (debounce 1500ms)
|
||||||
|
writeReg('waiting', 'permission prompt');
|
||||||
|
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\n');
|
||||||
|
await sleep(260);
|
||||||
|
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
|
||||||
|
|
||||||
|
await sleep(1500); // dépasse le debounce
|
||||||
|
expect(notifies).toHaveLength(1);
|
||||||
|
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: 'permission', url: `/sessions/${summary.id}` });
|
||||||
|
} finally {
|
||||||
|
m.shutdown();
|
||||||
|
rmSync(sessDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('flow control', () => {
|
describe('flow control', () => {
|
||||||
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
|
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
|
||||||
const { summary, pty } = spawnBash();
|
const { summary, pty } = spawnBash();
|
||||||
|
|||||||
79
packages/server/test/push-service.test.ts
Normal file
79
packages/server/test/push-service.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PushService, type PushSender, type PushPayload } from '../src/core/push-service.js';
|
||||||
|
import { openDb, getSetting, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
function sub(endpoint: string): { endpoint: string; keys: { p256dh: string; auth: string } } {
|
||||||
|
return { endpoint, keys: { p256dh: `p-${endpoint}`, auth: `a-${endpoint}` } };
|
||||||
|
}
|
||||||
|
const payload: PushPayload = { sessionId: 's1', title: 't', body: 'b', kind: 'permission', url: '/sessions/s1' };
|
||||||
|
|
||||||
|
describe('PushService', () => {
|
||||||
|
it('génère les clés VAPID une seule fois (idempotent) et les persiste', () => {
|
||||||
|
const db: Db = openDb(':memory:');
|
||||||
|
const a = new PushService(db);
|
||||||
|
const pub = a.publicKey();
|
||||||
|
expect(pub).toMatch(/.{20,}/); // clé base64url non triviale
|
||||||
|
expect(getSetting(db, 'vapid_private')).not.toBeNull();
|
||||||
|
// une seconde instance sur la même db réutilise les clés (pas de régénération)
|
||||||
|
const b = new PushService(db);
|
||||||
|
expect(b.publicKey()).toBe(pub);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribe (UPSERT par endpoint), count et unsubscribe', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const p = new PushService(db);
|
||||||
|
p.subscribe('tok1', sub('https://push/a'), 'UA');
|
||||||
|
p.subscribe('tok1', sub('https://push/b'), null);
|
||||||
|
expect(p.count()).toBe(2);
|
||||||
|
// ré-abonnement du même endpoint → pas de doublon
|
||||||
|
p.subscribe('tok2', sub('https://push/a'), 'UA2');
|
||||||
|
expect(p.count()).toBe(2);
|
||||||
|
p.unsubscribe('https://push/a');
|
||||||
|
expect(p.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notify : supprime l’abonnement sur 410 Gone, conserve sur autre erreur, marque last_ok_at sur succès', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const sender: PushSender = vi.fn(async (s) => {
|
||||||
|
if (s.endpoint.endsWith('/gone')) throw Object.assign(new Error('gone'), { statusCode: 410 });
|
||||||
|
if (s.endpoint.endsWith('/flaky')) throw Object.assign(new Error('boom'), { statusCode: 500 });
|
||||||
|
return { statusCode: 201 };
|
||||||
|
});
|
||||||
|
const p = new PushService(db, 'mailto:test@x', sender);
|
||||||
|
p.subscribe('tok', sub('https://push/ok'), null);
|
||||||
|
p.subscribe('tok', sub('https://push/gone'), null);
|
||||||
|
p.subscribe('tok', sub('https://push/flaky'), null);
|
||||||
|
|
||||||
|
await p.notify(payload);
|
||||||
|
|
||||||
|
expect(sender).toHaveBeenCalledTimes(3);
|
||||||
|
// 410 → purgé ; 500 → conservé ; ok → conservé
|
||||||
|
expect(p.count()).toBe(2);
|
||||||
|
const rows = db.prepare('SELECT endpoint, last_ok_at FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string; last_ok_at: string | null }>;
|
||||||
|
const byEndpoint = Object.fromEntries(rows.map((r) => [r.endpoint, r.last_ok_at]));
|
||||||
|
expect(byEndpoint['https://push/ok']).not.toBeNull(); // succès horodaté
|
||||||
|
expect(byEndpoint['https://push/flaky']).toBeNull(); // échec transitoire : pas d'horodatage, mais conservé
|
||||||
|
expect(byEndpoint['https://push/gone']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notify sans abonnement : ne touche pas au sender', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const sender: PushSender = vi.fn(async () => ({}));
|
||||||
|
const p = new PushService(db, 'mailto:test@x', sender);
|
||||||
|
await p.notify(payload);
|
||||||
|
expect(sender).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le payload poussé est le JSON sérialisé de PushPayload', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
let captured = '';
|
||||||
|
const sender: PushSender = async (_s, body) => {
|
||||||
|
captured = body;
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
const p = new PushService(db, 'mailto:test@x', sender);
|
||||||
|
p.subscribe('tok', sub('https://push/ok'), null);
|
||||||
|
await p.notify(payload);
|
||||||
|
expect(JSON.parse(captured)).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
packages/server/test/worktree-manager.test.ts
Normal file
147
packages/server/test/worktree-manager.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, basename, dirname, resolve } from 'node:path';
|
||||||
|
import type { WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import { WorktreeManager } from '../src/core/worktree-manager.js';
|
||||||
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
|
import { DiscoveryService } from '../src/core/discovery-service.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
// node-pty inerte (la corrélation de session n'a besoin que d'un pid et d'un cwd).
|
||||||
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
|
let pid = 50_000;
|
||||||
|
class FakePty {
|
||||||
|
pid = pid++;
|
||||||
|
write = vi.fn();
|
||||||
|
resize = vi.fn();
|
||||||
|
pause = vi.fn();
|
||||||
|
resume = vi.fn();
|
||||||
|
kill = vi.fn();
|
||||||
|
onData(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
onExit(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { default: { spawn: (): FakePty => new FakePty() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTmpRepo(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-'));
|
||||||
|
dirs.push(dir);
|
||||||
|
const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||||
|
run('init', '-b', 'main');
|
||||||
|
run('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
run('config', 'user.name', 'Test');
|
||||||
|
writeFileSync(join(dir, 'README.md'), '# test\n');
|
||||||
|
run('add', '-A');
|
||||||
|
run('commit', '-m', 'init');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorktreeManager', () => {
|
||||||
|
let db: Db;
|
||||||
|
let pty: PtyManager;
|
||||||
|
let discovery: DiscoveryService;
|
||||||
|
let wt: WorktreeManager;
|
||||||
|
let claudeHome: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = openDb(':memory:');
|
||||||
|
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
|
||||||
|
dirs.push(claudeHome);
|
||||||
|
pty = new PtyManager(db, join(claudeHome, 'sessions'));
|
||||||
|
discovery = new DiscoveryService({ ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
|
||||||
|
wt = new WorktreeManager(db, pty, discovery);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addRepo : repo valide enregistré ; non-repo rejeté (400)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const summary = await wt.addRepo({ path: repo });
|
||||||
|
expect(summary).toMatchObject({ path: repo, label: basename(repo), valid: true });
|
||||||
|
expect((await wt.listRepos())).toHaveLength(1);
|
||||||
|
|
||||||
|
const notRepo = mkdtempSync(join(tmpdir(), 'arb-nogit-'));
|
||||||
|
dirs.push(notRepo);
|
||||||
|
await expect(wt.addRepo({ path: notRepo })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
// doublon
|
||||||
|
await expect(wt.addRepo({ path: repo })).rejects.toMatchObject({ statusCode: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createWorktree : worktree + hook exécuté + event worktree_update', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({
|
||||||
|
path: repo,
|
||||||
|
postCreateHooks: [{ id: 'h1', label: 'touch', run: 'touch hook-ran.txt', enabled: true }],
|
||||||
|
});
|
||||||
|
const events: Array<{ repoId: string; worktree: WorktreeSummary }> = [];
|
||||||
|
wt.on('worktree_update', (e) => events.push(e));
|
||||||
|
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
const out = await wt.createWorktree(r.id, { branch: 'feat', newBranch: true, runHooks: true });
|
||||||
|
|
||||||
|
expect(resolve(out.worktree.path)).toBe(resolve(wtPath));
|
||||||
|
expect(out.worktree.branch).toBe('feat');
|
||||||
|
expect(out.hookResults).toHaveLength(1);
|
||||||
|
expect(out.hookResults[0]).toMatchObject({ exitCode: 0 });
|
||||||
|
expect(existsSync(join(wtPath, 'hook-ran.txt'))).toBe(true);
|
||||||
|
expect(events.some((e) => resolve(e.worktree.path) === resolve(wtPath))).toBe(true);
|
||||||
|
|
||||||
|
const list = await wt.listRepoWorktrees(r.id, true);
|
||||||
|
expect(list.some((w) => resolve(w.path) === resolve(wtPath))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createWorktree : branche invalide → 400', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.deleteWorktree(r.id, repo, false)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' });
|
||||||
|
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'x', newBranch: true, runHooks: false });
|
||||||
|
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
|
||||||
|
|
||||||
|
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' });
|
||||||
|
await wt.deleteWorktree(r.id, wtPath, true);
|
||||||
|
expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('corrélation : une session dont le cwd = worktree apparaît dans worktree.sessions', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'sess', newBranch: true, runHooks: false });
|
||||||
|
|
||||||
|
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||||
|
const list = await wt.listRepoWorktrees(r.id, true);
|
||||||
|
const target = list.find((w) => resolve(w.path) === resolve(wtPath));
|
||||||
|
expect(target?.sessions).toHaveLength(1);
|
||||||
|
expect(target?.sessions[0]).toMatchObject({ source: 'managed', live: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteWorktree : session live dans le worktree → 409 sans force', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'busy', newBranch: true, runHooks: false });
|
||||||
|
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||||
|
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
// Types REST partagés (préfixe /api/v1) — sous-ensemble P1.
|
// Types REST partagés (préfixe /api/v1).
|
||||||
import type { SessionSummary } from './protocol.js';
|
import type { PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js';
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string; details?: unknown };
|
error: { code: string; message: string; details?: unknown };
|
||||||
@@ -34,3 +34,72 @@ export interface SessionResponse {
|
|||||||
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
||||||
export type ResumeSessionRequest = Record<string, never>;
|
export type ResumeSessionRequest = Record<string, never>;
|
||||||
export type ForkSessionRequest = Record<string, never>;
|
export type ForkSessionRequest = Record<string, never>;
|
||||||
|
|
||||||
|
// ---- Repos & worktrees (P3) ----
|
||||||
|
export interface ReposListResponse {
|
||||||
|
repos: RepoSummary[];
|
||||||
|
}
|
||||||
|
export interface RepoResponse {
|
||||||
|
repo: RepoSummary;
|
||||||
|
}
|
||||||
|
export interface CreateRepoRequest {
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
postCreateHooks?: PostCreateHook[];
|
||||||
|
preTrust?: boolean;
|
||||||
|
}
|
||||||
|
export interface UpdateRepoRequest {
|
||||||
|
label?: string;
|
||||||
|
postCreateHooks?: PostCreateHook[];
|
||||||
|
preTrust?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreesListResponse {
|
||||||
|
worktrees: WorktreeSummary[];
|
||||||
|
}
|
||||||
|
export interface WorktreeResponse {
|
||||||
|
worktree: WorktreeSummary;
|
||||||
|
}
|
||||||
|
export interface HookRunResult {
|
||||||
|
hookId: string;
|
||||||
|
label: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
output: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
export interface CreateWorktreeRequest {
|
||||||
|
branch: string;
|
||||||
|
/** true : créer la branche (`-b`) ; false : checkout d'une branche existante. */
|
||||||
|
newBranch: boolean;
|
||||||
|
/** point de départ de la nouvelle branche (défaut : HEAD courant du repo). */
|
||||||
|
baseRef?: string;
|
||||||
|
/** chemin cible du worktree (défaut : `<parent>/<repo>-wt-<branch>`). */
|
||||||
|
path?: string;
|
||||||
|
runHooks?: boolean;
|
||||||
|
preTrust?: boolean;
|
||||||
|
/** lancer une session dans le worktree créé (défaut : aucune). */
|
||||||
|
startSession?: 'claude' | 'bash' | null;
|
||||||
|
}
|
||||||
|
export interface CreateWorktreeResponse {
|
||||||
|
worktree: WorktreeSummary;
|
||||||
|
hookResults: HookRunResult[];
|
||||||
|
session: SessionSummary | null;
|
||||||
|
}
|
||||||
|
export interface AdoptWorktreeRequest {
|
||||||
|
path: string;
|
||||||
|
runHooks?: boolean;
|
||||||
|
preTrust?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Web Push (P4) ----
|
||||||
|
export interface VapidKeyResponse {
|
||||||
|
/** clé publique VAPID (applicationServerKey côté navigateur). */
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
export interface PushSubscribeRequest {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
}
|
||||||
|
export interface PushUnsubscribeRequest {
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ export type SessionSource =
|
|||||||
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
||||||
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
||||||
|
|
||||||
|
// ---- États fins de session (P3-B, claude-adapter) ----
|
||||||
|
/** busy = Claude traite ; waiting = bloqué sur un dialogue ; idle = prêt pour une instruction. */
|
||||||
|
export type SessionActivity = 'busy' | 'waiting' | 'idle';
|
||||||
|
export type DialogKind = 'trust' | 'permission' | 'question' | 'plan';
|
||||||
|
export interface DialogOption {
|
||||||
|
n: number;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
export interface SessionDialog {
|
||||||
|
kind: DialogKind;
|
||||||
|
waitingFor: string | null;
|
||||||
|
options: DialogOption[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -92,6 +107,57 @@ export interface SessionSummary {
|
|||||||
attachable: boolean;
|
attachable: boolean;
|
||||||
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
||||||
registryStatus: SessionRegistryStatus | null;
|
registryStatus: SessionRegistryStatus | null;
|
||||||
|
// ---- P3-B : états fins (optionnels, remplis par le claude-adapter) ----
|
||||||
|
/** absent/null = inconnu (bash, démarrage, historique). */
|
||||||
|
activity?: SessionActivity | null;
|
||||||
|
waitingFor?: string | null;
|
||||||
|
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
||||||
|
dialog?: SessionDialog | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
/** Hook lancé après création d'un worktree (commande shell exécutée dans le nouveau worktree). */
|
||||||
|
export interface PostCreateHook {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
run: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoSummary {
|
||||||
|
id: string;
|
||||||
|
/** chemin absolu de la racine du repo (main worktree). */
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
defaultBranch: string | null;
|
||||||
|
postCreateHooks: PostCreateHook[];
|
||||||
|
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
||||||
|
preTrust: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
/** false si le chemin n'est plus un repo git accessible. */
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitStatus {
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
dirtyCount: number;
|
||||||
|
upstream: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeSummary {
|
||||||
|
repoId: string;
|
||||||
|
/** chemin absolu du worktree (clé de corrélation avec le cwd des sessions). */
|
||||||
|
path: string;
|
||||||
|
branch: string | null;
|
||||||
|
head: string;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
prunable: boolean;
|
||||||
|
isMain: boolean;
|
||||||
|
git: WorktreeGitStatus;
|
||||||
|
/** sessions corrélées par cwd (managées + découvertes) ; leur `activity` est remplie en P3-B. */
|
||||||
|
sessions: SessionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Messages client → serveur ----
|
// ---- Messages client → serveur ----
|
||||||
@@ -100,9 +166,13 @@ export type ClientMessage =
|
|||||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
||||||
| { type: 'detach'; channel: number }
|
| { type: 'detach'; channel: number }
|
||||||
| { type: 'stdin'; channel: number; data: string }
|
| { type: 'stdin'; channel: number; data: string }
|
||||||
|
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
||||||
|
// en keystrokes (chiffre+Entrée pour 'select', Entrée pour 'confirm', Esc pour 'deny')
|
||||||
|
// et valide l'option contre le dialogue courant (anti-frappe fantôme mobile).
|
||||||
|
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
||||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||||
| { type: 'ack'; channel: number; bytes: number }
|
| { type: 'ack'; channel: number; bytes: number }
|
||||||
| { type: 'sub'; topics: Array<'sessions'> }
|
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
||||||
| { type: 'ping' };
|
| { type: 'ping' };
|
||||||
|
|
||||||
// ---- Messages serveur → client ----
|
// ---- Messages serveur → client ----
|
||||||
@@ -113,6 +183,10 @@ export type ServerMessage =
|
|||||||
| { type: 'control_changed'; channel: number; controlling: boolean }
|
| { type: 'control_changed'; channel: number; controlling: boolean }
|
||||||
| { type: 'session_update'; session: SessionSummary }
|
| { type: 'session_update'; session: SessionSummary }
|
||||||
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
||||||
|
| { type: 'repo_update'; repo: RepoSummary }
|
||||||
|
| { type: 'repo_removed'; repoId: string }
|
||||||
|
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
||||||
|
| { type: 'worktree_removed'; repoId: string; path: string }
|
||||||
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||||
| { type: 'pong' };
|
| { type: 'pong' };
|
||||||
|
|
||||||
@@ -123,6 +197,7 @@ export type ErrorCode =
|
|||||||
| 'NOT_ATTACHED'
|
| 'NOT_ATTACHED'
|
||||||
| 'NOT_CONTROLLING'
|
| 'NOT_CONTROLLING'
|
||||||
| 'SESSION_EXITED'
|
| 'SESSION_EXITED'
|
||||||
|
| 'INVALID_ANSWER'
|
||||||
| 'INTERNAL';
|
| 'INTERNAL';
|
||||||
|
|
||||||
export function parseClientMessage(raw: string): ClientMessage | null {
|
export function parseClientMessage(raw: string): ClientMessage | null {
|
||||||
@@ -150,6 +225,16 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
|
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
|
||||||
? { type: 'stdin', channel: m.channel, data: m.data }
|
? { type: 'stdin', channel: m.channel, data: m.data }
|
||||||
: null;
|
: null;
|
||||||
|
case 'answer': {
|
||||||
|
if (!isU32(m.channel)) return null;
|
||||||
|
if (m.action === 'select')
|
||||||
|
return isCount(m.optionN) && (m.optionN as number) >= 1 && (m.optionN as number) <= 99
|
||||||
|
? { type: 'answer', channel: m.channel, action: 'select', optionN: m.optionN as number }
|
||||||
|
: null;
|
||||||
|
return m.action === 'confirm' || m.action === 'deny'
|
||||||
|
? { type: 'answer', channel: m.channel, action: m.action }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
case 'resize':
|
case 'resize':
|
||||||
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
||||||
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
||||||
@@ -159,8 +244,8 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||||
: null;
|
: null;
|
||||||
case 'sub':
|
case 'sub':
|
||||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions')
|
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees')
|
||||||
? { type: 'sub', topics: m.topics as Array<'sessions'> }
|
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees'> }
|
||||||
: null;
|
: null;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return { type: 'ping' };
|
return { type: 'ping' };
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ function assertInvariants(msg: ClientMessage): void {
|
|||||||
expect(typeof msg.data).toBe('string');
|
expect(typeof msg.data).toBe('string');
|
||||||
expect(msg.data.length).toBeLessThanOrEqual(65536);
|
expect(msg.data.length).toBeLessThanOrEqual(65536);
|
||||||
break;
|
break;
|
||||||
|
case 'answer':
|
||||||
|
expect(isU32(msg.channel)).toBe(true);
|
||||||
|
expect(['select', 'confirm', 'deny']).toContain(msg.action);
|
||||||
|
if (msg.action === 'select') {
|
||||||
|
expect(typeof msg.optionN).toBe('number');
|
||||||
|
expect(msg.optionN).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(msg.optionN).toBeLessThanOrEqual(99);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'resize':
|
case 'resize':
|
||||||
expect(isU32(msg.channel)).toBe(true);
|
expect(isU32(msg.channel)).toBe(true);
|
||||||
expect(isDim(msg.cols)).toBe(true);
|
expect(isDim(msg.cols)).toBe(true);
|
||||||
@@ -92,12 +101,25 @@ describe('parseClientMessage — cas valides', () => {
|
|||||||
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
|
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answer select / confirm / deny', () => {
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
|
||||||
|
.toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
|
||||||
|
.toEqual({ type: 'answer', channel: 2, action: 'confirm' });
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
|
||||||
|
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
|
||||||
|
// optionN n'est porté que par 'select' (ignoré pour confirm/deny)
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
|
||||||
|
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
|
||||||
|
});
|
||||||
|
|
||||||
it('ack à zéro octet', () => {
|
it('ack à zéro octet', () => {
|
||||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sub avec topics sessions (et tableau vide accepté)', () => {
|
it('sub avec topics sessions/worktrees (et tableau vide accepté)', () => {
|
||||||
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
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":[]}')).toEqual({ type: 'sub', topics: [] });
|
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +174,15 @@ describe('parseClientMessage — cas malformés', () => {
|
|||||||
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
|
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
|
||||||
|
expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('ack : bytes négatif ou non numérique', () => {
|
it('ack : bytes négatif ou non numérique', () => {
|
||||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
|
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
|
||||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
|
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
|
||||||
@@ -167,16 +198,16 @@ describe('parseClientMessage — cas malformés', () => {
|
|||||||
describe('parseClientMessage — fuzz rapide', () => {
|
describe('parseClientMessage — fuzz rapide', () => {
|
||||||
it('ne lève jamais et tout message accepté respecte les invariants', () => {
|
it('ne lève jamais et tout message accepté respecte les invariants', () => {
|
||||||
const rand = mulberry32(0xa5b0e7);
|
const rand = mulberry32(0xa5b0e7);
|
||||||
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
||||||
const values: unknown[] = [
|
const values: unknown[] = [
|
||||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
|
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
|
||||||
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
|
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
|
||||||
];
|
];
|
||||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
|
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
const obj: Record<string, unknown> = { type: pick(types) };
|
const obj: Record<string, unknown> = { type: pick(types) };
|
||||||
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) {
|
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
|
||||||
if (rand() < 0.7) obj[key] = pick(values);
|
if (rand() < 0.7) obj[key] = pick(values);
|
||||||
}
|
}
|
||||||
const raw = JSON.stringify(obj);
|
const raw = JSON.stringify(obj);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<!-- iOS : le Web Push n'est disponible qu'en PWA installée (« Ajouter à l'écran d'accueil ») -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Arboretum" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
<title>Arboretum</title>
|
<title>Arboretum</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
18
packages/web/public/icon.svg
Normal file
18
packages/web/public/icon.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Arboretum">
|
||||||
|
<rect width="512" height="512" fill="#09090b"/>
|
||||||
|
<!-- arbre / branches (worktrees) — tracé dans la zone sûre maskable (~80% central) -->
|
||||||
|
<g fill="none" stroke="#34d399" stroke-width="22" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- tronc -->
|
||||||
|
<path d="M256 420 V232"/>
|
||||||
|
<!-- branche gauche -->
|
||||||
|
<path d="M256 300 L168 212"/>
|
||||||
|
<!-- branche droite -->
|
||||||
|
<path d="M256 268 L344 180"/>
|
||||||
|
</g>
|
||||||
|
<!-- nœuds (sessions) -->
|
||||||
|
<g fill="#34d399">
|
||||||
|
<circle cx="256" cy="156" r="34"/>
|
||||||
|
<circle cx="160" cy="196" r="26"/>
|
||||||
|
<circle cx="352" cy="164" r="26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 727 B |
13
packages/web/public/manifest.webmanifest
Normal file
13
packages/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "Arboretum",
|
||||||
|
"short_name": "Arboretum",
|
||||||
|
"description": "A self-hosted dashboard for your git worktrees and Claude Code sessions.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"theme_color": "#09090b",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
52
packages/web/public/sw.js
Normal file
52
packages/web/public/sw.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Service worker Arboretum (P4-C) : Web Push + clic de notification.
|
||||||
|
// Volontairement minimal — pas de précache offline (hors périmètre P4) : son seul rôle est de
|
||||||
|
// recevoir les push (session passée en `waiting`) et d'ouvrir/focus la vue session au clic.
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
const title = data.title || 'Arboretum';
|
||||||
|
const options = {
|
||||||
|
body: data.body || 'A session needs your input.',
|
||||||
|
tag: data.sessionId || 'arboretum', // remplace la notif précédente de la même session
|
||||||
|
data: { url: data.url || '/' },
|
||||||
|
icon: '/icon.svg',
|
||||||
|
badge: '/icon.svg',
|
||||||
|
requireInteraction: true, // ignoré sur iOS, respecté ailleurs
|
||||||
|
};
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = (event.notification.data && event.notification.data.url) || '/';
|
||||||
|
const target = new URL(url, self.location.origin).href;
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
for (const client of clients) {
|
||||||
|
// un onglet de l'app est déjà ouvert : on le focus (et on y navigue si besoin)
|
||||||
|
if ('focus' in client) {
|
||||||
|
await client.focus();
|
||||||
|
if (client.url !== target && 'navigate' in client) {
|
||||||
|
try {
|
||||||
|
await client.navigate(target);
|
||||||
|
} catch {
|
||||||
|
// navigate peut échouer selon le contexte ; le focus suffit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(target);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pas de précache : on s'active immédiatement pour que le push soit opérationnel sans recharger.
|
||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||||
71
packages/web/src/components/DialogPrompt.vue
Normal file
71
packages/web/src/components/DialogPrompt.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="dialog" class="rounded-lg border border-amber-900/60 bg-amber-950/30 p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
||||||
|
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="opt in dialog.options"
|
||||||
|
:key="opt.n"
|
||||||
|
class="btn text-xs"
|
||||||
|
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="respond('select', opt.n)"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-amber-400">{{ opt.n }}</span> {{ opt.label }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger text-xs" :disabled="busy" @click="respond('deny')">
|
||||||
|
{{ t('sessions.dialog.deny') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// P4-A : répondre à un dialogue Claude sans ouvrir le terminal. Le composant gère sa
|
||||||
|
// propre attache interactive LÉGÈRE (sink no-op, sans xterm), créée à la demande au
|
||||||
|
// premier clic et libérée au démontage — c'est ce qui réalise la réponse « mobile ».
|
||||||
|
import { computed, onUnmounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { wsClient, type Attachment, type TerminalSink } from '../lib/ws-client';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const busy = ref(false);
|
||||||
|
const dialog = computed(() => (props.session.activity === 'waiting' ? props.session.dialog ?? null : null));
|
||||||
|
|
||||||
|
// Sink no-op : l'attache de réponse n'affiche rien ; elle rappelle le callback du flow
|
||||||
|
// control pour ne jamais bloquer le PTY côté serveur.
|
||||||
|
const noopSink: TerminalSink = {
|
||||||
|
write: (_data, cb) => cb?.(),
|
||||||
|
reset: () => {},
|
||||||
|
onDetached: () => {},
|
||||||
|
onControlChanged: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let attachPromise: Promise<Attachment> | null = null;
|
||||||
|
function ensureAttached(): Promise<Attachment> {
|
||||||
|
attachPromise ??= wsClient.attach({ sessionId: props.session.id, mode: 'interactive', cols: 120, rows: 32, sink: noopSink });
|
||||||
|
return attachPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(action: 'select' | 'confirm' | 'deny', optionN?: number): Promise<void> {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const att = await ensureAttached();
|
||||||
|
att.answer(action, optionN);
|
||||||
|
} catch {
|
||||||
|
// attache impossible (session disparue) : l'état réel arrive par session_update
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
attachPromise?.then((a) => a.detach()).catch(() => {});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
93
packages/web/src/components/RepoSection.vue
Normal file
93
packages/web/src/components/RepoSection.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
|
||||||
|
<span v-if="!repo.valid" class="badge bg-red-950 text-red-400">{{ t('repos.invalid') }}</span>
|
||||||
|
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<button class="btn text-xs" @click="creating = !creating">{{ t('worktrees.new') }}</button>
|
||||||
|
<button class="btn text-xs" :disabled="busy" @click="onPrune">{{ t('worktrees.prune') }}</button>
|
||||||
|
<button class="btn-danger text-xs" @click="onRemove">{{ t('repos.remove') }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form v-if="creating" class="flex flex-wrap items-end gap-2 rounded border border-zinc-800 bg-zinc-950/40 p-2" @submit.prevent="onCreate">
|
||||||
|
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('worktrees.branch') }}
|
||||||
|
<input v-model="branch" class="input font-mono" :placeholder="repo.defaultBranch ? `feature/…` : 'feature/…'" required />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('worktrees.start') }}
|
||||||
|
<select v-model="startSession" class="input">
|
||||||
|
<option :value="null">{{ t('worktrees.startNone') }}</option>
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
|
||||||
|
<button type="submit" class="btn-primary text-xs" :disabled="busy || branch.trim() === ''">
|
||||||
|
{{ busy ? t('worktrees.creating') : t('worktrees.create') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import WorktreeCard from './WorktreeCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ repo: RepoSummary }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useWorktreesStore();
|
||||||
|
|
||||||
|
const worktrees = computed(() => store.worktreesForRepo(props.repo.id));
|
||||||
|
const creating = ref(false);
|
||||||
|
const branch = ref('');
|
||||||
|
const newBranch = ref(true);
|
||||||
|
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function onCreate(): Promise<void> {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
|
||||||
|
branch.value = '';
|
||||||
|
creating.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPrune(): Promise<void> {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await store.prune(props.repo.id);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove(): Promise<void> {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await store.removeRepo(props.repo.id);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
33
packages/web/src/components/SessionStateBadge.vue
Normal file
33
packages/web/src/components/SessionStateBadge.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="!session.live && session.resumable" class="badge bg-amber-950 text-amber-400">
|
||||||
|
{{ t('sessions.resumable') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="!session.live" class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||||
|
<span v-else class="badge gap-1" :class="badgeClass">
|
||||||
|
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// activity (P3-B, managées) prime ; registryStatus (P2, découvertes) en repli.
|
||||||
|
const activity = computed(() => props.session.activity ?? props.session.registryStatus ?? null);
|
||||||
|
const label = computed(() => (activity.value ? t(`sessions.registryStatus.${activity.value}`) : t('sessions.live')));
|
||||||
|
const badgeClass = computed(() => ({
|
||||||
|
'bg-amber-950 text-amber-300': activity.value === 'waiting',
|
||||||
|
'bg-sky-950 text-sky-300': activity.value === 'busy',
|
||||||
|
'bg-emerald-950 text-emerald-400': activity.value === 'idle' || activity.value === null,
|
||||||
|
}));
|
||||||
|
const dotClass = computed(() => ({
|
||||||
|
'bg-amber-300 animate-pulse': activity.value === 'waiting',
|
||||||
|
'bg-sky-300 animate-pulse': activity.value === 'busy',
|
||||||
|
'bg-emerald-400': activity.value === 'idle' || activity.value === null,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
104
packages/web/src/components/WorktreeCard.vue
Normal file
104
packages/web/src/components/WorktreeCard.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-sm text-zinc-200">{{ branchLabel }}</span>
|
||||||
|
<span v-if="worktree.isMain" class="badge bg-zinc-800 text-zinc-400">{{ t('worktrees.main') }}</span>
|
||||||
|
<span v-if="worktree.locked" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.locked') }}</span>
|
||||||
|
<span v-if="worktree.prunable" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.prunable') }}</span>
|
||||||
|
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<span v-if="worktree.git.ahead">↑{{ worktree.git.ahead }}</span>
|
||||||
|
<span v-if="worktree.git.behind">↓{{ worktree.git.behind }}</span>
|
||||||
|
<span v-if="worktree.git.dirtyCount" class="text-amber-400">● {{ t('worktrees.dirty', { n: worktree.git.dirtyCount }) }}</span>
|
||||||
|
<span v-else class="text-zinc-600">{{ t('worktrees.clean') }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
|
||||||
|
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<RouterLink
|
||||||
|
v-for="s in worktree.sessions"
|
||||||
|
:key="s.id"
|
||||||
|
:to="{ name: 'session', params: { id: s.id } }"
|
||||||
|
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<SessionStateBadge :session="liveSession(s)" />
|
||||||
|
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
||||||
|
|
||||||
|
<div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
|
||||||
|
<template v-if="confirming">
|
||||||
|
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
|
||||||
|
<button class="btn-danger text-xs" :disabled="busy" @click="onDelete">
|
||||||
|
{{ forceNeeded ? t('worktrees.forceDelete') : t('worktrees.confirmDelete') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn text-xs" @click="reset">{{ t('common.cancel') }}</button>
|
||||||
|
</template>
|
||||||
|
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
|
||||||
|
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { ApiError } from '../lib/api';
|
||||||
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
|
import DialogPrompt from './DialogPrompt.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ worktree: WorktreeSummary }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
|
||||||
|
// L'état embarqué dans le worktree est un instantané ; on privilégie la version live du store
|
||||||
|
// des sessions (mise à jour en continu) pour que busy/waiting/idle reste temps réel.
|
||||||
|
function liveSession(s: SessionSummary): SessionSummary {
|
||||||
|
return sessions.sessions.find((x) => x.id === s.id) ?? s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
|
||||||
|
const waitingSessions = computed(() =>
|
||||||
|
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirming = ref(false);
|
||||||
|
const forceNeeded = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
const branchLabel = computed(() =>
|
||||||
|
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
confirming.value = false;
|
||||||
|
forceNeeded.value = false;
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(): Promise<void> {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await store.deleteWorktree(props.worktree.repoId, props.worktree.path, forceNeeded.value);
|
||||||
|
// succès : l'événement worktree_removed (ou le retrait local) démonte la carte
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
forceNeeded.value = true; // dirty ou session vivante → proposer la suppression forcée
|
||||||
|
error.value = err.message;
|
||||||
|
} else {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -47,6 +47,49 @@ export default {
|
|||||||
idle: 'idle',
|
idle: 'idle',
|
||||||
waiting: 'waiting',
|
waiting: 'waiting',
|
||||||
},
|
},
|
||||||
|
dialog: {
|
||||||
|
waiting: 'Waiting for your input',
|
||||||
|
deny: 'Deny (Esc)',
|
||||||
|
kind: {
|
||||||
|
trust: 'Trust',
|
||||||
|
permission: 'Permission',
|
||||||
|
question: 'Question',
|
||||||
|
plan: 'Plan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
title: 'Worktrees',
|
||||||
|
allSessions: 'All sessions',
|
||||||
|
},
|
||||||
|
repos: {
|
||||||
|
add: 'Add repo',
|
||||||
|
adding: 'Adding…',
|
||||||
|
remove: 'Remove',
|
||||||
|
pathLabel: 'Repository path',
|
||||||
|
pathPlaceholder: '/absolute/path/to/repo',
|
||||||
|
empty: 'No repository registered yet — add one above.',
|
||||||
|
invalid: 'unavailable',
|
||||||
|
},
|
||||||
|
worktrees: {
|
||||||
|
new: 'New worktree',
|
||||||
|
create: 'Create',
|
||||||
|
creating: 'Creating…',
|
||||||
|
branch: 'Branch',
|
||||||
|
newBranch: 'create branch',
|
||||||
|
start: 'Start',
|
||||||
|
startNone: 'no session',
|
||||||
|
delete: 'Delete',
|
||||||
|
confirmDelete: 'Confirm delete',
|
||||||
|
forceDelete: 'Force delete',
|
||||||
|
prune: 'Prune',
|
||||||
|
main: 'main',
|
||||||
|
detached: 'detached',
|
||||||
|
locked: 'locked',
|
||||||
|
prunable: 'prunable',
|
||||||
|
clean: 'clean',
|
||||||
|
dirty: '{n} changed',
|
||||||
|
noSession: 'no session',
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
observer: 'observer (read-only)',
|
observer: 'observer (read-only)',
|
||||||
@@ -54,6 +97,12 @@ export default {
|
|||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
||||||
},
|
},
|
||||||
|
push: {
|
||||||
|
enable: 'Enable notifications',
|
||||||
|
disable: 'Notifications on',
|
||||||
|
denied: 'Notification permission denied — enable it in your browser settings.',
|
||||||
|
unsupported: 'Push needs HTTPS (e.g. Tailscale Serve); on iOS, install the app to your home screen first.',
|
||||||
|
},
|
||||||
ws: {
|
ws: {
|
||||||
reconnecting: 'Connection lost — reconnecting…',
|
reconnecting: 'Connection lost — reconnecting…',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,49 @@ const fr: typeof en = {
|
|||||||
idle: 'inactive',
|
idle: 'inactive',
|
||||||
waiting: 'en attente',
|
waiting: 'en attente',
|
||||||
},
|
},
|
||||||
|
dialog: {
|
||||||
|
waiting: 'En attente de votre saisie',
|
||||||
|
deny: 'Refuser (Échap)',
|
||||||
|
kind: {
|
||||||
|
trust: 'Confiance',
|
||||||
|
permission: 'Permission',
|
||||||
|
question: 'Question',
|
||||||
|
plan: 'Plan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
title: 'Worktrees',
|
||||||
|
allSessions: 'Toutes les sessions',
|
||||||
|
},
|
||||||
|
repos: {
|
||||||
|
add: 'Ajouter un repo',
|
||||||
|
adding: 'Ajout…',
|
||||||
|
remove: 'Retirer',
|
||||||
|
pathLabel: 'Chemin du dépôt',
|
||||||
|
pathPlaceholder: '/chemin/absolu/du/repo',
|
||||||
|
empty: 'Aucun dépôt enregistré — ajoutez-en un ci-dessus.',
|
||||||
|
invalid: 'indisponible',
|
||||||
|
},
|
||||||
|
worktrees: {
|
||||||
|
new: 'Nouveau worktree',
|
||||||
|
create: 'Créer',
|
||||||
|
creating: 'Création…',
|
||||||
|
branch: 'Branche',
|
||||||
|
newBranch: 'créer la branche',
|
||||||
|
start: 'Démarrer',
|
||||||
|
startNone: 'aucune session',
|
||||||
|
delete: 'Supprimer',
|
||||||
|
confirmDelete: 'Confirmer',
|
||||||
|
forceDelete: 'Forcer la suppression',
|
||||||
|
prune: 'Élaguer',
|
||||||
|
main: 'principal',
|
||||||
|
detached: 'détaché',
|
||||||
|
locked: 'verrouillé',
|
||||||
|
prunable: 'élaguable',
|
||||||
|
clean: 'propre',
|
||||||
|
dirty: '{n} modifié(s)',
|
||||||
|
noSession: 'aucune session',
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
observer: 'observateur (lecture seule)',
|
observer: 'observateur (lecture seule)',
|
||||||
@@ -57,6 +100,12 @@ const fr: typeof en = {
|
|||||||
notAttachable:
|
notAttachable:
|
||||||
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
||||||
},
|
},
|
||||||
|
push: {
|
||||||
|
enable: 'Activer les notifications',
|
||||||
|
disable: 'Notifications activées',
|
||||||
|
denied: 'Permission de notification refusée — activez-la dans les réglages du navigateur.',
|
||||||
|
unsupported: 'Le push nécessite HTTPS (ex. Tailscale Serve) ; sur iOS, installez d’abord l’app sur l’écran d’accueil.',
|
||||||
|
},
|
||||||
ws: {
|
ws: {
|
||||||
reconnecting: 'Connexion perdue — reconnexion…',
|
reconnecting: 'Connexion perdue — reconnexion…',
|
||||||
},
|
},
|
||||||
|
|||||||
70
packages/web/src/lib/push.ts
Normal file
70
packages/web/src/lib/push.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Abonnement Web Push côté navigateur (P4-C). Enregistre le service worker, demande la permission,
|
||||||
|
// s'abonne avec la clé VAPID publique du serveur, et transmet l'abonnement au daemon.
|
||||||
|
import { api } from './api';
|
||||||
|
import type { VapidKeyResponse } from '@arboretum/shared';
|
||||||
|
|
||||||
|
const SW_URL = '/sw.js';
|
||||||
|
|
||||||
|
/** Push nécessite service worker + PushManager + Notification (et un contexte sécurisé : localhost ou HTTPS). */
|
||||||
|
export function pushSupported(): boolean {
|
||||||
|
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationPermission(): NotificationPermission {
|
||||||
|
return 'Notification' in window ? Notification.permission : 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||||
|
if (!('serviceWorker' in navigator)) return null;
|
||||||
|
try {
|
||||||
|
return await navigator.serviceWorker.register(SW_URL);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La clé VAPID publique est transmise en base64url ; PushManager attend un BufferSource
|
||||||
|
// adossé à un ArrayBuffer (pas SharedArrayBuffer) → on type explicitement le buffer.
|
||||||
|
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(b64);
|
||||||
|
const out = new Uint8Array(new ArrayBuffer(raw.length));
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function currentSubscription(): Promise<PushSubscription | null> {
|
||||||
|
if (!pushSupported()) return null;
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
return reg.pushManager.getSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnableResult = 'enabled' | 'denied' | 'unsupported';
|
||||||
|
|
||||||
|
export async function enablePush(): Promise<EnableResult> {
|
||||||
|
if (!pushSupported()) return 'unsupported';
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') return 'denied';
|
||||||
|
await registerServiceWorker();
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const { key } = await api.get<VapidKeyResponse>('/api/v1/push/vapid-public-key');
|
||||||
|
const sub =
|
||||||
|
(await reg.pushManager.getSubscription()) ??
|
||||||
|
(await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(key) }));
|
||||||
|
const json = sub.toJSON();
|
||||||
|
const keys = json.keys ?? {};
|
||||||
|
await api.post('/api/v1/push/subscribe', { endpoint: sub.endpoint, keys: { p256dh: keys.p256dh ?? '', auth: keys.auth ?? '' } });
|
||||||
|
return 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disablePush(): Promise<void> {
|
||||||
|
const sub = await currentSubscription();
|
||||||
|
if (!sub) return;
|
||||||
|
try {
|
||||||
|
await api.post('/api/v1/push/unsubscribe', { endpoint: sub.endpoint });
|
||||||
|
} catch {
|
||||||
|
// le retrait local prime ; un échec réseau sur l'unsubscribe serveur n'est pas bloquant
|
||||||
|
}
|
||||||
|
await sub.unsubscribe();
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export interface TerminalSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
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 interface AttachOptions {
|
export interface AttachOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -74,6 +75,16 @@ export class Attachment {
|
|||||||
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
|
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Répond à un dialogue Claude (P4-A) sans clavier ; le serveur traduit en keystrokes. */
|
||||||
|
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
|
||||||
|
if (this.closed || this.channel < 0) return;
|
||||||
|
this.client.sendControl(
|
||||||
|
action === 'select' && optionN !== undefined
|
||||||
|
? { type: 'answer', channel: this.channel, action, optionN }
|
||||||
|
: { type: 'answer', channel: this.channel, action },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */
|
/** mémorise toujours la taille (réutilisée à la ré-attache) ; n'envoie que si controlling */
|
||||||
resize(cols: number, rows: number): void {
|
resize(cols: number, rows: number): void {
|
||||||
this.cols = cols;
|
this.cols = cols;
|
||||||
@@ -104,6 +115,7 @@ export class WsClient {
|
|||||||
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
|
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
|
||||||
private awaitingAttached: Attachment[] = [];
|
private awaitingAttached: Attachment[] = [];
|
||||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||||
|
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
@@ -143,13 +155,35 @@ export class WsClient {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
||||||
|
private activeTopics(): Array<'sessions' | 'worktrees'> {
|
||||||
|
const t: Array<'sessions' | 'worktrees'> = [];
|
||||||
|
if (this.sessionListeners.size > 0) t.push('sessions');
|
||||||
|
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendSub(): void {
|
||||||
|
this.sendControl({ type: 'sub', topics: this.activeTopics() });
|
||||||
|
}
|
||||||
|
|
||||||
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
|
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
|
||||||
this.sessionListeners.add(listener);
|
this.sessionListeners.add(listener);
|
||||||
this.connect();
|
this.connect();
|
||||||
if (this.sessionListeners.size === 1) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
this.sendSub();
|
||||||
return () => {
|
return () => {
|
||||||
this.sessionListeners.delete(listener);
|
this.sessionListeners.delete(listener);
|
||||||
if (this.sessionListeners.size === 0) this.sendControl({ type: 'sub', topics: [] });
|
this.sendSub();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeWorktrees(listener: (e: WorktreeEvent) => void): () => void {
|
||||||
|
this.worktreeListeners.add(listener);
|
||||||
|
this.connect();
|
||||||
|
this.sendSub();
|
||||||
|
return () => {
|
||||||
|
this.worktreeListeners.delete(listener);
|
||||||
|
this.sendSub();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +322,7 @@ export class WsClient {
|
|||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.backoffMs = BACKOFF_MIN_MS;
|
this.backoffMs = BACKOFF_MIN_MS;
|
||||||
this.status.value = 'open';
|
this.status.value = 'open';
|
||||||
if (this.sessionListeners.size > 0) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
this.sendSub();
|
||||||
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
|
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
|
||||||
for (const att of this.attachments) {
|
for (const att of this.attachments) {
|
||||||
if (!att.closed) this.sendAttach(att);
|
if (!att.closed) this.sendAttach(att);
|
||||||
@@ -339,6 +373,13 @@ export class WsClient {
|
|||||||
for (const cb of this.sessionListeners) cb(msg);
|
for (const cb of this.sessionListeners) cb(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'repo_update':
|
||||||
|
case 'repo_removed':
|
||||||
|
case 'worktree_update':
|
||||||
|
case 'worktree_removed': {
|
||||||
|
for (const cb of this.worktreeListeners) cb(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'error': {
|
case 'error': {
|
||||||
if (msg.channel !== undefined) {
|
if (msg.channel !== undefined) {
|
||||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createPinia } from 'pinia';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import { i18n } from './i18n';
|
import { i18n } from './i18n';
|
||||||
|
import { registerServiceWorker } from './lib/push';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
||||||
|
|
||||||
|
// PWA (P4-C) : enregistre le service worker des notifications push si l'environnement le permet.
|
||||||
|
void registerServiceWorker();
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export const router = createRouter({
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
|
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
|
||||||
{ path: '/', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
{ 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: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
@@ -15,6 +16,6 @@ export const router = createRouter({
|
|||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
if (auth.authenticated === null) await auth.check();
|
if (auth.authenticated === null) await auth.check();
|
||||||
if (to.name === 'login') return auth.authenticated ? { name: 'sessions' } : true;
|
if (to.name === 'login') return auth.authenticated ? { name: 'dashboard' } : true;
|
||||||
return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
|
return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
|
||||||
});
|
});
|
||||||
|
|||||||
42
packages/web/src/stores/push.ts
Normal file
42
packages/web/src/stores/push.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { currentSubscription, disablePush, enablePush, notificationPermission, pushSupported } from '../lib/push';
|
||||||
|
|
||||||
|
export const usePushStore = defineStore('push', () => {
|
||||||
|
const supported = ref(pushSupported());
|
||||||
|
const enabled = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
/** null | 'denied' | 'unsupported' | message d'erreur */
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
supported.value = pushSupported();
|
||||||
|
if (!supported.value) {
|
||||||
|
enabled.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enabled.value = notificationPermission() === 'granted' && (await currentSubscription()) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(): Promise<void> {
|
||||||
|
if (busy.value || !supported.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
if (enabled.value) {
|
||||||
|
await disablePush();
|
||||||
|
enabled.value = false;
|
||||||
|
} else {
|
||||||
|
const r = await enablePush();
|
||||||
|
enabled.value = r === 'enabled';
|
||||||
|
if (r !== 'enabled') error.value = r;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { supported, enabled, busy, error, refresh, toggle };
|
||||||
|
});
|
||||||
126
packages/web/src/stores/worktrees.ts
Normal file
126
packages/web/src/stores/worktrees.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
CreateWorktreeRequest,
|
||||||
|
CreateWorktreeResponse,
|
||||||
|
RepoResponse,
|
||||||
|
ReposListResponse,
|
||||||
|
RepoSummary,
|
||||||
|
WorktreeSummary,
|
||||||
|
WorktreesListResponse,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { wsClient, type WorktreeEvent } from '../lib/ws-client';
|
||||||
|
|
||||||
|
export const useWorktreesStore = defineStore('worktrees', () => {
|
||||||
|
const repos = ref<RepoSummary[]>([]);
|
||||||
|
const worktrees = ref<WorktreeSummary[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref<string | null>(null);
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
function upsertRepo(repo: RepoSummary): void {
|
||||||
|
const idx = repos.value.findIndex((r) => r.id === repo.id);
|
||||||
|
if (idx >= 0) repos.value.splice(idx, 1, repo);
|
||||||
|
else repos.value.push(repo);
|
||||||
|
}
|
||||||
|
function removeRepoLocal(id: string): void {
|
||||||
|
repos.value = repos.value.filter((r) => r.id !== id);
|
||||||
|
worktrees.value = worktrees.value.filter((w) => w.repoId !== id);
|
||||||
|
}
|
||||||
|
function upsertWorktree(wt: WorktreeSummary): void {
|
||||||
|
const idx = worktrees.value.findIndex((w) => w.path === wt.path);
|
||||||
|
if (idx >= 0) worktrees.value.splice(idx, 1, wt);
|
||||||
|
else worktrees.value.push(wt);
|
||||||
|
}
|
||||||
|
function removeWorktreeLocal(path: string): void {
|
||||||
|
worktrees.value = worktrees.value.filter((w) => w.path !== path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEvent(e: WorktreeEvent): void {
|
||||||
|
if (e.type === 'repo_update') upsertRepo(e.repo);
|
||||||
|
else if (e.type === 'repo_removed') removeRepoLocal(e.repoId);
|
||||||
|
else if (e.type === 'worktree_update') upsertWorktree(e.worktree);
|
||||||
|
else removeWorktreeLocal(e.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function worktreesForRepo(repoId: string): WorktreeSummary[] {
|
||||||
|
return worktrees.value
|
||||||
|
.filter((w) => w.repoId === repoId)
|
||||||
|
.sort((a, b) => Number(b.isMain) - Number(a.isMain) || a.path.localeCompare(b.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAll(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = null;
|
||||||
|
try {
|
||||||
|
const [r, w] = await Promise.all([
|
||||||
|
api.get<ReposListResponse>('/api/v1/repos'),
|
||||||
|
api.get<WorktreesListResponse>('/api/v1/worktrees'),
|
||||||
|
]);
|
||||||
|
repos.value = r.repos;
|
||||||
|
worktrees.value = w.worktrees;
|
||||||
|
} catch (err) {
|
||||||
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRepoWorktrees(repoId: string): Promise<void> {
|
||||||
|
const w = await api.get<WorktreesListResponse>(`/api/v1/repos/${repoId}/worktrees`);
|
||||||
|
for (const wt of w.worktrees) upsertWorktree(wt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRepo(path: string, label?: string): Promise<RepoSummary> {
|
||||||
|
const res = await api.post<RepoResponse>('/api/v1/repos', { path, ...(label ? { label } : {}) });
|
||||||
|
upsertRepo(res.repo);
|
||||||
|
await refreshRepoWorktrees(res.repo.id);
|
||||||
|
return res.repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRepo(id: string): Promise<void> {
|
||||||
|
await api.delete<{ ok: true }>(`/api/v1/repos/${id}`);
|
||||||
|
removeRepoLocal(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorktree(repoId: string, req: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
|
||||||
|
const res = await api.post<CreateWorktreeResponse>(`/api/v1/repos/${repoId}/worktrees`, req);
|
||||||
|
upsertWorktree(res.worktree);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
|
||||||
|
await api.delete<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees?path=${encodeURIComponent(path)}&force=${force}`);
|
||||||
|
removeWorktreeLocal(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prune(repoId: string): Promise<void> {
|
||||||
|
await api.post<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees/prune`);
|
||||||
|
await refreshRepoWorktrees(repoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRealtime(): void {
|
||||||
|
unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
|
||||||
|
}
|
||||||
|
function stopRealtime(): void {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repos,
|
||||||
|
worktrees,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
worktreesForRepo,
|
||||||
|
fetchAll,
|
||||||
|
addRepo,
|
||||||
|
removeRepo,
|
||||||
|
createWorktree,
|
||||||
|
deleteWorktree,
|
||||||
|
prune,
|
||||||
|
startRealtime,
|
||||||
|
stopRealtime,
|
||||||
|
};
|
||||||
|
});
|
||||||
104
packages/web/src/views/DashboardView.vue
Normal file
104
packages/web/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<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('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: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
||||||
|
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
||||||
|
<button
|
||||||
|
v-if="push.supported"
|
||||||
|
class="btn"
|
||||||
|
:class="push.enabled ? 'border-emerald-700 text-emerald-300' : ''"
|
||||||
|
:disabled="push.busy"
|
||||||
|
:title="pushErrorText"
|
||||||
|
@click="push.toggle()"
|
||||||
|
>
|
||||||
|
{{ push.enabled ? t('push.disable') : t('push.enable') }}
|
||||||
|
</button>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
||||||
|
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('repos.pathLabel') }}
|
||||||
|
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
|
||||||
|
{{ adding ? t('repos.adding') : t('repos.add') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="addError" class="text-sm text-red-400">{{ addError }}</p>
|
||||||
|
|
||||||
|
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
||||||
|
<p v-else-if="store.loading && store.repos.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||||
|
<p v-else-if="store.repos.length === 0" class="text-sm text-zinc-500">{{ t('repos.empty') }}</p>
|
||||||
|
|
||||||
|
<RepoSection v-for="repo in store.repos" :key="repo.id" :repo="repo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { usePushStore } from '../stores/push';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
import RepoSection from '../components/RepoSection.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const store = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const push = usePushStore();
|
||||||
|
|
||||||
|
const newPath = ref('');
|
||||||
|
const adding = ref(false);
|
||||||
|
const addError = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
||||||
|
const pushErrorText = computed(() => {
|
||||||
|
const e = push.error;
|
||||||
|
if (!e) return '';
|
||||||
|
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void store.fetchAll();
|
||||||
|
store.startRealtime();
|
||||||
|
// les sessions alimentent la corrélation worktree↔session (cwd) côté serveur ; on charge et on
|
||||||
|
// suit leurs mises à jour temps réel pour que les badges d'état (busy/waiting/idle) restent live.
|
||||||
|
void sessions.fetchSessions();
|
||||||
|
sessions.startRealtime();
|
||||||
|
void push.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onAddRepo(): Promise<void> {
|
||||||
|
adding.value = true;
|
||||||
|
addError.value = null;
|
||||||
|
try {
|
||||||
|
await store.addRepo(newPath.value.trim());
|
||||||
|
newPath.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
addError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
adding.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout(): Promise<void> {
|
||||||
|
store.stopRealtime();
|
||||||
|
sessions.stopRealtime();
|
||||||
|
await auth.logout();
|
||||||
|
await router.replace({ name: 'login' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
||||||
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||||
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
||||||
<div v-else class="flex flex-1 items-center justify-center p-6 text-center">
|
<div v-else class="flex flex-1 items-center justify-center p-6 text-center">
|
||||||
@@ -26,6 +27,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import TerminalView from '../components/TerminalView.vue';
|
import TerminalView from '../components/TerminalView.vue';
|
||||||
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.title') }}</h1>
|
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.title') }}</h1>
|
||||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<RouterLink :to="{ name: 'dashboard' }" class="btn">{{ t('dashboard.title') }}</RouterLink>
|
||||||
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
|
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
|
||||||
{{ t('sessions.refresh') }}
|
{{ t('sessions.refresh') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -58,16 +59,12 @@
|
|||||||
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
||||||
{{ t('sessions.sourceDiscovered') }}
|
{{ t('sessions.sourceDiscovered') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
<SessionStateBadge :session="s" />
|
||||||
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
|
||||||
{{ s.registryStatus ? t(`sessions.registryStatus.${s.registryStatus}`) : t('sessions.live') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="s.resumable" class="badge bg-amber-950 text-amber-400">{{ t('sessions.resumable') }}</span>
|
|
||||||
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
|
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
|
||||||
<p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p>
|
<p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p>
|
||||||
|
<DialogPrompt v-if="s.live && s.activity === 'waiting'" :session="s" />
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||||
<span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span>
|
<span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span>
|
||||||
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
||||||
@@ -113,6 +110,8 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||||
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
Reference in New Issue
Block a user