Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8036de2f0d | |||
| 08539f4ca6 | |||
| 915fdf185d | |||
| ea7b2fd278 | |||
| 4e1c5b2c33 | |||
| c6ea9f7930 | |||
| ca6700d6ce | |||
| dcb6cd1ec7 | |||
| 3b251c8174 | |||
| 8d3a47bd91 | |||
| 073f2cf9ca | |||
| 36bfeb057e | |||
| 7a3c119f36 | |||
| 813dfd952c | |||
| 693e6736de | |||
| 33295a7aaf |
@@ -27,6 +27,7 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run typecheck
|
- run: npm run typecheck
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
- run: npm run build:site
|
||||||
- run: npx vitest run
|
- run: npx vitest run
|
||||||
|
|
||||||
pack-smoke:
|
pack-smoke:
|
||||||
@@ -41,30 +42,26 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
# @arboretum/shared (dépendance runtime non publiée) est embarquée dans le tarball
|
# @arboretum/shared (paquet workspace NON publié) est INLINÉ dans dist/_shared au prepack
|
||||||
# via bundleDependencies (matérialisée au prepack). On packe donc UN SEUL tarball
|
# (scripts/inline-shared.mjs) : le tarball est 100 % autonome — aucun node_modules embarqué,
|
||||||
# et on l'installe seul, comme un vrai consommateur depuis le registre.
|
# aucune bundleDependency, aucun symlink. On packe en mode workspace (-w), EXACTEMENT comme
|
||||||
|
# le fait « npm publish » dans release.yml, puis on l'installe seul comme un vrai consommateur.
|
||||||
- name: Pack tarball
|
- name: Pack tarball
|
||||||
run: |
|
run: |
|
||||||
rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs
|
rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs
|
||||||
# Le hook prepack (copy-web + vendor-shared + copy-meta) matérialise
|
npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs
|
||||||
# @arboretum/shared comme VRAI dossier dans le node_modules du serveur —
|
|
||||||
# condition pour que npm l'embarque via bundleDependencies. Selon la version
|
|
||||||
# de npm du runner, « npm pack -w » ne déclenche PAS toujours ce lifecycle
|
|
||||||
# (contrairement à « npm publish -w », qui lance bien prepack). On l'exécute
|
|
||||||
# donc explicitement, on vérifie la matérialisation, puis on packe avec
|
|
||||||
# --ignore-scripts (prepack déjà fait, dossier physique présent → bundlé).
|
|
||||||
npm run prepack -w @johanleroy/git-arboretum
|
|
||||||
test -f packages/server/node_modules/@arboretum/shared/dist/index.js \
|
|
||||||
|| { echo "ERREUR: prepack n'a pas matérialisé @arboretum/shared/dist/index.js"; exit 1; }
|
|
||||||
npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs --ignore-scripts
|
|
||||||
ls -l /tmp/tarballs
|
ls -l /tmp/tarballs
|
||||||
- name: Assert @arboretum/shared is bundled in the tarball
|
- name: Assert the package is self-contained (@arboretum/shared inlined)
|
||||||
run: |
|
run: |
|
||||||
tgz=$(ls /tmp/tarballs/*.tgz)
|
tgz=$(ls /tmp/tarballs/*.tgz)
|
||||||
tar -tzf "$tgz" | grep -q 'node_modules/@arboretum/shared/dist/index.js' \
|
rm -rf /tmp/inspect && mkdir -p /tmp/inspect && tar -xzf "$tgz" -C /tmp/inspect
|
||||||
|| { echo "ERREUR: @arboretum/shared non embarqué dans $tgz"; exit 1; }
|
test -f /tmp/inspect/package/dist/_shared/index.js \
|
||||||
echo "OK: bundleDependency @arboretum/shared présente dans $tgz"
|
|| { echo "ERREUR: dist/_shared/index.js absent de $tgz — inline-shared n'a pas tourné ?"; exit 1; }
|
||||||
|
if grep -rq '@arboretum/shared' /tmp/inspect/package/dist; then
|
||||||
|
echo "ERREUR: import bare '@arboretum/shared' encore présent dans le JS publié"
|
||||||
|
grep -rn '@arboretum/shared' /tmp/inspect/package/dist; exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: paquet autonome — shared inliné dans dist/_shared, aucun import externe"
|
||||||
- name: Install tarball in an empty project
|
- name: Install tarball in an empty project
|
||||||
run: |
|
run: |
|
||||||
mkdir /tmp/smoke
|
mkdir /tmp/smoke
|
||||||
55
.gitea/workflows/prod.yml
Normal file
55
.gitea/workflows/prod.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Deploy site (production)
|
||||||
|
|
||||||
|
# Déploie le site vitrine (packages/site) sur Plesk via FTPS (lftp), à l'identique
|
||||||
|
# de lidge_web / tracksniff-web. Ne se déclenche que sur un changement du site.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'packages/site/**'
|
||||||
|
- '.gitea/workflows/prod.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-site
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Install scopé au workspace du site : pas de build des deps natives du
|
||||||
|
# serveur (node-pty / node:sqlite) qui n'ont rien à faire ici.
|
||||||
|
- name: Install deps (@arboretum/site)
|
||||||
|
run: npm install -w @arboretum/site --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: npm run build:site
|
||||||
|
|
||||||
|
- name: Check build output
|
||||||
|
run: ls -la packages/site/dist
|
||||||
|
|
||||||
|
- name: Deploy via FTPS (lftp)
|
||||||
|
run: |
|
||||||
|
if [ -z "$REMOTE_PATH" ]; then
|
||||||
|
echo "::error::Secret SITE_REMOTE_PATH manquant (chemin docroot du vhost git-arboretum.com)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
apt-get update && apt-get install -y lftp
|
||||||
|
lftp -c "
|
||||||
|
open -u \"$FTP_USER\",\"$FTP_PASSWORD\" \"$FTP_HOST\"
|
||||||
|
set ssl:verify-certificate no
|
||||||
|
mirror -R --delete --verbose packages/site/dist \"$REMOTE_PATH\"
|
||||||
|
"
|
||||||
|
env:
|
||||||
|
FTP_HOST: ${{ secrets.FTP_HOST }}
|
||||||
|
FTP_USER: ${{ secrets.FTP_USER }}
|
||||||
|
FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
|
||||||
|
# Chemin docroot du vhost git-arboretum.com sur Plesk (ex. /httpdocs/arboretum/public).
|
||||||
|
REMOTE_PATH: ${{ secrets.SITE_REMOTE_PATH }}
|
||||||
73
.gitea/workflows/release.yml
Normal file
73
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Publication du paquet @johanleroy/git-arboretum sur le registre npm du Gitea,
|
||||||
|
# déclenchée UNIQUEMENT par un tag vX.Y.Z (jamais sur un push de branche).
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish to Gitea npm registry
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
registry-url: 'https://git.lidge.fr/api/packages/johanleroy/npm/'
|
||||||
|
scope: '@johanleroy'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
# Garde-fou : le tag (sans le "v") doit correspondre à la version du paquet.
|
||||||
|
# GITHUB_REF_NAME est une variable d'env du runner (pas d'interpolation ${{ }} dans le shell).
|
||||||
|
- name: Verify tag matches package version
|
||||||
|
run: |
|
||||||
|
pkg=$(node -p "require('./packages/server/package.json').version")
|
||||||
|
tag="${GITHUB_REF_NAME#v}"
|
||||||
|
if [ "$pkg" != "$tag" ]; then
|
||||||
|
echo "ERREUR: tag '$tag' != version paquet '$pkg'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: tag $tag == version $pkg"
|
||||||
|
# Publication idempotente : le signal faisant autorité d'une version déjà présente est le
|
||||||
|
# 409 « already exists » renvoyé par npm publish lui-même (npm view est non fiable contre le
|
||||||
|
# registre npm de Gitea — faux négatif masqué par >/dev/null). On tente toujours le publish ;
|
||||||
|
# un 409 = succès idempotent, tout autre échec reste fatal. Le shell Actions tourne en
|
||||||
|
# `bash -eo pipefail` : on isole l'échec attendu dans la condition d'un `if` pour ne pas
|
||||||
|
# déclencher `set -e`. Le secret du registre est mappé sur NODE_AUTH_TOKEN lu par le .npmrc
|
||||||
|
# de setup-node.
|
||||||
|
- name: Publish (idempotent — tolère un 409 « already exists »)
|
||||||
|
run: |
|
||||||
|
if out="$(npm publish -w @johanleroy/git-arboretum 2>&1)"; then
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
echo "Publication réussie."
|
||||||
|
else
|
||||||
|
code=$?
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
if printf '%s' "$out" | grep -qiE 'E409|409 Conflict|already exists'; then
|
||||||
|
echo "::notice::Version déjà présente sur le registre (409) — publication idempotente, étape ignorée."
|
||||||
|
else
|
||||||
|
echo "::error::Échec de la publication (code $code)."
|
||||||
|
exit "$code"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
# SBOM (transparence supply-chain entreprise) : généré pour le paquet publié et exposé en
|
||||||
|
# artefact. C'est un bonus : un échec de génération/upload ne doit jamais faire rougir une
|
||||||
|
# release dont le publish a réussi → continue-on-error.
|
||||||
|
# upload-artifact@v3 : Gitea Actions (GHES-like) ne supporte pas @v4 (@actions/artifact v2+).
|
||||||
|
- name: Generate SBOM (CycloneDX)
|
||||||
|
continue-on-error: true
|
||||||
|
run: npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json -w @johanleroy/git-arboretum || npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: sbom
|
||||||
|
path: sbom.json
|
||||||
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
# Publication du paquet @johanleroy/git-arboretum sur le registre npm du Gitea,
|
|
||||||
# déclenchée UNIQUEMENT par un tag vX.Y.Z (jamais sur un push de branche).
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish to Gitea npm registry
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
registry-url: 'https://git.lidge.fr/api/packages/johanleroy/npm/'
|
|
||||||
scope: '@johanleroy'
|
|
||||||
cache: npm
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run build
|
|
||||||
# Garde-fou : le tag (sans le "v") doit correspondre à la version du paquet.
|
|
||||||
# GITHUB_REF_NAME est une variable d'env du runner (pas d'interpolation ${{ }} dans le shell).
|
|
||||||
- name: Verify tag matches package version
|
|
||||||
run: |
|
|
||||||
pkg=$(node -p "require('./packages/server/package.json').version")
|
|
||||||
tag="${GITHUB_REF_NAME#v}"
|
|
||||||
if [ "$pkg" != "$tag" ]; then
|
|
||||||
echo "ERREUR: tag '$tag' != version paquet '$pkg'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "OK: tag $tag == version $pkg"
|
|
||||||
- run: npm publish -w @johanleroy/git-arboretum
|
|
||||||
env:
|
|
||||||
# Mapper le secret du registre (PAT Gitea write:package, ou le token auto
|
|
||||||
# GITEA_TOKEN s'il a ce scope) sur NODE_AUTH_TOKEN lu par le .npmrc de setup-node.
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
10
README.fr.md
10
README.fr.md
@@ -209,9 +209,13 @@ Un terminal web, c'est de l'exécution de code à distance *par conception*. Les
|
|||||||
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
||||||
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
||||||
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel.
|
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel.
|
||||||
|
- Envoie des en-têtes HTTP durcis (CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, HSTS conditionnel, `no-store` sur l'API), restreint le dossier de données à `0o700` et la base à `0o600`, et **chiffre les secrets sensibles au repos** (AES-256-GCM).
|
||||||
|
- Tient un **journal d'audit** des opérations sensibles et offre l'**export/effacement RGPD** des données (Réglages → Sécurité & conformité).
|
||||||
|
|
||||||
Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet.
|
Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet.
|
||||||
|
|
||||||
|
Voir [`SECURITY.md`](SECURITY.md) pour le modèle de menace complet et [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) pour le durcissement en environnement réglementé.
|
||||||
|
|
||||||
## Ce qui le distingue
|
## Ce qui le distingue
|
||||||
|
|
||||||
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
@@ -251,6 +255,12 @@ node packages/server/scripts/acceptance-p4.mjs # Web Push + commande WS `answe
|
|||||||
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Soutenir le projet
|
||||||
|
|
||||||
|
Arboretum est un projet personnel libre et auto-financé. S'il vous fait gagner du temps, vous pouvez soutenir son développement :
|
||||||
|
|
||||||
|
[](https://buymeacoffee.com/johanleroy)
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT — voir [LICENSE](LICENSE).
|
MIT — voir [LICENSE](LICENSE).
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -209,9 +209,13 @@ A web terminal is remote code execution *by design*. Arboretum's guardrails are
|
|||||||
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
||||||
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
||||||
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff.
|
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff.
|
||||||
|
- Sends hardened HTTP headers (CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, conditional HSTS, `no-store` on the API), restricts the data directory to `0o700` and the database to `0o600`, and **encrypts sensitive secrets at rest** (AES-256-GCM).
|
||||||
|
- Keeps an **audit log** of sensitive operations and offers **GDPR** data export/erasure (Settings → Security & compliance).
|
||||||
|
|
||||||
Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet.
|
Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet.
|
||||||
|
|
||||||
|
See [`SECURITY.md`](SECURITY.md) for the full threat model and [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) for hardening in regulated environments.
|
||||||
|
|
||||||
## What makes it different
|
## What makes it different
|
||||||
|
|
||||||
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
@@ -251,6 +255,12 @@ node packages/server/scripts/acceptance-p4.mjs # Web Push + WS `answer` comman
|
|||||||
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Arboretum is a free, self-funded side project. If it saves you time, you can support its development:
|
||||||
|
|
||||||
|
[](https://buymeacoffee.com/johanleroy)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
61
SECURITY.md
Normal file
61
SECURITY.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Arboretum is a self-hosted daemon that serves a web dashboard to drive git worktrees and the
|
||||||
|
Claude Code sessions running on them. **A web terminal is remote code execution by design** — that
|
||||||
|
is the product, not a bug. Arboretum's security model is therefore built on *structural* guards
|
||||||
|
(loopback-only binding, authenticated access, strict Origin checks) far more than on cryptography
|
||||||
|
alone.
|
||||||
|
|
||||||
|
This document describes the threat model, the controls that are implemented, the deliberate
|
||||||
|
trade-offs, and how to report a vulnerability. For hardening a deployment in a regulated
|
||||||
|
environment, see [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md).
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
- **Single-user by design.** Arboretum runs on the owner's machine and is meant for one operator.
|
||||||
|
There is no multi-tenant isolation and no RBAC — and none is claimed.
|
||||||
|
- **Loopback by default.** The server binds `127.0.0.1`; `config.ts` *refuses* any non-loopback bind
|
||||||
|
unless you pass `--i-know-this-exposes-a-terminal`. Remote access is expected via **Tailscale Serve**
|
||||||
|
(TLS + tailnet identity), never by opening a port.
|
||||||
|
- **The terminal is RCE.** Anyone who can authenticate can run code. The controls below exist to make
|
||||||
|
sure only the authenticated operator reaches it, and that the surrounding surface (cookies, headers,
|
||||||
|
data at rest) is hardened.
|
||||||
|
|
||||||
|
## Implemented controls
|
||||||
|
|
||||||
|
| Area | Control | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Network | Loopback-only default; non-loopback refused without explicit flag | `packages/server/src/config.ts` |
|
||||||
|
| AuthN | Global guard on **all** `/api/**` and `/ws` (only login is public, and rate-limited) | `packages/server/src/app.ts` |
|
||||||
|
| AuthN | Tokens stored **hashed** (SHA-256), compared in constant time; bootstrap token shown once | `packages/server/src/auth/service.ts` |
|
||||||
|
| Sessions | Cookie is an HMAC-SHA256 signed payload, `HttpOnly` + `SameSite=Strict`, `Secure` when HTTPS | `packages/server/src/routes/auth.ts` |
|
||||||
|
| CSRF / WS | Strict `Origin` check on every `/api/**` and `/ws` request (anti cross-site WS hijacking) | `packages/server/src/app.ts` |
|
||||||
|
| CSRF | Mutations carrying a body must be `application/json` | `packages/server/src/app.ts` |
|
||||||
|
| Rate limit | Global login rate limit with exponential backoff (not per-IP — Tailscale fronts everything as 127.0.0.1) | `packages/server/src/auth/service.ts` |
|
||||||
|
| HTTP headers | CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, conditional HSTS, `Cache-Control: no-store` on API; `Server` header stripped | `packages/server/src/app.ts` |
|
||||||
|
| Injection | All SQL is parameterized; all git calls use `execFile` (no shell); path-traversal guards | `packages/server/src/**` |
|
||||||
|
| Data at rest | DB file/dir forced to `0o600`/`0o700`; sensitive secrets (server secret, VAPID private key) encrypted with AES-256-GCM | `packages/server/src/db/index.ts`, `core/secret-box.ts` |
|
||||||
|
| Audit | Persistent audit log of sensitive mutations (tokens, settings, secrets, push, groups) | `packages/server/src/core/audit-log.ts` |
|
||||||
|
| Privacy | GDPR export (`/api/v1/data/export`) and erasure (`/api/v1/data/delete-my-data`) | `packages/server/src/routes/data.ts` |
|
||||||
|
| Install | Runs as a **user** service (systemd user unit / launchd LaunchAgent), never root | `packages/server/src/cli/install.ts` |
|
||||||
|
|
||||||
|
## Deliberate trade-offs
|
||||||
|
|
||||||
|
- **Long-lived API tokens.** Tokens do not expire by age (CLI automation stability) but can be revoked
|
||||||
|
instantly, and `last_used_at` is tracked. Review and rotate tokens periodically.
|
||||||
|
- **Encryption-at-rest key management.** With no `ARBORETUM_SECRET_KEY` set, the encryption key lives in
|
||||||
|
`dataDir/secret.key` (`0o600`) next to the database — this protects a leaked database *copy* (backup,
|
||||||
|
WAL) but not a full `dataDir` compromise. For strong protection, set `ARBORETUM_SECRET_KEY` and store it
|
||||||
|
separately from database backups. Full-DB SQLCipher is intentionally avoided (it breaks the `npx`
|
||||||
|
prebuilt portability).
|
||||||
|
- **No multi-user model.** If you need multiple operators with isolation, Arboretum is not the right tool.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please report security issues **privately** — do not open a public issue.
|
||||||
|
|
||||||
|
- Email: **security@johanleroy.fr** (or `contact@johanleroy.fr`).
|
||||||
|
- Include a description, affected version, and reproduction steps.
|
||||||
|
- Expect an acknowledgement within **7 days** and a coordinated disclosure window of up to **90 days**.
|
||||||
|
|
||||||
|
Thank you for helping keep Arboretum users safe.
|
||||||
95
docs/ENTERPRISE_DEPLOYMENT.md
Normal file
95
docs/ENTERPRISE_DEPLOYMENT.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Enterprise deployment guide
|
||||||
|
|
||||||
|
This guide complements [`../SECURITY.md`](../SECURITY.md) with the operational steps a regulated or
|
||||||
|
security-conscious organization needs to deploy Arboretum with confidence.
|
||||||
|
|
||||||
|
## 1. Remote access: Tailscale Serve (recommended)
|
||||||
|
|
||||||
|
Never open a public port. Keep the default `127.0.0.1` bind and put Arboretum behind Tailscale Serve:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on the host running arboretum (default bind 127.0.0.1:7317)
|
||||||
|
tailscale serve --bg 7317
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives you TLS, a stable `*.ts.net` hostname, and tailnet identity. Then add that origin to the
|
||||||
|
allow-list so the strict Origin check accepts it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arboretum --allow-origin https://my-host.tailnet.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--i-know-this-exposes-a-terminal` flag exists only as an escape hatch for non-loopback binds; it
|
||||||
|
is **never** a deployment mode and is never injected automatically by `arboretum install`.
|
||||||
|
|
||||||
|
## 2. Encryption at rest
|
||||||
|
|
||||||
|
Sensitive secrets (the HMAC server secret and the VAPID private key) are encrypted with AES-256-GCM
|
||||||
|
before being stored in SQLite. Token values are never stored — only their SHA-256 hashes.
|
||||||
|
|
||||||
|
For **strong** protection (key not co-located with the database), provide a passphrase via the
|
||||||
|
environment instead of the on-disk key file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARBORETUM_SECRET_KEY='<a long random passphrase from your secrets manager>' arboretum
|
||||||
|
```
|
||||||
|
|
||||||
|
- Store this passphrase in your secrets manager / KMS, **separately** from database backups.
|
||||||
|
- Without it, the key is auto-generated in `dataDir/secret.key` (`0o600`). This still protects a leaked
|
||||||
|
database copy, but not a full `dataDir` compromise.
|
||||||
|
- Rotating the passphrase requires re-encrypting existing values; the simplest path is to revoke and
|
||||||
|
recreate the bootstrap token after rotation.
|
||||||
|
|
||||||
|
## 3. Filesystem permissions
|
||||||
|
|
||||||
|
On startup Arboretum forces `dataDir` to `0o700` and the database files (`*.db`, `-wal`, `-shm`) to
|
||||||
|
`0o600`. Verify after first run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stat -c '%a %n' ~/.local/share/arboretum ~/.local/share/arboretum/*.db
|
||||||
|
# expect: 700 …/arboretum and 600 …/arboretum.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `$HOME` private (standard `0o700`). If you relocate data with `--db` or `XDG_DATA_HOME`, make sure
|
||||||
|
the target directory is not world-readable.
|
||||||
|
|
||||||
|
## 4. Audit logging
|
||||||
|
|
||||||
|
All sensitive mutations are recorded in an append-only `audit_logs` table: token create/revoke, login
|
||||||
|
success/failure, settings changes, secret generation, push subscribe/unsubscribe, group CRUD, and data
|
||||||
|
erasure. Query it via the API (paginated):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
'http://127.0.0.1:7317/api/v1/audit-logs?limit=100'
|
||||||
|
```
|
||||||
|
|
||||||
|
The audit log never contains secret values — only non-sensitive metadata (ids, labels, counters). It is
|
||||||
|
also visible in the dashboard under **Settings → Security & compliance**.
|
||||||
|
|
||||||
|
## 5. GDPR (data subject requests)
|
||||||
|
|
||||||
|
- **Export**: `GET /api/v1/data/export` returns every record tied to the authenticated token (token
|
||||||
|
metadata, push subscriptions, session history, settings) as JSON. Also available as a one-click
|
||||||
|
download in **Settings → Security & compliance**.
|
||||||
|
- **Erasure**: `POST /api/v1/data/delete-my-data` is a two-step call — the first response returns a
|
||||||
|
`confirm` code that must be POSTed back to execute. It purges the token's push subscriptions and
|
||||||
|
revokes the token (unless it is the last active one).
|
||||||
|
|
||||||
|
## 6. Backups & retention
|
||||||
|
|
||||||
|
- Back up the SQLite database (`arboretum.db`) with the WAL checkpointed. Treat backups as sensitive.
|
||||||
|
- If you use `ARBORETUM_SECRET_KEY`, back the key up **separately** — a database backup is useless (and
|
||||||
|
safe) without it, which is the point.
|
||||||
|
- Session history is retained until the database is reset. To start clean, stop the service and remove
|
||||||
|
the database file.
|
||||||
|
|
||||||
|
## 7. Logging hygiene
|
||||||
|
|
||||||
|
The log level is controlled by `ARBORETUM_LOG` (default `info`). Do **not** run `debug`/`trace` in
|
||||||
|
production: verbose levels may log request metadata. Keep `info` or higher.
|
||||||
|
|
||||||
|
## 8. Supply chain
|
||||||
|
|
||||||
|
Each published release ships with a CycloneDX SBOM (`sbom.json`) generated in CI, so you can scan the
|
||||||
|
dependency tree for known vulnerabilities before deploying.
|
||||||
120
package-lock.json
generated
120
package-lock.json
generated
@@ -23,6 +23,10 @@
|
|||||||
"resolved": "packages/shared",
|
"resolved": "packages/shared",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@arboretum/site": {
|
||||||
|
"resolved": "packages/site",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@arboretum/web": {
|
"node_modules/@arboretum/web": {
|
||||||
"resolved": "packages/web",
|
"resolved": "packages/web",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -764,6 +768,16 @@
|
|||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/jetbrains-mono": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@homebridge/node-pty-prebuilt-multiarch": {
|
"node_modules/@homebridge/node-pty-prebuilt-multiarch": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz",
|
||||||
@@ -4840,13 +4854,9 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.3.0",
|
"version": "1.6.0",
|
||||||
"bundleDependencies": [
|
|
||||||
"@arboretum/shared"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
@@ -4859,17 +4869,117 @@
|
|||||||
"arboretum": "dist/index.js"
|
"arboretum": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@arboretum/shared": "0.1.0",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.0"
|
"@types/ws": "^8.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.16"
|
"node": ">=22.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "buymeacoffee",
|
||||||
|
"url": "https://buymeacoffee.com/johanleroy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@arboretum/shared",
|
"name": "@arboretum/shared",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
|
"packages/site": {
|
||||||
|
"name": "@arboretum/site",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.38",
|
||||||
|
"vue-i18n": "^11.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource/jetbrains-mono": "^5.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vue-tsc": "^3.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/site/node_modules/vite": {
|
||||||
|
"version": "8.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
|
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lightningcss": "^1.32.0",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"rolldown": "1.0.3",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite": "bin/vite.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
|
"@vitejs/devtools": "^0.1.18",
|
||||||
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
|
"jiti": ">=1.21.0",
|
||||||
|
"less": "^4.0.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"sass-embedded": "^1.70.0",
|
||||||
|
"stylus": ">=0.54.8",
|
||||||
|
"sugarss": "^5.0.0",
|
||||||
|
"terser": "^5.16.0",
|
||||||
|
"tsx": "^4.8.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitejs/devtools": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"less": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stylus": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sugarss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"terser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tsx": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@arboretum/web",
|
"name": "@arboretum/web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"pack": "npm run build && npm pack -w @johanleroy/git-arboretum",
|
"pack": "npm run build && npm pack -w @johanleroy/git-arboretum",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev:server": "npm run dev -w @johanleroy/git-arboretum",
|
"dev:server": "npm run dev -w @johanleroy/git-arboretum",
|
||||||
"dev:web": "npm run dev -w @arboretum/web"
|
"dev:web": "npm run dev -w @arboretum/web",
|
||||||
|
"build:site": "npm run build -w @arboretum/site",
|
||||||
|
"dev:site": "npm run dev -w @arboretum/site",
|
||||||
|
"preview:site": "npm run preview -w @arboretum/site"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.3.0",
|
"version": "1.6.0",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://git.lidge.fr/johanleroy/arboretum/issues"
|
"url": "https://git.lidge.fr/johanleroy/arboretum/issues"
|
||||||
},
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "buymeacoffee",
|
||||||
|
"url": "https://buymeacoffee.com/johanleroy"
|
||||||
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"git",
|
"git",
|
||||||
"worktree",
|
"worktree",
|
||||||
@@ -44,14 +48,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"dev": "tsc -b --watch & node --watch dist/index.js",
|
"dev": "tsc -b --watch & node --watch dist/index.js",
|
||||||
"prepack": "node scripts/copy-web.mjs && node scripts/vendor-shared.mjs && node scripts/copy-meta.mjs",
|
"prepack": "node scripts/copy-web.mjs && node scripts/inline-shared.mjs && node scripts/copy-meta.mjs",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"bundleDependencies": [
|
|
||||||
"@arboretum/shared"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@arboretum/shared": "0.1.0",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.5.0"
|
"@types/ws": "^8.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Vrai daemon + vrai repo git tmp. Couvre : enregistrement repo, création worktree avec hook
|
// 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.
|
// post-create, broadcast WS worktree_update, corrélation worktree↔session (bash), suppression.
|
||||||
import { spawn, execFileSync } from 'node:child_process';
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, dirname, basename } from 'node:path';
|
import { join, dirname, basename } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -119,6 +119,24 @@ try {
|
|||||||
const feat = wlist1.worktrees.find((w) => w.branch === 'feat');
|
const feat = wlist1.worktrees.find((w) => w.branch === 'feat');
|
||||||
check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live));
|
check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live));
|
||||||
|
|
||||||
|
// Session sur le checkout principal (« bosser sur la branche principale ») avec création de branche.
|
||||||
|
const mainSess = await j(`/api/v1/repos/${repoSummary.id}/session`, 'POST', cookie, { command: 'bash', branch: 'mainfeat', newBranch: true });
|
||||||
|
const mainBody = await mainSess.json();
|
||||||
|
check('POST repo session → 201 (bash)', mainSess.status === 201 && mainBody.session?.command === 'bash');
|
||||||
|
check('session de branche principale : cwd = checkout principal', mainBody.session?.cwd === repoSummary.path);
|
||||||
|
const mainPush = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.isMain && m.worktree?.branch === 'mainfeat');
|
||||||
|
check('broadcast worktree_update (branche principale basculée)', !!mainPush);
|
||||||
|
await sleep(300);
|
||||||
|
const wlistM = await (await j('/api/v1/worktrees', 'GET', cookie)).json();
|
||||||
|
const mainWt = wlistM.worktrees.find((w) => w.isMain);
|
||||||
|
check('session corrélée au checkout principal', mainWt?.branch === 'mainfeat' && mainWt.sessions.some((s) => s.id === mainBody.session.id));
|
||||||
|
|
||||||
|
// Refus 409 quand le checkout principal est sale.
|
||||||
|
writeFileSync(join(repo, 'scratch.txt'), 'wip\n');
|
||||||
|
const dirtyRefused = await j(`/api/v1/repos/${repoSummary.id}/session`, 'POST', cookie, { command: 'bash', branch: 'other', newBranch: true });
|
||||||
|
check('repo session sur checkout sale → 409', dirtyRefused.status === 409);
|
||||||
|
rmSync(join(repo, 'scratch.txt'), { force: true });
|
||||||
|
|
||||||
// Suppression forcée (une session vit dans le worktree).
|
// 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);
|
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);
|
check('delete sans force (session live) → 409', refused.status === 409);
|
||||||
|
|||||||
@@ -24,15 +24,20 @@ const check = (name, ok, detail = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
||||||
|
function initRepo(path) {
|
||||||
|
execFileSync('mkdir', ['-p', path]);
|
||||||
|
const git = (...args) => execFileSync('git', args, { cwd: path, 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: path });
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'init');
|
||||||
|
}
|
||||||
const repo = join(tmp, 'demo-repo');
|
const repo = join(tmp, 'demo-repo');
|
||||||
execFileSync('mkdir', ['-p', repo]);
|
const repo2 = join(tmp, 'demo-repo-2');
|
||||||
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
initRepo(repo);
|
||||||
git('init', '-b', 'main');
|
initRepo(repo2);
|
||||||
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(
|
const srv = spawn(
|
||||||
'node',
|
'node',
|
||||||
@@ -109,6 +114,41 @@ try {
|
|||||||
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
||||||
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
||||||
|
|
||||||
|
// ---- P6 : session de groupe multi-repo (UNE session couvrant tous les repos via --add-dir) ----
|
||||||
|
// Enregistre un 2e repo, l'ajoute au groupe, puis lance UNE session de groupe (bash, sans quota).
|
||||||
|
const addRepo2 = await j('/api/v1/repos', 'POST', cookie, { path: repo2 });
|
||||||
|
const repo2Summary = (await addRepo2.json()).repo;
|
||||||
|
check('POST /repos (2e repo) → 201', addRepo2.status === 201 && repo2Summary?.valid === true);
|
||||||
|
await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repo2Summary.id });
|
||||||
|
|
||||||
|
// Mode « checkouts principaux » (pas de branch) : couvre le worktree principal de chaque repo.
|
||||||
|
const gsRes = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash' });
|
||||||
|
const gsBody = await gsRes.json();
|
||||||
|
const gsession = gsBody.session;
|
||||||
|
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
|
||||||
|
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
|
||||||
|
check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1);
|
||||||
|
check('session de groupe : groupId posé', gsession?.groupId === group.id);
|
||||||
|
check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2);
|
||||||
|
|
||||||
|
// La session apparaît dans la liste globale avec son groupId.
|
||||||
|
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||||
|
const listed = sessList.sessions?.find((s) => s.id === gsession.id);
|
||||||
|
check('GET /sessions : session de groupe présente avec groupId', listed?.groupId === group.id);
|
||||||
|
|
||||||
|
// broadcast WS session_update reçu pour la session de groupe.
|
||||||
|
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
|
||||||
|
check('broadcast WS session_update (session de groupe)', !!pushedSession);
|
||||||
|
|
||||||
|
// groupe sans worktree résolu (branche inexistante) → 400.
|
||||||
|
const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' });
|
||||||
|
check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);
|
||||||
|
|
||||||
|
// Arrêt de la session de groupe + nettoyage du 2e repo (CASCADE retire repo2 de la membership).
|
||||||
|
const killSession = await j(`/api/v1/sessions/${gsession.id}`, 'DELETE', cookie);
|
||||||
|
check('DELETE session de groupe → 200', killSession.status === 200);
|
||||||
|
await j(`/api/v1/repos/${repo2Summary.id}`, 'DELETE', cookie);
|
||||||
|
|
||||||
// Renommage via PATCH.
|
// Renommage via PATCH.
|
||||||
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
||||||
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
||||||
|
|||||||
71
packages/server/scripts/inline-shared.mjs
Normal file
71
packages/server/scripts/inline-shared.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Inline @arboretum/shared (paquet workspace NON publié) directement dans le dist du serveur,
|
||||||
|
// pour que le tarball npm soit 100 % autonome. Branché sur le hook "prepack".
|
||||||
|
//
|
||||||
|
// Pourquoi PAS bundleDependencies : embarquer une dépendance qui est aussi un *workspace* via
|
||||||
|
// bundleDependencies est instable selon l'environnement npm (mode -w, exécution en root sur un
|
||||||
|
// runner CI, version d'arborist) — npm voit le nœud comme un lien workspace et n'embarque parfois
|
||||||
|
// AUCUN fichier ("bundled files: 0"), produisant un paquet cassé chez le consommateur. On élimine
|
||||||
|
// donc toute magie de bundling : on copie le JS compilé de shared dans dist/_shared et on réécrit
|
||||||
|
// l'import bare '@arboretum/shared' du serveur vers ce chemin relatif. Zéro node_modules embarqué,
|
||||||
|
// zéro symlink, résultat identique partout.
|
||||||
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, dirname, relative, sep } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const serverDist = join(serverDir, 'dist');
|
||||||
|
const sharedDist = join(serverDir, '..', 'shared', 'dist');
|
||||||
|
const inlineDir = join(serverDist, '_shared');
|
||||||
|
|
||||||
|
for (const [label, p] of [['dist serveur', serverDist], ['dist shared', sharedDist]]) {
|
||||||
|
if (!existsSync(p)) {
|
||||||
|
console.error(`inline-shared: ${label} introuvable (${p}) — lance "npm run build" avant le pack.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Copier le JS compilé de shared dans dist/_shared (uniquement *.js : seul le runtime compte ;
|
||||||
|
// les .d.ts/.map ne sont de toute façon pas publiés via le glob `files`). index.js réexporte
|
||||||
|
// ./protocol.js et ./api.js en relatif → la copie complète préserve la résolution interne.
|
||||||
|
rmSync(inlineDir, { recursive: true, force: true });
|
||||||
|
mkdirSync(inlineDir, { recursive: true });
|
||||||
|
let copied = 0;
|
||||||
|
for (const name of readdirSync(sharedDist)) {
|
||||||
|
if (name.endsWith('.js')) {
|
||||||
|
cpSync(join(sharedDist, name), join(inlineDir, name));
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copied === 0) {
|
||||||
|
console.error(`inline-shared: aucun .js dans ${sharedDist} — shared n'est pas compilé.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Réécrire l'import bare '@arboretum/shared' de chaque .js du serveur vers le chemin relatif
|
||||||
|
// (POSIX) pointant sur dist/_shared/index.js, calculé par fichier (profondeur variable).
|
||||||
|
const walk = (dir) =>
|
||||||
|
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||||
|
const p = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) return p === inlineDir ? [] : walk(p); // ne pas se réécrire soi-même
|
||||||
|
return e.name.endsWith('.js') ? [p] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
let rewritten = 0;
|
||||||
|
for (const file of walk(serverDist)) {
|
||||||
|
const src = readFileSync(file, 'utf8');
|
||||||
|
if (!src.includes('@arboretum/shared')) continue;
|
||||||
|
let rel = relative(dirname(file), join(inlineDir, 'index.js')).split(sep).join('/');
|
||||||
|
if (!rel.startsWith('.')) rel = `./${rel}`;
|
||||||
|
const out = src.replaceAll(`'@arboretum/shared'`, `'${rel}'`).replaceAll(`"@arboretum/shared"`, `"${rel}"`);
|
||||||
|
if (out !== src) {
|
||||||
|
writeFileSync(file, out);
|
||||||
|
rewritten++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rewritten === 0) {
|
||||||
|
console.error(`inline-shared: aucun import '@arboretum/shared' réécrit dans ${serverDist} — build manquant ?`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`inline-shared: ${copied} fichier(s) shared -> dist/_shared, import réécrit dans ${rewritten} fichier(s) serveur`);
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Embarque @arboretum/shared (dépendance runtime non publiée) DANS le tarball npm.
|
|
||||||
// Branché sur le hook "prepack", aux côtés de bundleDependencies dans package.json.
|
|
||||||
//
|
|
||||||
// Pourquoi un vrai dossier et pas le symlink workspace : sous npm workspaces, shared
|
|
||||||
// est seulement symlinké dans le node_modules racine ; `npm pack` n'embarque une
|
|
||||||
// bundleDependency que si elle existe comme VRAI dossier dans le node_modules du
|
|
||||||
// paquet packé (packages/server/node_modules/@arboretum/shared). On le matérialise ici.
|
|
||||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const sharedDir = join(serverDir, '..', 'shared');
|
|
||||||
const sharedDist = join(sharedDir, 'dist');
|
|
||||||
const target = join(serverDir, 'node_modules', '@arboretum', 'shared');
|
|
||||||
|
|
||||||
if (!existsSync(sharedDist)) {
|
|
||||||
console.error(
|
|
||||||
`vendor-shared: ${sharedDist} introuvable — lance "npm run build" (shared doit être compilé) avant le pack.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// package.json minimal et déterministe : on reprend les champs de résolution réels de
|
|
||||||
// shared (version incluse, pour rester synchro), sans scripts ni files inutiles au runtime.
|
|
||||||
const shared = JSON.parse(readFileSync(join(sharedDir, 'package.json'), 'utf8'));
|
|
||||||
const minimal = {
|
|
||||||
name: shared.name,
|
|
||||||
version: shared.version,
|
|
||||||
type: shared.type,
|
|
||||||
main: shared.main,
|
|
||||||
types: shared.types,
|
|
||||||
exports: shared.exports,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Idempotent : on repart d'un dossier propre à chaque pack.
|
|
||||||
rmSync(target, { recursive: true, force: true });
|
|
||||||
mkdirSync(target, { recursive: true });
|
|
||||||
writeFileSync(join(target, 'package.json'), JSON.stringify(minimal, null, 2) + '\n');
|
|
||||||
cpSync(sharedDist, join(target, 'dist'), { recursive: true });
|
|
||||||
|
|
||||||
console.log(`vendor-shared: embarqué ${shared.name}@${shared.version} -> ${target}`);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import fastifyWebsocket from '@fastify/websocket';
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
@@ -14,6 +14,7 @@ import { WorktreeManager } from './core/worktree-manager.js';
|
|||||||
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
||||||
import { GroupManager } from './core/group-manager.js';
|
import { GroupManager } from './core/group-manager.js';
|
||||||
import { PushService } from './core/push-service.js';
|
import { PushService } from './core/push-service.js';
|
||||||
|
import { loadSecretBox } from './core/secret-box.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 { registerRepoRoutes } from './routes/repos.js';
|
||||||
@@ -22,7 +23,37 @@ import { registerWorktreeRoutes } from './routes/worktrees.js';
|
|||||||
import { registerPushRoutes } from './routes/push.js';
|
import { registerPushRoutes } from './routes/push.js';
|
||||||
import { registerSettingsRoutes } from './routes/settings.js';
|
import { registerSettingsRoutes } from './routes/settings.js';
|
||||||
import { registerFsRoutes } from './routes/fs.js';
|
import { registerFsRoutes } from './routes/fs.js';
|
||||||
|
import { registerAuditRoutes } from './routes/audit.js';
|
||||||
|
import { registerDataRoutes } from './routes/data.js';
|
||||||
import { registerWsGateway } from './ws/gateway.js';
|
import { registerWsGateway } from './ws/gateway.js';
|
||||||
|
import { isHttpsRequest } from './routes/auth.js';
|
||||||
|
|
||||||
|
// En-têtes de sécurité posés sur TOUTES les réponses (defense-in-depth + conformité scanners
|
||||||
|
// entreprise). Le terminal web reste du RCE par conception : ces en-têtes durcissent la SPA et
|
||||||
|
// le transport, ils ne remplacent pas le modèle loopback + auth + Origin.
|
||||||
|
const SECURITY_HEADERS: Record<string, string> = {
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
|
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()',
|
||||||
|
// CSP calibrée pour la SPA : Tailwind/xterm injectent du style inline ; le WebSocket impose
|
||||||
|
// ws:/wss: en connect-src ; le service worker push impose worker-src 'self'.
|
||||||
|
'Content-Security-Policy': [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' ws: wss:",
|
||||||
|
"worker-src 'self'",
|
||||||
|
"manifest-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; '),
|
||||||
|
};
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
@@ -46,9 +77,11 @@ export interface AppBundle {
|
|||||||
|
|
||||||
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);
|
// Chiffrement au repos des secrets (server_secret, clé privée VAPID) dans la base.
|
||||||
|
const box = loadSecretBox(config.dataDir);
|
||||||
|
const auth = new AuthService(db, box);
|
||||||
const limiter = new LoginRateLimiter();
|
const limiter = new LoginRateLimiter();
|
||||||
const push = new PushService(db, config.vapidContact);
|
const push = new PushService(db, config.vapidContact, undefined, box);
|
||||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||||
const discovery = new DiscoveryService({
|
const discovery = new DiscoveryService({
|
||||||
ptyManager: manager,
|
ptyManager: manager,
|
||||||
@@ -60,6 +93,43 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
||||||
const groups = new GroupManager(db);
|
const groups = new GroupManager(db);
|
||||||
|
|
||||||
|
// En-têtes de sécurité sur toute réponse + no-store sur les réponses sensibles (API/WS).
|
||||||
|
// onSend DOIT retourner le payload (sinon Fastify vide la réponse).
|
||||||
|
app.addHook('onSend', async (req, reply, payload) => {
|
||||||
|
reply.removeHeader('Server'); // anti-fingerprinting (no-op si absent)
|
||||||
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v);
|
||||||
|
if (isHttpsRequest(req)) {
|
||||||
|
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||||
|
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Garde CSRF defense-in-depth : une mutation porteuse d'un corps DOIT être en application/json
|
||||||
|
// (bloque les soumissions de formulaire cross-site en text/plain, que Fastify parserait sinon).
|
||||||
|
app.addHook('preHandler', async (req, reply) => {
|
||||||
|
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return;
|
||||||
|
const len = req.headers['content-length'];
|
||||||
|
if (!len || len === '0') return; // pas de corps : rien à valider
|
||||||
|
const ct = (req.headers['content-type'] ?? '').toLowerCase();
|
||||||
|
if (!ct.startsWith('application/json')) {
|
||||||
|
return reply.status(415).send({ error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Content-Type must be application/json' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handler d'erreur : ne jamais fuiter de stack/chemin au client ; détail complet côté logs.
|
||||||
|
app.setErrorHandler((err: FastifyError, req, reply) => {
|
||||||
|
const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 500;
|
||||||
|
if (status >= 500) {
|
||||||
|
req.log.error({ err }, 'unhandled error');
|
||||||
|
return reply.status(status).send({ error: { code: 'INTERNAL', message: 'Internal Server Error' } });
|
||||||
|
}
|
||||||
|
// erreurs client (4xx) : code générique, message court non sensible.
|
||||||
|
return reply.status(status).send({ error: { code: err.code ?? 'BAD_REQUEST', message: err.message } });
|
||||||
|
});
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
options: { maxPayload: 1024 * 1024 },
|
options: { maxPayload: 1024 * 1024 },
|
||||||
@@ -97,14 +167,16 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
registerPushRoutes(app, push);
|
registerPushRoutes(app, push, db);
|
||||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||||
registerFsRoutes(app);
|
registerFsRoutes(app);
|
||||||
|
registerAuditRoutes(app, db);
|
||||||
|
registerDataRoutes(app, db, auth);
|
||||||
// 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) => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||||
|
import type { SecretBox } from '../core/secret-box.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
const COOKIE_NAME = 'arb_session';
|
const COOKIE_NAME = 'arb_session';
|
||||||
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
||||||
@@ -12,11 +14,21 @@ export interface AuthContext {
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly secret: Buffer;
|
private readonly secret: Buffer;
|
||||||
|
|
||||||
constructor(private readonly db: Db) {
|
constructor(
|
||||||
let secretHex = getSetting(db, 'server_secret');
|
private readonly db: Db,
|
||||||
if (!secretHex) {
|
box?: SecretBox,
|
||||||
|
) {
|
||||||
|
// server_secret chiffré au repos quand un SecretBox est fourni (prod). Migration douce :
|
||||||
|
// une valeur en clair pré-existante est re-chiffrée à la lecture.
|
||||||
|
const stored = getSetting(db, 'server_secret');
|
||||||
|
let secretHex: string;
|
||||||
|
if (!stored) {
|
||||||
secretHex = randomBytes(32).toString('hex');
|
secretHex = randomBytes(32).toString('hex');
|
||||||
setSetting(db, 'server_secret', secretHex);
|
setSetting(db, 'server_secret', box ? box.encrypt(secretHex) : secretHex);
|
||||||
|
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'server_secret' });
|
||||||
|
} else {
|
||||||
|
secretHex = box ? box.decrypt(stored) : stored;
|
||||||
|
if (box && !box.isEncrypted(stored)) setSetting(db, 'server_secret', box.encrypt(secretHex));
|
||||||
}
|
}
|
||||||
this.secret = Buffer.from(secretHex, 'hex');
|
this.secret = Buffer.from(secretHex, 'hex');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { chmodSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -53,6 +53,14 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
|
|
||||||
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
// La DB contient des secrets (server_secret, clé privée VAPID, hashs de tokens) : le dossier de
|
||||||
|
// données ne doit jamais être lisible par d'autres utilisateurs du système. chmod best-effort
|
||||||
|
// (peut échouer sur certains FS Windows/montés ; le démarrage avertit alors sans bloquer).
|
||||||
|
try {
|
||||||
|
chmodSync(dataDir, 0o700);
|
||||||
|
} catch {
|
||||||
|
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
|
||||||
|
}
|
||||||
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
||||||
return {
|
return {
|
||||||
port: Number(values.port),
|
port: Number(values.port),
|
||||||
|
|||||||
65
packages/server/src/core/audit-log.ts
Normal file
65
packages/server/src/core/audit-log.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Journal d'audit : trace persistante des opérations sensibles (création/révocation de tokens,
|
||||||
|
// changements de réglages, génération de secrets, abonnements push, CRUD groupes). Exigence de
|
||||||
|
// conformité entreprise (GDPR/SOX/ISO 27001). Règle ABSOLUE : ne JAMAIS journaliser un secret en
|
||||||
|
// clair — `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AuditLogEntry } from '@arboretum/shared';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
|
||||||
|
export type AuditResult = 'ok' | 'denied' | 'error';
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
/** tokenId de l'acteur, ou 'system' (opérations automatiques), ou 'anonymous' (avant auth). */
|
||||||
|
actor: string;
|
||||||
|
/** verbe.objet, ex. 'token.create', 'settings.update', 'login.failure'. */
|
||||||
|
action: string;
|
||||||
|
resourceId?: string | null;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
result?: AuditResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre une entrée d'audit. Best-effort : une erreur d'écriture ne casse jamais l'opération métier. */
|
||||||
|
export function recordAudit(db: Db, e: AuditEntry): void {
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO audit_logs (id, ts, actor, action, resource_id, details, result) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
new Date().toISOString(),
|
||||||
|
e.actor,
|
||||||
|
e.action,
|
||||||
|
e.resourceId ?? null,
|
||||||
|
e.details ? JSON.stringify(e.details) : null,
|
||||||
|
e.result ?? 'ok',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* l'audit ne doit jamais faire échouer l'action auditée */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste paginée par date décroissante (curseur `before` = ts strictement inférieur). */
|
||||||
|
export function listAudit(db: Db, opts: { limit: number; before?: string | null }): AuditLogEntry[] {
|
||||||
|
const limit = Math.min(Math.max(Math.trunc(opts.limit) || 50, 1), 200);
|
||||||
|
const rows = (
|
||||||
|
opts.before
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, ts, actor, action, resource_id AS resourceId, details, result FROM audit_logs WHERE ts < ? ORDER BY ts DESC LIMIT ?',
|
||||||
|
)
|
||||||
|
.all(opts.before, limit)
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, ts, actor, action, resource_id AS resourceId, details, result FROM audit_logs ORDER BY ts DESC LIMIT ?',
|
||||||
|
)
|
||||||
|
.all(limit)
|
||||||
|
) as Array<{ id: string; ts: string; actor: string; action: string; resourceId: string | null; details: string | null; result: string }>;
|
||||||
|
return rows.map((r) => ({ ...r, details: r.details ? safeParse(r.details) : null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(s: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export interface SpawnOptions {
|
|||||||
command: 'claude' | 'bash';
|
command: 'claude' | 'bash';
|
||||||
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
||||||
resume?: { claudeSessionId: string; fork?: boolean };
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
|
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
||||||
|
addDirs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedClaudeBin: string | null = null;
|
let cachedClaudeBin: string | null = null;
|
||||||
@@ -42,5 +44,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
|||||||
args.push('--resume', opts.resume.claudeSessionId);
|
args.push('--resume', opts.resume.claudeSessionId);
|
||||||
if (opts.resume.fork) args.push('--fork-session');
|
if (opts.resume.fork) args.push('--fork-session');
|
||||||
}
|
}
|
||||||
|
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
||||||
|
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
||||||
return { file: resolveClaudeBin(), args, env };
|
return { file: resolveClaudeBin(), args, env };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ export async function addWorktree(
|
|||||||
await git(repoPath, args);
|
await git(repoPath, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` — utilisé pour démarrer une
|
||||||
|
* session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
|
||||||
|
* si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
|
||||||
|
* est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
|
||||||
|
*/
|
||||||
|
export async function switchBranch(repoPath: string, opts: { branch: string; create: boolean }): Promise<void> {
|
||||||
|
await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
|
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
|
||||||
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ export class GroupManager extends EventEmitter<GroupManagerEvents> {
|
|||||||
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
||||||
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
||||||
if (res.changes === 0) return false;
|
if (res.changes === 0) return false;
|
||||||
|
// Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement.
|
||||||
|
this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id);
|
||||||
this.emit('group_removed', id);
|
this.emit('group_removed', id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ const NOTIFY_DEBOUNCE_MS = 1500;
|
|||||||
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;
|
||||||
|
|
||||||
|
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
||||||
|
function parseAddedDirs(raw: string | null): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
||||||
export interface ClientBinding {
|
export interface ClientBinding {
|
||||||
channel: number;
|
channel: number;
|
||||||
@@ -48,6 +59,10 @@ 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;
|
||||||
|
/** répertoires supplémentaires reliés dans la session (--add-dir) ; [] pour une session mono-repo (P6). */
|
||||||
|
addedDirs: string[];
|
||||||
|
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
||||||
|
groupId: string | null;
|
||||||
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||||
tracker: SessionActivityTracker | null;
|
tracker: SessionActivityTracker | null;
|
||||||
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
||||||
@@ -72,14 +87,33 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
|
spawn(opts: {
|
||||||
|
cwd: string;
|
||||||
|
command?: 'claude' | 'bash';
|
||||||
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
|
/** répertoires supplémentaires à relier (session de groupe multi-repo, P6). */
|
||||||
|
addDirs?: string[];
|
||||||
|
/** groupe propriétaire (session de groupe, P6). */
|
||||||
|
groupId?: string;
|
||||||
|
}): SessionSummary {
|
||||||
const cwd = opts.cwd;
|
const cwd = opts.cwd;
|
||||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||||
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
// Dédoublonne et écarte le cwd primaire ; valide chaque répertoire supplémentaire (comme le cwd).
|
||||||
|
const addedDirs = [...new Set(opts.addDirs ?? [])].filter((d) => d !== cwd);
|
||||||
|
for (const dir of addedDirs) {
|
||||||
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||||
|
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
||||||
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
||||||
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
|
const spec = buildSpawnSpec({
|
||||||
|
command,
|
||||||
|
...(opts.resume ? { resume: opts.resume } : {}),
|
||||||
|
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||||
|
});
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const proc = pty.spawn(spec.file, spec.args, {
|
const proc = pty.spawn(spec.file, spec.args, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
@@ -101,6 +135,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
exited: null,
|
exited: null,
|
||||||
killTimer: null,
|
killTimer: null,
|
||||||
claudeSessionId: null,
|
claudeSessionId: null,
|
||||||
|
addedDirs,
|
||||||
|
groupId: opts.groupId ?? null,
|
||||||
tracker: null,
|
tracker: null,
|
||||||
prevActivity: null,
|
prevActivity: null,
|
||||||
notifyTimer: null,
|
notifyTimer: null,
|
||||||
@@ -116,8 +152,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
}
|
}
|
||||||
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, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);
|
.run(
|
||||||
|
id,
|
||||||
|
cwd,
|
||||||
|
command,
|
||||||
|
session.createdAt,
|
||||||
|
opts.resume?.claudeSessionId ?? null,
|
||||||
|
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
||||||
|
session.groupId,
|
||||||
|
);
|
||||||
|
|
||||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||||
@@ -145,6 +189,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte de session de groupe (P6) à réinjecter au resume : derniers `added_dirs`/`group_id`
|
||||||
|
* persistés pour ce claudeSessionId. Permet à `--resume` de re-relier les mêmes répertoires.
|
||||||
|
*/
|
||||||
|
groupSessionContext(claudeSessionId: string): { addedDirs: string[]; groupId: string | null } | null {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT added_dirs, group_id FROM sessions WHERE claude_session_id = ? AND added_dirs IS NOT NULL ORDER BY created_at DESC LIMIT 1')
|
||||||
|
.get(claudeSessionId) as { added_dirs: string | null; group_id: string | null } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
|
||||||
|
}
|
||||||
|
|
||||||
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
||||||
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
||||||
for (const s of this.live.values()) {
|
for (const s of this.live.values()) {
|
||||||
@@ -170,29 +226,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||||
const liveIds = new Set(this.live.keys());
|
const liveIds = new Set(this.live.keys());
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
|
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>;
|
||||||
const historical: SessionSummary[] = rows
|
const historical: SessionSummary[] = rows
|
||||||
.filter((r) => !liveIds.has(r.id))
|
.filter((r) => !liveIds.has(r.id))
|
||||||
.map((r) => ({
|
.map((r) => {
|
||||||
|
const addedDirs = parseAddedDirs(r.added_dirs);
|
||||||
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
cwd: r.cwd,
|
cwd: r.cwd,
|
||||||
command: r.command,
|
command: r.command,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
status: 'exited',
|
status: 'exited' as const,
|
||||||
live: false,
|
live: false,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
endedAt: r.ended_at,
|
endedAt: r.ended_at,
|
||||||
exitCode: r.exit_code,
|
exitCode: r.exit_code,
|
||||||
clients: 0,
|
clients: 0,
|
||||||
source: 'managed',
|
source: 'managed' as const,
|
||||||
claudeSessionId: r.claude_session_id,
|
claudeSessionId: r.claude_session_id,
|
||||||
pid: null,
|
pid: null,
|
||||||
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
|
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
|
||||||
resumable: r.command === 'claude' && r.claude_session_id != null,
|
resumable: r.command === 'claude' && r.claude_session_id != null,
|
||||||
attachable: false,
|
attachable: false,
|
||||||
registryStatus: null,
|
registryStatus: null,
|
||||||
}));
|
...(addedDirs.length ? { addedDirs } : {}),
|
||||||
|
groupId: r.group_id,
|
||||||
|
};
|
||||||
|
});
|
||||||
return [...liveSummaries, ...historical];
|
return [...liveSummaries, ...historical];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +488,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
activity: act?.activity ?? null,
|
activity: act?.activity ?? null,
|
||||||
waitingFor: act?.waitingFor ?? null,
|
waitingFor: act?.waitingFor ?? null,
|
||||||
dialog: act?.dialog ?? null,
|
dialog: act?.dialog ?? null,
|
||||||
|
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
||||||
|
groupId: s.groupId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||||
|
import type { SecretBox } from './secret-box.js';
|
||||||
|
import { recordAudit } from './audit-log.js';
|
||||||
|
|
||||||
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
// 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.
|
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
||||||
@@ -47,16 +49,22 @@ export class PushService {
|
|||||||
private readonly db: Db,
|
private readonly db: Db,
|
||||||
private readonly contact: string = 'mailto:arboretum@localhost',
|
private readonly contact: string = 'mailto:arboretum@localhost',
|
||||||
sender?: PushSender,
|
sender?: PushSender,
|
||||||
|
box?: SecretBox,
|
||||||
) {
|
) {
|
||||||
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
||||||
let pub = getSetting(db, 'vapid_public');
|
let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer)
|
||||||
let priv = getSetting(db, 'vapid_private');
|
const storedPriv = getSetting(db, 'vapid_private');
|
||||||
|
let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null;
|
||||||
if (!pub || !priv) {
|
if (!pub || !priv) {
|
||||||
const keys = webpush.generateVAPIDKeys();
|
const keys = webpush.generateVAPIDKeys();
|
||||||
pub = keys.publicKey;
|
pub = keys.publicKey;
|
||||||
priv = keys.privateKey;
|
priv = keys.privateKey;
|
||||||
setSetting(db, 'vapid_public', pub);
|
setSetting(db, 'vapid_public', pub);
|
||||||
setSetting(db, 'vapid_private', priv);
|
setSetting(db, 'vapid_private', box ? box.encrypt(priv) : priv);
|
||||||
|
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'vapid' });
|
||||||
|
} else if (box && storedPriv && !box.isEncrypted(storedPriv)) {
|
||||||
|
// migration douce : clé privée pré-existante en clair → re-chiffrée.
|
||||||
|
setSetting(db, 'vapid_private', box.encrypt(priv));
|
||||||
}
|
}
|
||||||
this.vapidPublic = pub;
|
this.vapidPublic = pub;
|
||||||
this.vapidPrivate = priv;
|
this.vapidPrivate = priv;
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ interface Frame {
|
|||||||
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
|
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
|
||||||
* Règles :
|
* Règles :
|
||||||
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
||||||
* on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ;
|
* on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
|
||||||
|
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
|
||||||
|
* conteneur — on l'enregistre ET on continue de descendre pour trouver les repos internes ;
|
||||||
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
||||||
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
||||||
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
||||||
@@ -68,10 +70,13 @@ export async function scanForRepos(
|
|||||||
if (seen.has(dir)) continue;
|
if (seen.has(dir)) continue;
|
||||||
seen.add(dir);
|
seen.add(dir);
|
||||||
|
|
||||||
// un repo : on l'enregistre et on ne descend pas.
|
// Un dossier avec `.git` est un repo. En PROFONDEUR (depth > 0) c'est une feuille : on
|
||||||
|
// l'enregistre sans descendre (on n'ouvre pas les sous-modules/worktrees imbriqués). Mais une
|
||||||
|
// RACINE fournie explicitement (depth 0) est un CONTENEUR de scan : si elle est elle-même un
|
||||||
|
// repo on l'enregistre, puis on CONTINUE de descendre pour découvrir les dépôts qu'elle contient.
|
||||||
if (existsSync(join(dir, '.git'))) {
|
if (existsSync(join(dir, '.git'))) {
|
||||||
found.add(dir);
|
found.add(dir);
|
||||||
continue;
|
if (depth > 0) continue;
|
||||||
}
|
}
|
||||||
if (depth >= limits.maxDepth) continue;
|
if (depth >= limits.maxDepth) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
||||||
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
||||||
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { getSetting } from '../db/index.js';
|
import { getSetting } from '../db/index.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
import { isSafeAbsolutePath } from './git.js';
|
import { isSafeAbsolutePath } from './git.js';
|
||||||
@@ -15,12 +14,14 @@ export const DEFAULT_SCAN_INTERVAL_MIN = 5;
|
|||||||
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
||||||
export const MAX_SCAN_ROOTS = 16;
|
export const MAX_SCAN_ROOTS = 16;
|
||||||
|
|
||||||
/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */
|
/**
|
||||||
|
* Racines à scanner. Défaut : AUCUNE racine → aucun scan (clean install).
|
||||||
|
* L'utilisateur ajoute ses racines via Réglages → Découverte. Lecture tolérante (JSON malformé → []).
|
||||||
|
*/
|
||||||
export function readScanRoots(db: Db): string[] {
|
export function readScanRoots(db: Db): string[] {
|
||||||
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
||||||
if (!raw) return [homedir()];
|
if (!raw) return [];
|
||||||
const parsed = normalizeScanRoots(safeParse(raw));
|
return normalizeScanRoots(safeParse(raw)) ?? [];
|
||||||
return parsed && parsed.length > 0 ? parsed : [homedir()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
||||||
|
|||||||
69
packages/server/src/core/secret-box.ts
Normal file
69
packages/server/src/core/secret-box.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Chiffrement au repos des secrets applicatifs (server_secret HMAC, clé privée VAPID) stockés dans
|
||||||
|
// la table `settings`. node:sqlite (DatabaseSync) ne supporte pas sqlcipher → on chiffre au niveau
|
||||||
|
// applicatif les VALEURS sensibles, en AES-256-GCM (authentifié), avant insertion.
|
||||||
|
//
|
||||||
|
// Gestion de clé (par ordre de priorité) :
|
||||||
|
// 1. ARBORETUM_SECRET_KEY (variable d'env) → vraie protection : la clé ne vit pas sur le disque,
|
||||||
|
// donc une fuite de la base seule (backup, WAL) ne révèle pas les secrets ;
|
||||||
|
// 2. sinon, fichier clé `dataDir/secret.key` (0o600), généré au 1er démarrage. Protection partielle :
|
||||||
|
// couvre la fuite de la base seule, PAS celle du dossier de données complet (clé + base ensemble).
|
||||||
|
// Compromis assumé et documenté (docs/ENTERPRISE_DEPLOYMENT.md) : pour une protection forte, fournir
|
||||||
|
// ARBORETUM_SECRET_KEY et sauvegarder cette clé séparément des backups de la base.
|
||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
||||||
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const PREFIX = 'v1:'; // versionne le format ; absence de préfixe = valeur en clair (legacy → migration douce)
|
||||||
|
const SCRYPT_SALT = 'arboretum-secret-box-v1'; // sel fixe : l'entropie vient de la passphrase fournie
|
||||||
|
|
||||||
|
export class SecretBox {
|
||||||
|
constructor(private readonly key: Buffer) {}
|
||||||
|
|
||||||
|
/** Chiffre en `v1:<iv>:<tag>:<ciphertext>` (hex). */
|
||||||
|
encrypt(plaintext: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `${PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déchiffre une valeur `v1:` ; une valeur sans préfixe est retournée telle quelle (clair legacy). */
|
||||||
|
decrypt(stored: string): string {
|
||||||
|
if (!this.isEncrypted(stored)) return stored;
|
||||||
|
const [, ivHex, tagHex, ctHex] = stored.split(':');
|
||||||
|
if (!ivHex || !tagHex || !ctHex) throw new Error('secret-box: format chiffré invalide');
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(ivHex, 'hex'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(ctHex, 'hex')), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
isEncrypted(stored: string): boolean {
|
||||||
|
return stored.startsWith(PREFIX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le SecretBox de production : clé dérivée d'ARBORETUM_SECRET_KEY si fournie, sinon
|
||||||
|
* d'un fichier clé `dataDir/secret.key` (0o600) généré au besoin.
|
||||||
|
*/
|
||||||
|
export function loadSecretBox(dataDir: string): SecretBox {
|
||||||
|
const passphrase = process.env.ARBORETUM_SECRET_KEY;
|
||||||
|
if (passphrase && passphrase.length > 0) {
|
||||||
|
return new SecretBox(scryptSync(passphrase, SCRYPT_SALT, 32));
|
||||||
|
}
|
||||||
|
const keyPath = join(dataDir, 'secret.key');
|
||||||
|
let keyHex: string;
|
||||||
|
if (existsSync(keyPath)) {
|
||||||
|
keyHex = readFileSync(keyPath, 'utf8').trim();
|
||||||
|
} else {
|
||||||
|
keyHex = randomBytes(32).toString('hex');
|
||||||
|
writeFileSync(keyPath, keyHex + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
chmodSync(keyPath, 0o600);
|
||||||
|
} catch {
|
||||||
|
/* FS sans permissions POSIX : ignoré */
|
||||||
|
}
|
||||||
|
return new SecretBox(Buffer.from(keyHex, 'hex'));
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
listWorktrees,
|
listWorktrees,
|
||||||
pruneWorktrees,
|
pruneWorktrees,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
|
switchBranch,
|
||||||
worktreeStatus,
|
worktreeStatus,
|
||||||
type ParsedWorktree,
|
type ParsedWorktree,
|
||||||
} from './git.js';
|
} from './git.js';
|
||||||
@@ -355,6 +356,41 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance UNE session dans le checkout principal du repo (`repo.path`) — pour « bosser sur la branche
|
||||||
|
* principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
|
||||||
|
* dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
|
||||||
|
* Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
|
||||||
|
* est le dépôt réel de l'utilisateur, déjà configuré/approuvé. Sérialisé par repo (withLock).
|
||||||
|
*/
|
||||||
|
async startMainSession(
|
||||||
|
repoId: string,
|
||||||
|
req: { command?: 'claude' | 'bash'; branch?: string; newBranch?: boolean },
|
||||||
|
): Promise<{ session: SessionSummary; worktree: WorktreeSummary | null }> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const branch = req.branch?.trim() || undefined;
|
||||||
|
if (branch !== undefined && !isValidBranchName(branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${branch}`);
|
||||||
|
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
if (branch !== undefined) {
|
||||||
|
// garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
|
||||||
|
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
|
||||||
|
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit or stash before switching branch');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await switchBranch(row.path, { branch, create: req.newBranch ?? true });
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(400, 'SWITCH_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
}
|
||||||
|
const session = this.ptyManager.spawn({ cwd: row.path, command: req.command ?? 'claude' });
|
||||||
|
const worktree = await this.emitWorktree(row, row.path);
|
||||||
|
return { session, worktree };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
|
async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
|
||||||
const row = this.getRepoRow(repoId);
|
const row = this.getRepoRow(repoId);
|
||||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { chmodSync, existsSync } from 'node:fs';
|
||||||
|
|
||||||
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||||
{
|
{
|
||||||
@@ -99,6 +100,35 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
id: 6,
|
id: 6,
|
||||||
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
|
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations
|
||||||
|
// sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS
|
||||||
|
// de secret en clair — `details` est un JSON de métadonnées non sensibles.
|
||||||
|
id: 7,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource_id TEXT,
|
||||||
|
details TEXT,
|
||||||
|
result TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_audit_ts ON audit_logs(ts);
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// P6 — session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
|
||||||
|
// et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo).
|
||||||
|
// `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe.
|
||||||
|
id: 8,
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE sessions ADD COLUMN added_dirs TEXT;
|
||||||
|
ALTER TABLE sessions ADD COLUMN group_id TEXT;
|
||||||
|
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
@@ -107,10 +137,27 @@ export function openDb(path: string): Db {
|
|||||||
const db = new DatabaseSync(path);
|
const db = new DatabaseSync(path);
|
||||||
db.exec('PRAGMA journal_mode = WAL');
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
db.exec('PRAGMA foreign_keys = ON');
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
hardenDbPermissions(path);
|
||||||
migrate(db);
|
migrate(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restreint la base (et ses fichiers WAL/SHM) à 0o600 — proprio uniquement. La base contient des
|
||||||
|
* secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par
|
||||||
|
* d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX.
|
||||||
|
*/
|
||||||
|
function hardenDbPermissions(path: string): void {
|
||||||
|
if (path === ':memory:') return;
|
||||||
|
for (const p of [path, `${path}-wal`, `${path}-shm`]) {
|
||||||
|
try {
|
||||||
|
if (existsSync(p)) chmodSync(p, 0o600);
|
||||||
|
} catch {
|
||||||
|
/* FS sans permissions POSIX (Windows / montage) : ignoré */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function migrate(db: DatabaseSync): void {
|
function migrate(db: DatabaseSync): void {
|
||||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||||
const applied = new Set(
|
const applied = new Set(
|
||||||
|
|||||||
17
packages/server/src/routes/audit.ts
Normal file
17
packages/server/src/routes/audit.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Consultation du journal d'audit (onglet Réglages / outils de conformité). Lecture seule, sous
|
||||||
|
// l'auth globale. Pagination par curseur `before` (ts décroissant).
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AuditLogsResponse } from '@arboretum/shared';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import { listAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
|
export function registerAuditRoutes(app: FastifyInstance, db: Db): void {
|
||||||
|
app.get('/api/v1/audit-logs', async (req): Promise<AuditLogsResponse> => {
|
||||||
|
const q = req.query as { limit?: string; before?: string };
|
||||||
|
const limit = Math.min(Math.max(Math.trunc(Number(q.limit) || 50), 1), 200);
|
||||||
|
const entries = listAudit(db, { limit, before: q.before ?? null });
|
||||||
|
// s'il reste potentiellement des entrées (page pleine), expose le curseur suivant.
|
||||||
|
const nextBefore = entries.length === limit ? entries[entries.length - 1]!.ts : null;
|
||||||
|
return { entries, nextBefore };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ import type {
|
|||||||
TokensListResponse,
|
TokensListResponse,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||||
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
||||||
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
||||||
function isHttpsRequest(req: FastifyRequest): boolean {
|
export function isHttpsRequest(req: FastifyRequest): boolean {
|
||||||
const xfp = req.headers['x-forwarded-proto'];
|
const xfp = req.headers['x-forwarded-proto'];
|
||||||
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
||||||
return proto === 'https';
|
return proto === 'https';
|
||||||
@@ -23,6 +25,7 @@ export function registerAuthRoutes(
|
|||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
limiter: LoginRateLimiter,
|
limiter: LoginRateLimiter,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
|
db: Db,
|
||||||
): void {
|
): void {
|
||||||
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
||||||
const wait = limiter.check();
|
const wait = limiter.check();
|
||||||
@@ -33,9 +36,11 @@ export function registerAuthRoutes(
|
|||||||
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
limiter.recordFailure();
|
limiter.recordFailure();
|
||||||
|
recordAudit(db, { actor: 'anonymous', action: 'login.failure', result: 'denied' });
|
||||||
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
||||||
}
|
}
|
||||||
limiter.recordSuccess();
|
limiter.recordSuccess();
|
||||||
|
recordAudit(db, { actor: ctx.tokenId, action: 'login.success' });
|
||||||
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -78,18 +83,22 @@ export function registerAuthRoutes(
|
|||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } });
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } });
|
||||||
}
|
}
|
||||||
const { id, token } = auth.createTokenRecord(label);
|
const { id, token } = auth.createTokenRecord(label);
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'token.create', resourceId: id, details: { label } });
|
||||||
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
|
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
|
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const result = auth.revokeToken(id);
|
const result = auth.revokeToken(id);
|
||||||
|
const actor = req.authContext?.tokenId ?? 'unknown';
|
||||||
if (result === 'not_found') {
|
if (result === 'not_found') {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
|
||||||
}
|
}
|
||||||
if (result === 'last') {
|
if (result === 'last') {
|
||||||
|
recordAudit(db, { actor, action: 'token.revoke', resourceId: id, result: 'denied', details: { reason: 'last_active_token' } });
|
||||||
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
|
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
|
||||||
}
|
}
|
||||||
|
recordAudit(db, { actor, action: 'token.revoke', resourceId: id });
|
||||||
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
|
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
54
packages/server/src/routes/data.ts
Normal file
54
packages/server/src/routes/data.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// RGPD (droits d'accès & d'effacement) : export et suppression des données rattachées au token
|
||||||
|
// authentifié. Modèle mono-utilisateur → le détenteur du token EST le sujet des données. Sous l'auth
|
||||||
|
// globale. La suppression se fait en deux temps (code de confirmation) pour éviter les clics accidentels.
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { DataExportResponse, DeleteMyDataRequest, DeleteMyDataResponse } from '@arboretum/shared';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import type { AuthService } from '../auth/service.js';
|
||||||
|
import { readScanIntervalMin, readScanRoots } from '../core/scan-settings.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
|
export function registerDataRoutes(app: FastifyInstance, db: Db, auth: AuthService): void {
|
||||||
|
app.get('/api/v1/data/export', async (req): Promise<DataExportResponse> => {
|
||||||
|
const current = req.authContext!.tokenId;
|
||||||
|
const tokens = auth.listTokens().map((t) => ({ ...t, current: t.id === current }));
|
||||||
|
const pushSubscriptions = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT endpoint, user_agent AS userAgent, created_at AS createdAt, last_ok_at AS lastOkAt FROM push_subscriptions WHERE token_id = ?',
|
||||||
|
)
|
||||||
|
.all(current) as DataExportResponse['pushSubscriptions'];
|
||||||
|
const sessions = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, cwd, command, title, created_at AS createdAt, ended_at AS endedAt, exit_code AS exitCode FROM sessions ORDER BY created_at',
|
||||||
|
)
|
||||||
|
.all() as DataExportResponse['sessions'];
|
||||||
|
return {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
tokens,
|
||||||
|
pushSubscriptions,
|
||||||
|
sessions,
|
||||||
|
settings: { scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/data/delete-my-data', async (req, reply) => {
|
||||||
|
const current = req.authContext!.tokenId;
|
||||||
|
// code déterministe lié au token (sans état serveur) : renvoyé au 1er appel, exigé au 2nd.
|
||||||
|
const code = createHash('sha256').update(`delete:${current}`).digest('hex').slice(0, 12);
|
||||||
|
const body = (req.body as DeleteMyDataRequest | null) ?? {};
|
||||||
|
const subsCount = (db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE token_id = ?').get(current) as { n: number }).n;
|
||||||
|
|
||||||
|
if (body.confirm !== code) {
|
||||||
|
const res: DeleteMyDataResponse = { status: 'pending', confirm: code, summary: { pushSubscriptions: subsCount, tokenRevoked: false } };
|
||||||
|
return reply.send(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM push_subscriptions WHERE token_id = ?').run(current);
|
||||||
|
// révoque le token courant (sauf si c'est le dernier actif → garde anti lock-out conservée).
|
||||||
|
const revoked = auth.revokeToken(current) === 'ok';
|
||||||
|
recordAudit(db, { actor: current, action: 'data.delete', details: { pushSubscriptions: subsCount, tokenRevoked: revoked } });
|
||||||
|
const res: DeleteMyDataResponse = { status: 'done', summary: { pushSubscriptions: subsCount, tokenRevoked: revoked } };
|
||||||
|
return reply.send(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,14 +2,26 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type {
|
import type {
|
||||||
AddRepoRequest,
|
AddRepoRequest,
|
||||||
CreateGroupRequest,
|
CreateGroupRequest,
|
||||||
|
CreateGroupSessionRequest,
|
||||||
GroupResponse,
|
GroupResponse,
|
||||||
|
GroupSessionResponse,
|
||||||
GroupsListResponse,
|
GroupsListResponse,
|
||||||
UpdateGroupRequest,
|
UpdateGroupRequest,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { GroupManager } from '../core/group-manager.js';
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
import { sendManagerError } from './repos.js';
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void {
|
export function registerGroupRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
gm: GroupManager,
|
||||||
|
db: Db,
|
||||||
|
wt: WorktreeManager,
|
||||||
|
manager: PtyManager,
|
||||||
|
): void {
|
||||||
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||||
|
|
||||||
app.get('/api/v1/groups/:id', async (req, reply) => {
|
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||||
@@ -33,6 +45,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
|||||||
...(body.color !== undefined ? { color: body.color } : {}),
|
...(body.color !== undefined ? { color: body.color } : {}),
|
||||||
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
||||||
});
|
});
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.create', resourceId: group.id, details: { label: group.label } });
|
||||||
return reply.status(201).send({ group } satisfies GroupResponse);
|
return reply.status(201).send({ group } satisfies GroupResponse);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
@@ -48,6 +61,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
|||||||
...(body.description !== undefined ? { description: body.description } : {}),
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
...(body.color !== undefined ? { color: body.color } : {}),
|
...(body.color !== undefined ? { color: body.color } : {}),
|
||||||
});
|
});
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.update', resourceId: id });
|
||||||
return reply.send({ group } satisfies GroupResponse);
|
return reply.send({ group } satisfies GroupResponse);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
@@ -59,6 +73,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
|||||||
if (!gm.deleteGroup(id)) {
|
if (!gm.deleteGroup(id)) {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
||||||
}
|
}
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.delete', resourceId: id });
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,4 +98,62 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
|||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir).
|
||||||
|
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe — le client
|
||||||
|
// ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ;
|
||||||
|
// absent → le worktree principal de chaque repo.
|
||||||
|
app.post('/api/v1/groups/:id/session', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<CreateGroupSessionRequest> | null) ?? {};
|
||||||
|
const command = body.command ?? 'claude';
|
||||||
|
if (command !== 'claude' && command !== 'bash') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||||
|
}
|
||||||
|
const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null;
|
||||||
|
|
||||||
|
let group;
|
||||||
|
try {
|
||||||
|
group = gm.getGroup(id);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
const skipped: Array<{ repoId: string; reason: string }> = [];
|
||||||
|
for (const repoId of group.repoIds) {
|
||||||
|
let worktrees;
|
||||||
|
try {
|
||||||
|
worktrees = await wt.listRepoWorktrees(repoId);
|
||||||
|
} catch (err) {
|
||||||
|
skipped.push({ repoId, reason: err instanceof Error ? err.message : String(err) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = branch ? worktrees.find((w) => w.branch === branch) : worktrees.find((w) => w.isMain);
|
||||||
|
if (!match) {
|
||||||
|
skipped.push({ repoId, reason: branch ? `no worktree on branch ${branch}` : 'no main worktree' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!dirs.includes(match.path)) dirs.push(match.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, ...rest] = dirs;
|
||||||
|
if (primary === undefined) {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id });
|
||||||
|
recordAudit(db, {
|
||||||
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
|
action: 'group.session.create',
|
||||||
|
resourceId: id,
|
||||||
|
details: { command, dirs: dirs.length, ...(branch ? { branch } : {}) },
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ session, dirs, skipped } satisfies GroupSessionResponse);
|
||||||
|
} catch (err) {
|
||||||
|
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||||
|
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
||||||
import type { PushService } from '../core/push-service.js';
|
import type { PushService } from '../core/push-service.js';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
export function registerPushRoutes(app: FastifyInstance, push: PushService): void {
|
export function registerPushRoutes(app: FastifyInstance, push: PushService, db: Db): void {
|
||||||
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
||||||
|
|
||||||
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
||||||
@@ -15,6 +17,7 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService): voi
|
|||||||
// garanti non-null : la route est protégée par le preValidation global.
|
// garanti non-null : la route est protégée par le preValidation global.
|
||||||
const tokenId = req.authContext!.tokenId;
|
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);
|
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
|
||||||
|
recordAudit(db, { actor: tokenId, action: 'push.subscribe' });
|
||||||
return reply.status(201).send({ ok: true });
|
return reply.status(201).send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService): voi
|
|||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
||||||
}
|
}
|
||||||
push.unsubscribe(body.endpoint);
|
push.unsubscribe(body.endpoint);
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'push.unsubscribe' });
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id } });
|
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
|
||||||
|
const ctx = manager.groupSessionContext(id);
|
||||||
|
const session = manager.spawn({
|
||||||
|
cwd: discovered.cwd,
|
||||||
|
resume: { claudeSessionId: id },
|
||||||
|
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
|
||||||
|
...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
|
||||||
|
});
|
||||||
const res: SessionResponse = { session };
|
const res: SessionResponse = { session };
|
||||||
return reply.status(201).send(res);
|
return reply.status(201).send(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
import { type Db, setSetting } from '../db/index.js';
|
||||||
import type { PushService } from '../core/push-service.js';
|
import type { PushService } from '../core/push-service.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
import {
|
import {
|
||||||
SCAN_INTERVAL_KEY,
|
SCAN_INTERVAL_KEY,
|
||||||
SCAN_ROOTS_KEY,
|
SCAN_ROOTS_KEY,
|
||||||
@@ -15,23 +16,7 @@ import {
|
|||||||
readScanRoots,
|
readScanRoots,
|
||||||
} from '../core/scan-settings.js';
|
} from '../core/scan-settings.js';
|
||||||
|
|
||||||
// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||||
const GITEA_URL_KEY = 'gitea_url';
|
|
||||||
|
|
||||||
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
|
|
||||||
function normalizeGiteaUrl(raw: string): string | null {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (trimmed === '') return null;
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(trimmed);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSettingsRoutes(
|
export function registerSettingsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -39,8 +24,6 @@ export function registerSettingsRoutes(
|
|||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
push: PushService,
|
push: PushService,
|
||||||
): void {
|
): void {
|
||||||
// '' (effacé) est normalisé en null côté réponse.
|
|
||||||
const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null;
|
|
||||||
const serverInfo = (): ServerInfo => ({
|
const serverInfo = (): ServerInfo => ({
|
||||||
version: serverVersion,
|
version: serverVersion,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
@@ -52,7 +35,6 @@ export function registerSettingsRoutes(
|
|||||||
});
|
});
|
||||||
const snapshot = (): SettingsResponse => ({
|
const snapshot = (): SettingsResponse => ({
|
||||||
settings: {
|
settings: {
|
||||||
giteaUrl: readGiteaUrl(),
|
|
||||||
scanRoots: readScanRoots(db),
|
scanRoots: readScanRoots(db),
|
||||||
scanIntervalMin: readScanIntervalMin(db),
|
scanIntervalMin: readScanIntervalMin(db),
|
||||||
},
|
},
|
||||||
@@ -63,20 +45,6 @@ export function registerSettingsRoutes(
|
|||||||
|
|
||||||
app.patch('/api/v1/settings', async (req, reply) => {
|
app.patch('/api/v1/settings', async (req, reply) => {
|
||||||
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {};
|
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {};
|
||||||
if ('giteaUrl' in body) {
|
|
||||||
const v = body.giteaUrl;
|
|
||||||
if (v === null || v === '') {
|
|
||||||
setSetting(db, GITEA_URL_KEY, ''); // effacement
|
|
||||||
} else if (typeof v === 'string') {
|
|
||||||
const normalized = normalizeGiteaUrl(v);
|
|
||||||
if (!normalized) {
|
|
||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a valid http(s) URL' } });
|
|
||||||
}
|
|
||||||
setSetting(db, GITEA_URL_KEY, normalized);
|
|
||||||
} else {
|
|
||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ('scanRoots' in body) {
|
if ('scanRoots' in body) {
|
||||||
const roots = normalizeScanRoots(body.scanRoots);
|
const roots = normalizeScanRoots(body.scanRoots);
|
||||||
if (!roots) {
|
if (!roots) {
|
||||||
@@ -91,6 +59,11 @@ export function registerSettingsRoutes(
|
|||||||
}
|
}
|
||||||
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
||||||
}
|
}
|
||||||
|
recordAudit(db, {
|
||||||
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
|
action: 'settings.update',
|
||||||
|
details: { keys: Object.keys(body) },
|
||||||
|
});
|
||||||
return reply.send(snapshot());
|
return reply.send(snapshot());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type {
|
|||||||
AdoptWorktreeRequest,
|
AdoptWorktreeRequest,
|
||||||
CreateWorktreeRequest,
|
CreateWorktreeRequest,
|
||||||
CreateWorktreeResponse,
|
CreateWorktreeResponse,
|
||||||
|
SessionResponse,
|
||||||
|
StartRepoSessionRequest,
|
||||||
WorktreeResponse,
|
WorktreeResponse,
|
||||||
WorktreesListResponse,
|
WorktreesListResponse,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
@@ -43,6 +45,33 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session sur le checkout principal (« bosser sur la branche principale » sans worktree),
|
||||||
|
// avec création/bascule de branche optionnelle côté serveur.
|
||||||
|
app.post('/api/v1/repos/:id/session', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<StartRepoSessionRequest> | null) ?? {};
|
||||||
|
if (body.command != null && body.command !== 'claude' && body.command !== 'bash') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||||
|
}
|
||||||
|
if (body.branch != null && typeof body.branch !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch must be a string' } });
|
||||||
|
}
|
||||||
|
if (body.newBranch != null && typeof body.newBranch !== 'boolean') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'newBranch must be a boolean' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await wt.startMainSession(id, {
|
||||||
|
...(body.command !== undefined ? { command: body.command } : {}),
|
||||||
|
...(body.branch !== undefined ? { branch: body.branch } : {}),
|
||||||
|
...(body.newBranch !== undefined ? { newBranch: body.newBranch } : {}),
|
||||||
|
});
|
||||||
|
const res: SessionResponse = { session: out.session };
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => {
|
app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const body = req.body as Partial<AdoptWorktreeRequest> | null;
|
const body = req.body as Partial<AdoptWorktreeRequest> | null;
|
||||||
|
|||||||
164
packages/server/test/audit-data-routes.test.ts
Normal file
164
packages/server/test/audit-data-routes.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Lot 5 — sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression),
|
||||||
|
// garde Content-Type. Vérifie le câblage de bout en bout via buildApp + token bootstrap.
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildApp, type AppBundle } from '../src/app.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
import type { Config } from '../src/config.js';
|
||||||
|
import type { AuditLogsResponse, DataExportResponse, DeleteMyDataResponse } from '@arboretum/shared';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
|
class FakePty {
|
||||||
|
pid = 424242;
|
||||||
|
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() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.ARBORETUM_LOG = 'silent';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let bundle: AppBundle;
|
||||||
|
let db: Db;
|
||||||
|
let token: string;
|
||||||
|
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arboretum-audit-'));
|
||||||
|
const dbPath = join(dir, 'audit.db');
|
||||||
|
db = openDb(dbPath);
|
||||||
|
const config: Config = {
|
||||||
|
port: 9998,
|
||||||
|
bind: '127.0.0.1',
|
||||||
|
dbPath,
|
||||||
|
dataDir: dir,
|
||||||
|
allowedOrigins: ['https://host.tailnet.ts.net'],
|
||||||
|
printToken: false,
|
||||||
|
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
||||||
|
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
||||||
|
vapidContact: 'mailto:test@localhost',
|
||||||
|
autoDiscover: false,
|
||||||
|
};
|
||||||
|
bundle = buildApp(config, db, '1.2.3-test');
|
||||||
|
const t = bundle.auth.ensureBootstrapToken();
|
||||||
|
if (!t) throw new Error('bootstrap token attendu');
|
||||||
|
token = t;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await bundle.app.close();
|
||||||
|
db.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('En-têtes de sécurité', () => {
|
||||||
|
it('pose les en-têtes durs + no-store sur /api, sans header Server', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(res.headers['x-frame-options']).toBe('DENY');
|
||||||
|
expect(res.headers['referrer-policy']).toBe('no-referrer');
|
||||||
|
expect(res.headers['content-security-policy']).toContain("default-src 'self'");
|
||||||
|
expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
|
||||||
|
expect(String(res.headers['cache-control'])).toContain('no-store');
|
||||||
|
expect(res.headers['server']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pose HSTS uniquement derrière HTTPS (x-forwarded-proto)', async () => {
|
||||||
|
const http = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
|
||||||
|
expect(http.headers['strict-transport-security']).toBeUndefined();
|
||||||
|
const https = await bundle.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { ...auth(), 'x-forwarded-proto': 'https' },
|
||||||
|
});
|
||||||
|
expect(String(https.headers['strict-transport-security'])).toContain('max-age=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette une mutation avec corps non-JSON (415)', async () => {
|
||||||
|
const res = await bundle.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/tokens',
|
||||||
|
headers: { ...auth(), 'content-type': 'text/plain' },
|
||||||
|
payload: 'label=pwned',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(415);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Journal d’audit', () => {
|
||||||
|
it('journalise la création de token et l’expose via GET /audit-logs', async () => {
|
||||||
|
const create = await bundle.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/tokens',
|
||||||
|
headers: auth(),
|
||||||
|
payload: { label: 'ci-extra' },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs', headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as AuditLogsResponse;
|
||||||
|
const actions = body.entries.map((e) => e.action);
|
||||||
|
expect(actions).toContain('token.create');
|
||||||
|
expect(actions).toContain('secret.generate'); // généré au bootstrap (system)
|
||||||
|
// aucune VALEUR secrète en clair : pas de chaîne hex de 64 caractères (server_secret / clé).
|
||||||
|
// (les ids sont des UUID avec tirets → non concernés ; 'server_secret' est un nom de ressource.)
|
||||||
|
expect(res.body).not.toMatch(/[a-f0-9]{64}/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('journalise les échecs de login (actor anonymous)', async () => {
|
||||||
|
await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_invalid_xxx' } });
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs?limit=200', headers: auth() });
|
||||||
|
const body = res.json() as AuditLogsResponse;
|
||||||
|
expect(body.entries.some((e) => e.action === 'login.failure' && e.actor === 'anonymous')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RGPD', () => {
|
||||||
|
it('exporte les données du token authentifié', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/data/export', headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as DataExportResponse;
|
||||||
|
expect(Array.isArray(body.tokens)).toBe(true);
|
||||||
|
expect(body.tokens.some((t) => t.current)).toBe(true);
|
||||||
|
expect(Array.isArray(body.pushSubscriptions)).toBe(true);
|
||||||
|
expect(Array.isArray(body.sessions)).toBe(true);
|
||||||
|
expect(body.settings).toHaveProperty('scanRoots');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supprime en deux temps (pending → confirm → done) et révoque le token', async () => {
|
||||||
|
const pending = await bundle.app.inject({ method: 'POST', url: '/api/v1/data/delete-my-data', headers: auth(), payload: {} });
|
||||||
|
const p = pending.json() as DeleteMyDataResponse;
|
||||||
|
expect(p.status).toBe('pending');
|
||||||
|
expect(p.confirm).toBeTruthy();
|
||||||
|
|
||||||
|
// un 2e token existe (créé plus haut) → révoquer le token courant est autorisé.
|
||||||
|
const done = await bundle.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/data/delete-my-data',
|
||||||
|
headers: auth(),
|
||||||
|
payload: { confirm: p.confirm },
|
||||||
|
});
|
||||||
|
const d = done.json() as DeleteMyDataResponse;
|
||||||
|
expect(d.status).toBe('done');
|
||||||
|
expect(d.summary.tokenRevoked).toBe(true);
|
||||||
|
|
||||||
|
// le token courant est désormais révoqué → 401.
|
||||||
|
const after = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
|
||||||
|
expect(after.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/server/test/claude-launcher.test.ts
Normal file
29
packages/server/test/claude-launcher.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
|
||||||
|
|
||||||
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
|
||||||
|
describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
|
||||||
|
it('émet un --add-dir par répertoire supplémentaire (claude)', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'claude', addDirs: ['/a', '/b', '/c'] });
|
||||||
|
expect(spec.file).toBe('/usr/bin/claude');
|
||||||
|
expect(spec.args).toEqual(['--add-dir', '/a', '--add-dir', '/b', '--add-dir', '/c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combine --resume et --add-dir (reprise d’une session de groupe)', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'claude', resume: { claudeSessionId: 'sid' }, addDirs: ['/x'] });
|
||||||
|
expect(spec.args).toEqual(['--resume', 'sid', '--add-dir', '/x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aucun --add-dir quand addDirs est vide/absent', () => {
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).args).toEqual([]);
|
||||||
|
expect(buildSpawnSpec({ command: 'claude', addDirs: [] }).args).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore addDirs pour bash (pas de --add-dir)', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'bash', addDirs: ['/a', '/b'] });
|
||||||
|
expect(spec.file).toBe('bash');
|
||||||
|
expect(spec.args).toEqual(['--norc']);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/server/test/fixtures/dialogs/ask2.raw
vendored
14
packages/server/test/fixtures/dialogs/ask2.raw
vendored
@@ -19,7 +19,7 @@
|
|||||||
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
||||||
|
|
||||||
|
|
||||||
@@ -321,13 +321,13 @@
|
|||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[2C[6A[38;2;255;183;101mSim[8G[38;2;255;153;51mrin[14G[38;2;153;153;153m(2s · [38;2;177;177;177mthinking with xhigh effort[38;2;153;153;153m)[39m
|
[2C[6A[38;2;255;183;101mSim[8G[38;2;255;153;51mrin[14G[38;2;153;153;153m(2s · [38;2;177;177;177mthinking with xhigh effort[38;2;153;153;153m)[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[19C[6A[38;2;173;173;173mthinking with xhigh effort[39m
|
[19C[6A[38;2;173;173;173mthinking with xhigh effort[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[19C[6A[38;2;161;161;161mthinking with xhigh effort[39m
|
[19C[6A[38;2;161;161;161mthinking with xhigh effort[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[21C[6A[38;2;153;153;153m57[34Gthinking with xhigh effort[39m
|
[21C[6A[38;2;153;153;153m57[34Gthinking with xhigh effort[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -566,7 +566,7 @@
|
|||||||
|
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G96%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[4C[6A[38;2;255;153;51mn[8G[38;2;255;183;101mn[39m
|
[4C[6A[38;2;255;153;51mn[8G[38;2;255;183;101mn[39m
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
||||||
|
|
||||||
|
|
||||||
@@ -475,7 +475,7 @@
|
|||||||
[14C[6A[38;2;153;153;153m4[35Gthought for 1s)[39m[K
|
[14C[6A[38;2;153;153;153m4[35Gthought for 1s)[39m[K
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
||||||
|
|
||||||
|
|
||||||
@@ -218,13 +218,13 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[6A[38;2;255;153;51m✻[39m
|
[6A[38;2;255;153;51m✻[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[6A[38;2;255;153;51m✽[39m
|
[6A[38;2;255;153;51m✽[39m
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[12C[6A[38;2;153;153;153m(1s · thinking with xhigh effort)[39m
|
[12C[6A[38;2;153;153;153m(1s · thinking with xhigh effort)[39m
|
||||||
|
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[5C[6A[38;2;255;183;101mo[9G[38;2;255;153;51mn[14G[38;2;153;153;153m2[19G[38;2;169;169;169mthinking with xhigh effort[39m
|
[5C[6A[38;2;255;183;101mo[9G[38;2;255;153;51mn[14G[38;2;153;153;153m2[19G[38;2;169;169;169mthinking with xhigh effort[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
[2B[38;2;153;153;153m❯ [39m[K
|
[2B[38;2;153;153;153m❯ [39m[K
|
||||||
|
|
||||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m
|
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m
|
||||||
|
|
||||||
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m
|
[3G[38;2;153;153;153mesc[7Gto[10Ginterrupt[21G[38;2;255;204;0mYou've[28Gused[33G91%[37Gof[40Gyour[45Gsession[53Glimit[59G·[61Gresets[68G7pm[72G(America/Lima)[87G·[89G/usage-credits[104Gto[107Grequest[115Gmore[39m
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
@@ -430,16 +430,16 @@
|
|||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[6A[38;2;255;153;51m*[10Gng[39m
|
[6A[38;2;255;153;51m*[10Gng[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
[2C[3A[?25h[?2026l[?2026h[?25l[2D[3B
|
||||||
[11C[6A[38;2;255;153;51m…[39m
|
[11C[6A[38;2;255;153;51m…[39m
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
[2GClaude[9GCode'll[17Gbe[20Gable[25Gto[28Gread,[34Gedit,[40Gand[44Gexecute[52Gfiles[58Ghere.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
[2G]8;id=zaxmda;https://code.claude.com/docs/en/security[38;2;153;153;153mSecurity guide[39m]8;;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
addWorktree,
|
addWorktree,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
pruneWorktrees,
|
pruneWorktrees,
|
||||||
|
switchBranch,
|
||||||
isDirtyWorktreeError,
|
isDirtyWorktreeError,
|
||||||
} from '../src/core/git.js';
|
} from '../src/core/git.js';
|
||||||
|
|
||||||
@@ -118,6 +119,20 @@ describe('opérations git (repo tmp réel)', () => {
|
|||||||
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('switchBranch : create=true crée une branche, create=false bascule sur une existante', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const cur = (): string => execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim();
|
||||||
|
|
||||||
|
await switchBranch(repo, { branch: 'feature/new', create: true });
|
||||||
|
expect(cur()).toBe('feature/new');
|
||||||
|
|
||||||
|
await switchBranch(repo, { branch: 'main', create: false });
|
||||||
|
expect(cur()).toBe('main');
|
||||||
|
|
||||||
|
// créer une branche déjà existante échoue (git refuse).
|
||||||
|
await expect(switchBranch(repo, { branch: 'feature/new', create: true })).rejects.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('prune retire un worktree dont le dossier a disparu', async () => {
|
it('prune retire un worktree dont le dossier a disparu', async () => {
|
||||||
const repo = makeTmpRepo();
|
const repo = makeTmpRepo();
|
||||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
|
||||||
|
|||||||
@@ -97,4 +97,15 @@ describe('GroupManager', () => {
|
|||||||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A' });
|
||||||
|
// session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn).
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id);
|
||||||
|
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||||
|
const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null };
|
||||||
|
expect(row.group_id).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object
|
|||||||
describe('munge', () => {
|
describe('munge', () => {
|
||||||
it('reproduit le nom de dossier ~/.claude/projects', () => {
|
it('reproduit le nom de dossier ~/.claude/projects', () => {
|
||||||
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
|
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
|
||||||
expect(munge('/home/johan/WebstormProjects/arboretum')).toBe('-home-johan-WebstormProjects-arboretum');
|
expect(munge('/home/user/projects/demo')).toBe('-home-user-projects-demo');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -574,4 +574,53 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session de groupe multi-repo (P6)', () => {
|
||||||
|
it('spawn avec addDirs : --add-dir au pty, addedDirs + groupId résumés et persistés', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-g1-'));
|
||||||
|
const d2 = mkdtempSync(join(tmpdir(), 'arb-g2-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d2], groupId: 'grp1' });
|
||||||
|
expect(lastPty().args).toEqual(['--add-dir', d1, '--add-dir', d2]);
|
||||||
|
expect(summary.addedDirs).toEqual([d1, d2]);
|
||||||
|
expect(summary.groupId).toBe('grp1');
|
||||||
|
const row = db.prepare('SELECT added_dirs, group_id FROM sessions WHERE id = ?').get(summary.id) as {
|
||||||
|
added_dirs: string | null;
|
||||||
|
group_id: string | null;
|
||||||
|
};
|
||||||
|
expect(JSON.parse(row.added_dirs as string)).toEqual([d1, d2]);
|
||||||
|
expect(row.group_id).toBe('grp1');
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
rmSync(d2, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dédoublonne et écarte le cwd primaire des addDirs', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-g4-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d1, cwd] });
|
||||||
|
expect(summary.addedDirs).toEqual([d1]);
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un addDir inexistant (400)', () => {
|
||||||
|
expect(() => manager.spawn({ cwd, command: 'bash', addDirs: ['/no/such/dir/xyz-arb'] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groupSessionContext renvoie les addedDirs/groupId persistés (resume)', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-g5-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpX' });
|
||||||
|
// simule la capture du claudeSessionId (normalement résolue via le registre)
|
||||||
|
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-1', summary.id);
|
||||||
|
expect(manager.groupSessionContext('cs-1')).toEqual({ addedDirs: [d1], groupId: 'grpX' });
|
||||||
|
expect(manager.groupSessionContext('unknown')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ describe('scanForRepos', () => {
|
|||||||
expect(paths).toEqual([join(root, 'a')]);
|
expect(paths).toEqual([join(root, 'a')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('descend dans une racine qui est elle-même un repo (workspace + sous-repos)', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
makeRepo(root); // la racine fournie contient elle-même un .git (cas WebstormProjects)
|
||||||
|
makeRepo(root, 'a');
|
||||||
|
makeRepo(root, 'b');
|
||||||
|
const { paths } = await scanForRepos([root], limits);
|
||||||
|
// la racine ET ses dépôts internes sont découverts (la racine n'arrête pas le scan)
|
||||||
|
expect([...paths].sort()).toEqual([root, join(root, 'a'), join(root, 'b')].sort());
|
||||||
|
});
|
||||||
|
|
||||||
it('ignore node_modules et les dotdirs', async () => {
|
it('ignore node_modules et les dotdirs', async () => {
|
||||||
const root = tmpRoot();
|
const root = tmpRoot();
|
||||||
makeRepo(root, 'node_modules', 'pkg');
|
makeRepo(root, 'node_modules', 'pkg');
|
||||||
|
|||||||
37
packages/server/test/secret-box.test.ts
Normal file
37
packages/server/test/secret-box.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { SecretBox } from '../src/core/secret-box.js';
|
||||||
|
|
||||||
|
const box = (): SecretBox => new SecretBox(randomBytes(32));
|
||||||
|
|
||||||
|
describe('SecretBox', () => {
|
||||||
|
it('chiffre puis déchiffre (round-trip)', () => {
|
||||||
|
const b = box();
|
||||||
|
const secret = 'deadbeef'.repeat(8);
|
||||||
|
const enc = b.encrypt(secret);
|
||||||
|
expect(enc.startsWith('v1:')).toBe(true);
|
||||||
|
expect(enc).not.toContain(secret); // le clair n'apparaît pas
|
||||||
|
expect(b.decrypt(enc)).toBe(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produit un chiffré différent à chaque fois (IV aléatoire)', () => {
|
||||||
|
const b = box();
|
||||||
|
expect(b.encrypt('x')).not.toBe(b.encrypt('x'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne tel quel une valeur en clair (legacy) et la détecte', () => {
|
||||||
|
const b = box();
|
||||||
|
expect(b.isEncrypted('plain-secret')).toBe(false);
|
||||||
|
expect(b.decrypt('plain-secret')).toBe('plain-secret');
|
||||||
|
expect(b.isEncrypted(b.encrypt('x'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échoue si la clé ne correspond pas (GCM authentifié)', () => {
|
||||||
|
const enc = box().encrypt('secret');
|
||||||
|
expect(() => box().decrypt(enc)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échoue sur un format chiffré corrompu', () => {
|
||||||
|
expect(() => box().decrypt('v1:zzz')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que
|
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que
|
||||||
// l'allow-list et valide l'URL Gitea (http/https only, anti-XSS).
|
// l'allow-list (découverte des dépôts) et ignore toute clé hors allow-list.
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -65,7 +65,7 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/v1/settings', () => {
|
describe('GET /api/v1/settings', () => {
|
||||||
it('renvoie la config serveur non sensible et giteaUrl null par défaut', async () => {
|
it('renvoie la config serveur non sensible et aucune racine de scan par défaut', async () => {
|
||||||
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = res.json() as SettingsResponse;
|
const body = res.json() as SettingsResponse;
|
||||||
@@ -74,10 +74,8 @@ describe('GET /api/v1/settings', () => {
|
|||||||
expect(body.server.bind).toBe('127.0.0.1');
|
expect(body.server.bind).toBe('127.0.0.1');
|
||||||
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
|
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
|
||||||
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
|
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
|
||||||
expect(body.settings.giteaUrl).toBeNull();
|
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
|
||||||
// défauts de découverte : home (1 racine) + intervalle 5 min
|
expect(body.settings.scanRoots).toEqual([]);
|
||||||
expect(Array.isArray(body.settings.scanRoots)).toBe(true);
|
|
||||||
expect(body.settings.scanRoots).toHaveLength(1);
|
|
||||||
expect(body.settings.scanIntervalMin).toBe(5);
|
expect(body.settings.scanIntervalMin).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,29 +91,7 @@ describe('GET /api/v1/settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /api/v1/settings', () => {
|
describe('PATCH /api/v1/settings — sécurité', () => {
|
||||||
it('enregistre une URL Gitea valide et la renvoie', async () => {
|
|
||||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } });
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect((res.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/');
|
|
||||||
// persisté
|
|
||||||
const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
|
|
||||||
expect((get.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('efface l’URL avec null ou chaîne vide', async () => {
|
|
||||||
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } });
|
|
||||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: null } });
|
|
||||||
expect((res.json() as SettingsResponse).settings.giteaUrl).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejette une URL non http(s) — anti-XSS (400)', async () => {
|
|
||||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'javascript:alert(1)' } });
|
|
||||||
expect(res.statusCode).toBe(400);
|
|
||||||
const notUrl = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'pas une url' } });
|
|
||||||
expect(notUrl.statusCode).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
|
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
|
||||||
const before = getSetting(db, 'server_secret');
|
const before = getSetting(db, 'server_secret');
|
||||||
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
|
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
|
||||||
@@ -124,7 +100,7 @@ describe('PATCH /api/v1/settings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sans authentification → 401', async () => {
|
it('sans authentification → 401', async () => {
|
||||||
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { giteaUrl: 'https://x.example' } });
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { scanRoots: ['/home/u/work'] } });
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,6 +116,46 @@ describe('WorktreeManager', () => {
|
|||||||
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const out = await wt.startMainSession(r.id, { command: 'bash' });
|
||||||
|
expect(resolve(out.session.cwd)).toBe(resolve(repo));
|
||||||
|
expect(out.session).toMatchObject({ command: 'bash', live: true });
|
||||||
|
// pas de bascule de branche → toujours sur main.
|
||||||
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
|
||||||
|
const main = (await wt.listRepoWorktrees(r.id, true)).find((w) => w.isMain);
|
||||||
|
expect(main?.sessions.some((s) => s.id === out.session.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startMainSession : newBranch crée et bascule la branche dans le checkout principal + event', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const events: Array<{ worktree: WorktreeSummary }> = [];
|
||||||
|
wt.on('worktree_update', (e) => events.push(e));
|
||||||
|
const out = await wt.startMainSession(r.id, { command: 'bash', branch: 'feature/x', newBranch: true });
|
||||||
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('feature/x');
|
||||||
|
expect(out.worktree?.branch).toBe('feature/x');
|
||||||
|
expect(events.some((e) => e.worktree.isMain && e.worktree.branch === 'feature/x')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startMainSession : checkout principal sale → 409 DIRTY_TREE (pas de bascule)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
writeFileSync(join(repo, 'scratch.txt'), 'wip\n'); // arbre sale
|
||||||
|
await expect(wt.startMainSession(r.id, { command: 'bash', branch: 'feature/y', newBranch: true })).rejects.toMatchObject({
|
||||||
|
statusCode: 409,
|
||||||
|
code: 'DIRTY_TREE',
|
||||||
|
});
|
||||||
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startMainSession : branche invalide → 400', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.startMainSession(r.id, { command: 'bash', branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => {
|
it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => {
|
||||||
const repo = makeTmpRepo();
|
const repo = makeTmpRepo();
|
||||||
const r = await wt.addRepo({ path: repo });
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
|||||||
@@ -128,6 +128,20 @@ export interface AdoptWorktreeRequest {
|
|||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/repos/:id/session — lance UNE session dans le checkout principal du repo
|
||||||
|
* (`repo.path`) pour « bosser sur la branche principale » sans créer de worktree.
|
||||||
|
* Réponse : `SessionResponse`.
|
||||||
|
*/
|
||||||
|
export interface StartRepoSessionRequest {
|
||||||
|
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests sans quota. */
|
||||||
|
command?: 'claude' | 'bash';
|
||||||
|
/** si fourni : crée/bascule cette branche dans le checkout principal avant la session. */
|
||||||
|
branch?: string;
|
||||||
|
/** true (défaut quand `branch` fourni) : `git switch -c` ; false : bascule sur une branche existante. */
|
||||||
|
newBranch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Groupes de travail (P5) ----
|
// ---- Groupes de travail (P5) ----
|
||||||
export interface GroupsListResponse {
|
export interface GroupsListResponse {
|
||||||
groups: GroupSummary[];
|
groups: GroupSummary[];
|
||||||
@@ -152,6 +166,22 @@ export interface AddRepoRequest {
|
|||||||
repoId: string;
|
repoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Session de groupe multi-repo (P6) ----
|
||||||
|
/** Lance UNE session Claude couvrant tous les repos du groupe (via `--add-dir`). */
|
||||||
|
export interface CreateGroupSessionRequest {
|
||||||
|
/** défaut : claude. */
|
||||||
|
command?: 'claude' | 'bash';
|
||||||
|
/** présent → couvre le worktree de cette branche dans chaque repo ; absent → les checkouts principaux. */
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
export interface GroupSessionResponse {
|
||||||
|
session: SessionSummary;
|
||||||
|
/** répertoires effectivement couverts par la session (cwd primaire en tête). */
|
||||||
|
dirs: string[];
|
||||||
|
/** repos du groupe pour lesquels aucun worktree n'a pu être résolu. */
|
||||||
|
skipped: Array<{ repoId: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||||
export interface FsEntry {
|
export interface FsEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -199,8 +229,7 @@ export interface ServerInfo {
|
|||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
||||||
settings: {
|
settings: {
|
||||||
giteaUrl: string | null;
|
/** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */
|
||||||
/** racines absolues scannées pour la découverte auto des repos (défaut : home). */
|
|
||||||
scanRoots: string[];
|
scanRoots: string[];
|
||||||
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
||||||
scanIntervalMin: number;
|
scanIntervalMin: number;
|
||||||
@@ -208,10 +237,48 @@ export interface SettingsResponse {
|
|||||||
server: ServerInfo;
|
server: ServerInfo;
|
||||||
}
|
}
|
||||||
export interface UpdateSettingsRequest {
|
export interface UpdateSettingsRequest {
|
||||||
/** URL de l'instance Gitea (http/https) ; null ou '' pour effacer. */
|
|
||||||
giteaUrl?: string | null;
|
|
||||||
/** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */
|
/** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */
|
||||||
scanRoots?: string[];
|
scanRoots?: string[];
|
||||||
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
||||||
scanIntervalMin?: number;
|
scanIntervalMin?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Journal d'audit (conformité entreprise) ----
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
/** horodatage ISO 8601 */
|
||||||
|
ts: string;
|
||||||
|
/** tokenId de l'acteur, 'system' (auto) ou 'anonymous' (avant auth) */
|
||||||
|
actor: string;
|
||||||
|
/** verbe.objet, ex. 'token.create', 'settings.update' */
|
||||||
|
action: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
/** métadonnées non sensibles (jamais de secret) */
|
||||||
|
details: unknown;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
export interface AuditLogsResponse {
|
||||||
|
entries: AuditLogEntry[];
|
||||||
|
/** curseur de pagination (ts à passer en `before`) ; null si fin de liste. */
|
||||||
|
nextBefore: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RGPD : export / suppression des données liées au token authentifié ----
|
||||||
|
export interface DataExportResponse {
|
||||||
|
exportedAt: string;
|
||||||
|
tokens: Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null; current: boolean }>;
|
||||||
|
pushSubscriptions: Array<{ endpoint: string; userAgent: string | null; createdAt: string; lastOkAt: string | null }>;
|
||||||
|
sessions: Array<{ id: string; cwd: string; command: string; title: string | null; createdAt: string; endedAt: string | null; exitCode: number | null }>;
|
||||||
|
settings: { scanRoots: string[]; scanIntervalMin: number };
|
||||||
|
}
|
||||||
|
export interface DeleteMyDataRequest {
|
||||||
|
/** code de confirmation renvoyé par un premier appel sans `confirm` ; doit être renvoyé pour exécuter. */
|
||||||
|
confirm?: string;
|
||||||
|
}
|
||||||
|
export interface DeleteMyDataResponse {
|
||||||
|
/** 'pending' = confirmation requise (renvoie `confirm`) ; 'done' = suppression effectuée. */
|
||||||
|
status: 'pending' | 'done';
|
||||||
|
confirm?: string;
|
||||||
|
/** récapitulatif de ce qui sera/a été supprimé. */
|
||||||
|
summary: { pushSubscriptions: number; tokenRevoked: boolean };
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export interface SessionSummary {
|
|||||||
waitingFor?: string | null;
|
waitingFor?: string | null;
|
||||||
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
||||||
dialog?: SessionDialog | null;
|
dialog?: SessionDialog | null;
|
||||||
|
// ---- P6 : session de groupe multi-repo (additif) ----
|
||||||
|
/** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */
|
||||||
|
addedDirs?: string[];
|
||||||
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||||
|
groupId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Worktrees & repos (P3) ----
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
|||||||
53
packages/site/index.html
Normal file
53
packages/site/index.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" style="background-color: #09090b">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
|
||||||
|
<title>Arboretum — Mission control for your AI coding agents</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Arboretum is a local-first daemon you launch with npx that serves a web dashboard to run and supervise many Claude Code sessions across git worktrees — from any device, even your phone."
|
||||||
|
/>
|
||||||
|
<link rel="canonical" href="https://git-arboretum.com/" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Twitter -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://git-arboretum.com/" />
|
||||||
|
<meta property="og:title" content="Arboretum — Mission control for your AI coding agents" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Run and supervise many Claude Code sessions across every git worktree — from any device, even your phone."
|
||||||
|
/>
|
||||||
|
<meta property="og:site_name" content="Arboretum" />
|
||||||
|
<meta property="og:image" content="https://git-arboretum.com/assets/og-cover.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Arboretum — mission control for your AI coding agents" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Arboretum — Mission control for your AI coding agents" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Run and supervise many Claude Code sessions across every git worktree — from any device, even your phone."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://git-arboretum.com/assets/og-cover.png" />
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<!-- Polices JetBrains Mono self-host via @fontsource (importées dans src/main.ts). -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<noscript>
|
||||||
|
<div style="padding: 24px; font-family: system-ui, sans-serif; color: #f4f4f5; text-align: center">
|
||||||
|
Arboretum — mission control for your AI coding agents.
|
||||||
|
<a href="https://git.lidge.fr/johanleroy/arboretum" style="color: #34d399">View on Gitea</a>.
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
packages/site/package.json
Normal file
24
packages/site/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@arboretum/site",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.38",
|
||||||
|
"vue-i18n": "^11.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource/jetbrains-mono": "^5.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vue-tsc": "^3.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/site/public/.htaccess
Normal file
16
packages/site/public/.htaccess
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# git-arboretum.com — site vitrine statique (Vue 3 / Vite).
|
||||||
|
#
|
||||||
|
# Google PageSpeed (mod_pagespeed) renvoie un corps HTML VIDE sur ce domaine Plesk
|
||||||
|
# (la home répond 200 mais 0 octet → page blanche), alors que les assets statiques
|
||||||
|
# passent. Les bundles Vite sont déjà minifiés, hashés et gzippés : PageSpeed
|
||||||
|
# n'apporte rien ici et casse le rendu. On le désactive pour ce vhost.
|
||||||
|
#
|
||||||
|
# NB : si l'hébergement interdit ces directives en .htaccess (AllowOverride
|
||||||
|
# restrictif), Apache renverra une 500 → dans ce cas, supprimer ce fichier et
|
||||||
|
# désactiver PageSpeed depuis Plesk (Apache & nginx Settings).
|
||||||
|
<IfModule pagespeed_module>
|
||||||
|
ModPagespeed off
|
||||||
|
</IfModule>
|
||||||
|
<IfModule ngx_pagespeed_module>
|
||||||
|
pagespeed off;
|
||||||
|
</IfModule>
|
||||||
BIN
packages/site/public/apple-touch-icon.png
Normal file
BIN
packages/site/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
packages/site/public/assets/arboretum-mark.png
Normal file
BIN
packages/site/public/assets/arboretum-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
packages/site/public/assets/og-cover.png
Normal file
BIN
packages/site/public/assets/og-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
packages/site/public/favicon.ico
Normal file
BIN
packages/site/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
46
packages/site/public/icon.svg
Normal file
46
packages/site/public/icon.svg
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Arboretum">
|
||||||
|
<defs>
|
||||||
|
<!-- glow néon : flou doux derrière les traits et les nœuds -->
|
||||||
|
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="branch" x1="256" y1="430" x2="256" y2="90" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#10b981"/>
|
||||||
|
<stop offset="1" stop-color="#34d399"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="512" height="512" fill="#09090b"/>
|
||||||
|
|
||||||
|
<!-- branches / circuit (tronc + fourches symétriques), tracées dans la zone sûre maskable -->
|
||||||
|
<g fill="none" stroke="url(#branch)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)">
|
||||||
|
<!-- tronc : du nœud sommet jusqu'à la base avec le curseur >_ -->
|
||||||
|
<path d="M256 96 V392"/>
|
||||||
|
<!-- paire haute -->
|
||||||
|
<path d="M256 232 L150 232 V150"/>
|
||||||
|
<path d="M256 232 L362 232 V150"/>
|
||||||
|
<!-- paire médiane -->
|
||||||
|
<path d="M256 300 L104 300 V214"/>
|
||||||
|
<path d="M256 300 L408 300 V214"/>
|
||||||
|
<!-- paire basse -->
|
||||||
|
<path d="M256 356 L150 356 V300"/>
|
||||||
|
<path d="M256 356 L362 356 V300"/>
|
||||||
|
<!-- base : invite de commande >_ -->
|
||||||
|
<path d="M232 404 L246 418 L232 432" stroke-width="14"/>
|
||||||
|
<path d="M258 434 H286" stroke-width="14"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- nœuds (sessions) : anneaux cyan lumineux, centre sombre -->
|
||||||
|
<g filter="url(#glow)">
|
||||||
|
<g fill="#09090b" stroke="#22d3ee" stroke-width="9">
|
||||||
|
<circle cx="256" cy="96" r="20"/>
|
||||||
|
<circle cx="150" cy="150" r="16"/>
|
||||||
|
<circle cx="362" cy="150" r="16"/>
|
||||||
|
<circle cx="104" cy="214" r="16"/>
|
||||||
|
<circle cx="408" cy="214" r="16"/>
|
||||||
|
<circle cx="150" cy="300" r="16"/>
|
||||||
|
<circle cx="362" cy="300" r="16"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
4
packages/site/public/robots.txt
Normal file
4
packages/site/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://git-arboretum.com/sitemap.xml
|
||||||
9
packages/site/public/sitemap.xml
Normal file
9
packages/site/public/sitemap.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://git-arboretum.com/</loc>
|
||||||
|
<lastmod>2026-06-19</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
74
packages/site/src/App.vue
Normal file
74
packages/site/src/App.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppHeader from './components/AppHeader.vue';
|
||||||
|
import HeroSection from './components/HeroSection.vue';
|
||||||
|
import ProblemSection from './components/ProblemSection.vue';
|
||||||
|
import FeaturesSection from './components/FeaturesSection.vue';
|
||||||
|
import ShowcaseSection from './components/ShowcaseSection.vue';
|
||||||
|
import WorkGroupsSection from './components/WorkGroupsSection.vue';
|
||||||
|
import HowItWorksSection from './components/HowItWorksSection.vue';
|
||||||
|
import SecuritySection from './components/SecuritySection.vue';
|
||||||
|
import FaqSection from './components/FaqSection.vue';
|
||||||
|
import FinalCta from './components/FinalCta.vue';
|
||||||
|
import AppFooter from './components/AppFooter.vue';
|
||||||
|
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
// Tient l'attribut <html lang> synchronisé avec la langue active.
|
||||||
|
watch(
|
||||||
|
locale,
|
||||||
|
(l) => {
|
||||||
|
document.documentElement.lang = l;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grain de fond + halo emerald (décoratifs) — portés tels quels du design.
|
||||||
|
const grainStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: '0',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
backgroundImage: 'radial-gradient(rgba(255,255,255,.025) 1px,transparent 1.4px)',
|
||||||
|
backgroundSize: '34px 34px',
|
||||||
|
WebkitMaskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
||||||
|
maskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const glowStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '-26%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '1100px',
|
||||||
|
height: '680px',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
background: 'radial-gradient(50% 50% at 50% 50%,rgba(16,185,129,.12),transparent 64%)',
|
||||||
|
filter: 'blur(18px)',
|
||||||
|
} as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative overflow-x-hidden">
|
||||||
|
<div aria-hidden="true" :style="grainStyle"></div>
|
||||||
|
<div aria-hidden="true" :style="glowStyle"></div>
|
||||||
|
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main id="top" class="relative z-[1]">
|
||||||
|
<HeroSection />
|
||||||
|
<ProblemSection />
|
||||||
|
<FeaturesSection />
|
||||||
|
<ShowcaseSection />
|
||||||
|
<WorkGroupsSection />
|
||||||
|
<HowItWorksSection />
|
||||||
|
<SecuritySection />
|
||||||
|
<FaqSection />
|
||||||
|
<FinalCta />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
50
packages/site/src/components/AppFooter.vue
Normal file
50
packages/site/src/components/AppFooter.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { REPO, LICENSE, COFFEE } from '../lib/links';
|
||||||
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="relative z-[1] border-t border-[#1c1c1f]">
|
||||||
|
<div class="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-5 px-6 py-[34px]">
|
||||||
|
<div class="flex items-center gap-[11px]">
|
||||||
|
<img
|
||||||
|
src="/assets/arboretum-mark.png"
|
||||||
|
alt=""
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
class="h-[22px] w-[22px] object-contain"
|
||||||
|
style="filter: grayscale(1) brightness(1.5); opacity: 0.4"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-sm text-zinc-300">git-arboretum.com</span>
|
||||||
|
<span class="ml-1 text-[13px] text-zinc-600">— {{ t('footTag') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-[22px] text-sm">
|
||||||
|
<a :href="REPO" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 text-zinc-400 no-underline transition-colors hover:text-emerald-400">
|
||||||
|
<IconGitea :size="15" />
|
||||||
|
Gitea
|
||||||
|
</a>
|
||||||
|
<a :href="LICENSE" target="_blank" rel="noopener" class="text-zinc-400 no-underline transition-colors hover:text-emerald-400">
|
||||||
|
{{ t('license') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
:href="COFFEE"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1.5 text-zinc-400 no-underline transition-colors hover:text-amber-400 focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400/70"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M5 8h13a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1Z" /><path d="M6 15a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4" /><path d="M19 9h1.5a2.5 2.5 0 0 1 0 5H18" /></svg>
|
||||||
|
{{ t('coffee') }}
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-[12.5px] text-zinc-600">{{ INSTALL_COMMAND }}</code>
|
||||||
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
59
packages/site/src/components/AppHeader.vue
Normal file
59
packages/site/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { REPO } from '../lib/links';
|
||||||
|
import LangToggle from './LangToggle.vue';
|
||||||
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: '#features', key: 'navFeatures' },
|
||||||
|
{ href: '#how', key: 'navHow' },
|
||||||
|
{ href: '#security', key: 'navSecurity' },
|
||||||
|
{ href: '#faq', key: 'navFaq' },
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-50 border-b border-[#1c1c1f] bg-[rgba(9,9,11,0.72)] backdrop-blur-[14px]"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
|
||||||
|
<a href="#top" class="flex items-center gap-2.5 text-zinc-100 no-underline">
|
||||||
|
<img
|
||||||
|
src="/assets/arboretum-mark.png"
|
||||||
|
alt="Arboretum"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
class="block h-7 w-7 object-contain"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-[30px] min-[900px]:flex">
|
||||||
|
<a
|
||||||
|
v-for="link in navLinks"
|
||||||
|
:key="link.href"
|
||||||
|
:href="link.href"
|
||||||
|
class="text-[14.5px] text-zinc-400 no-underline transition-colors hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ t(link.key) }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3.5">
|
||||||
|
<LangToggle />
|
||||||
|
<a
|
||||||
|
:href="REPO"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
aria-label="Gitea"
|
||||||
|
class="inline-flex items-center gap-[7px] rounded-lg border border-zinc-800 px-[13px] py-[7px] text-[13.5px] font-medium text-zinc-300 no-underline transition-colors hover:border-emerald-500/50 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||||
|
>
|
||||||
|
<IconGitea :size="16" />
|
||||||
|
Gitea
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
57
packages/site/src/components/CopyButton.vue
Normal file
57
packages/site/src/components/CopyButton.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useCopy } from '../composables/useCopy';
|
||||||
|
import IconCopy from './icons/IconCopy.vue';
|
||||||
|
import IconCheck from './icons/IconCheck.vue';
|
||||||
|
|
||||||
|
// Bouton « copier » réutilisable, avec retour visuel (icône ✓ + label) pendant 1800 ms.
|
||||||
|
// Deux variantes :
|
||||||
|
// - 'bar' : bouton étiqueté intégré aux barres de commande (hero, CTA final).
|
||||||
|
// - 'icon' : bouton compact icône seule, pour les blocs de code (étapes, footer).
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
text: string;
|
||||||
|
variant?: 'bar' | 'icon';
|
||||||
|
label?: string; // aria-label décrivant ce qui est copié
|
||||||
|
barClass?: string; // padding/ajustements de la variante 'bar'
|
||||||
|
}>(),
|
||||||
|
{ variant: 'bar', label: '', barClass: 'px-[15px]' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { copied, copy } = useCopy();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="variant === 'bar'"
|
||||||
|
type="button"
|
||||||
|
:aria-label="label || t('copy')"
|
||||||
|
:class="[
|
||||||
|
'inline-flex flex-none items-center gap-[7px] border-l border-zinc-800 bg-zinc-900 font-mono text-[13px] font-semibold transition-colors hover:bg-zinc-800 hover:text-emerald-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-500/70',
|
||||||
|
barClass,
|
||||||
|
copied ? 'text-emerald-400' : 'text-zinc-300',
|
||||||
|
]"
|
||||||
|
@click="copy(text)"
|
||||||
|
>
|
||||||
|
<component :is="copied ? IconCheck : IconCopy" :size="15" />
|
||||||
|
<span aria-live="polite">{{ copied ? t('copied') : t('copy') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
:aria-label="label || t('copy')"
|
||||||
|
:title="copied ? t('copied') : label || t('copy')"
|
||||||
|
:class="[
|
||||||
|
'inline-flex flex-none items-center justify-center rounded-md border p-1.5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70',
|
||||||
|
copied
|
||||||
|
? 'border-emerald-500/40 bg-zinc-800 text-emerald-400'
|
||||||
|
: 'border-zinc-800 bg-zinc-900/80 text-zinc-400 hover:border-emerald-500/40 hover:bg-zinc-800 hover:text-emerald-400',
|
||||||
|
]"
|
||||||
|
@click="copy(text)"
|
||||||
|
>
|
||||||
|
<component :is="copied ? IconCheck : IconCopy" :size="14" />
|
||||||
|
<span aria-live="polite" class="sr-only">{{ copied ? t('copied') : t('copy') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
52
packages/site/src/components/FaqSection.vue
Normal file
52
packages/site/src/components/FaqSection.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { FAQS } from '../i18n/content';
|
||||||
|
import type { AppLocale } from '../i18n';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const open = ref(0);
|
||||||
|
const items = computed(() => FAQS[locale.value as AppLocale]);
|
||||||
|
|
||||||
|
function toggle(i: number): void {
|
||||||
|
open.value = open.value === i ? -1 : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelStyle(i: number) {
|
||||||
|
return {
|
||||||
|
maxHeight: open.value === i ? '300px' : '0px',
|
||||||
|
opacity: open.value === i ? 1 : 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'max-height .4s ease, opacity .35s ease',
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="faq" class="mx-auto max-w-[780px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div v-reveal class="mb-[38px] text-center">
|
||||||
|
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('navFaq') }}</div>
|
||||||
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">{{ t('faqTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-reveal>
|
||||||
|
<div v-for="(f, i) in items" :key="i" class="border-t" :class="open === i ? 'border-emerald-400/40' : 'border-zinc-800'">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="open === i"
|
||||||
|
:aria-controls="`faq-panel-${i}`"
|
||||||
|
class="flex w-full cursor-pointer items-center justify-between gap-4 border-none bg-transparent px-1 py-5 text-left text-[clamp(16px,2vw,18px)] font-medium text-zinc-50 focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||||
|
@click="toggle(i)"
|
||||||
|
>
|
||||||
|
<span>{{ f.q }}</span>
|
||||||
|
<span class="flex flex-none text-emerald-400 transition-transform duration-300" :class="open === i ? 'rotate-180' : ''">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div :id="`faq-panel-${i}`" :style="panelStyle(i)" :inert="open !== i" :aria-hidden="open !== i">
|
||||||
|
<p class="m-0 max-w-[640px] px-1 pb-5 text-[15.5px] leading-[1.6] text-zinc-400">{{ f.a }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
17
packages/site/src/components/FeatureCard.vue
Normal file
17
packages/site/src/components/FeatureCard.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 transition-[transform,border-color] duration-[180ms] ease-in-out hover:-translate-y-[3px] hover:border-emerald-400/40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mb-4 inline-flex h-[42px] w-[42px] items-center justify-center rounded-[10px] bg-emerald-500/10 text-emerald-400"
|
||||||
|
>
|
||||||
|
<slot name="icon" />
|
||||||
|
</div>
|
||||||
|
<h3 class="m-0 mb-[7px] text-lg font-semibold text-zinc-50">{{ title }}</h3>
|
||||||
|
<p class="m-0 text-[14.5px] leading-[1.55] text-zinc-400"><slot /></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
76
packages/site/src/components/FeaturesSection.vue
Normal file
76
packages/site/src/components/FeaturesSection.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import FeatureCard from './FeatureCard.vue';
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="features" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div v-reveal class="mb-[46px] text-center">
|
||||||
|
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('featKicker') }}</div>
|
||||||
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('featTitle') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(264px,1fr))] gap-4">
|
||||||
|
<FeatureCard :title="t('feat1Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat1Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat2Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat2Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat3Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg>
|
||||||
|
</template>
|
||||||
|
<span class="text-sky-300">{{ t('busy') }}</span> ·
|
||||||
|
<span class="text-amber-300">{{ t('waiting') }}</span> ·
|
||||||
|
<span class="text-emerald-400">{{ t('idle') }}</span> — {{ t('feat3Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat4Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2.5" /><path d="M12 18h.01" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat4Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat5Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.27 21a1.94 1.94 0 0 0 3.46 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat5Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat6Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat6Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat7Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="m7 16.5-4.74-2.85" /><path d="m7 16.5 5-3" /><path d="M7 16.5v5.17" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat7Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat8Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat8Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
52
packages/site/src/components/FinalCta.vue
Normal file
52
packages/site/src/components/FinalCta.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { REPO } from '../lib/links';
|
||||||
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-auto max-w-[1200px] px-6 pb-[100px]">
|
||||||
|
<div
|
||||||
|
v-reveal
|
||||||
|
class="rounded-[20px] border border-zinc-800 px-6 py-[clamp(36px,5vw,64px)] text-center"
|
||||||
|
style="background: radial-gradient(120% 140% at 50% 0%, rgba(16, 185, 129, 0.1), transparent 60%), #0c0c0e"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/arboretum-mark.png"
|
||||||
|
alt=""
|
||||||
|
width="52"
|
||||||
|
height="52"
|
||||||
|
class="mx-auto mb-5 block h-[52px] w-[52px] object-contain"
|
||||||
|
/>
|
||||||
|
<h2 class="m-0 mb-3.5 text-[clamp(30px,4vw,46px)] font-bold leading-[1.08] tracking-[-0.03em] text-zinc-50">
|
||||||
|
{{ t('ctaTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mx-auto mb-7 max-w-[480px] text-[17px] text-zinc-400">{{ t('ctaBody') }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mx-auto flex max-w-[460px] items-stretch overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-4 font-mono text-sm">
|
||||||
|
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
||||||
|
</div>
|
||||||
|
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" bar-class="px-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-[18px]">
|
||||||
|
<a
|
||||||
|
:href="REPO"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-[14.5px] text-zinc-400 no-underline transition-colors hover:text-emerald-400"
|
||||||
|
>
|
||||||
|
<IconGitea :size="16" />
|
||||||
|
{{ t('ctaSource') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
212
packages/site/src/components/HeroMockup.vue
Normal file
212
packages/site/src/components/HeroMockup.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useHeroTimeline, type SessionState } from '../composables/useHeroTimeline';
|
||||||
|
import { DLG_OPTS } from '../i18n/content';
|
||||||
|
import type { AppLocale } from '../i18n';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const { anim } = useHeroTimeline('hero-stage');
|
||||||
|
|
||||||
|
// Couleurs des lignes de terminal par type (porté de renderVals.C).
|
||||||
|
const LINE_COLORS: Record<string, string> = {
|
||||||
|
cmd: '#52525b',
|
||||||
|
tool: '#a1a1aa',
|
||||||
|
edit: '#34d399',
|
||||||
|
add: '#34d399',
|
||||||
|
del: '#f87171',
|
||||||
|
ok: '#34d399',
|
||||||
|
warn: '#fcd34d',
|
||||||
|
run: '#7dd3fc',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PILL: Record<SessionState, { bg: string; fg: string; dot: string; pulse: string }> = {
|
||||||
|
idle: { bg: 'rgba(2,44,34,.6)', fg: '#34d399', dot: '#34d399', pulse: '' },
|
||||||
|
busy: { bg: 'rgba(8,47,73,.7)', fg: '#7dd3fc', dot: '#7dd3fc', pulse: 'pulseSky 2s ease-in-out infinite' },
|
||||||
|
waiting: { bg: 'rgba(69,26,3,.7)', fg: '#fcd34d', dot: '#fcd34d', pulse: 'pulseAmber 2.2s ease-in-out infinite' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const pill = computed(() => PILL[anim.value.sess]);
|
||||||
|
const sessText = computed(() => t(anim.value.sess));
|
||||||
|
|
||||||
|
const termLines = computed(() =>
|
||||||
|
anim.value.lines.map((l) => ({ text: l.text, color: LINE_COLORS[l.k] ?? '#d4d4d8' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dlgOptions = computed(() =>
|
||||||
|
DLG_OPTS[locale.value as AppLocale].map((txt, i) => {
|
||||||
|
const highlighted = anim.value.hl === i;
|
||||||
|
const answered = anim.value.ans === i;
|
||||||
|
const row: Record<string, string> = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '9px',
|
||||||
|
padding: '7px 9px',
|
||||||
|
borderRadius: '7px',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
background: 'rgba(9,9,11,.4)',
|
||||||
|
transition: 'background .2s, border-color .2s',
|
||||||
|
};
|
||||||
|
if (highlighted) {
|
||||||
|
row.background = 'rgba(69,26,3,.5)';
|
||||||
|
row.border = '1px solid rgba(217,119,6,.6)';
|
||||||
|
}
|
||||||
|
if (answered) {
|
||||||
|
row.background = 'rgba(2,44,34,.45)';
|
||||||
|
row.border = '1px solid rgba(5,150,105,.6)';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
num: answered ? '✓' : String(i + 1),
|
||||||
|
text: txt,
|
||||||
|
rowStyle: row,
|
||||||
|
numColor: answered ? '#34d399' : '#fbbf24',
|
||||||
|
textColor: answered ? '#a7f3d0' : highlighted ? '#fde68a' : '#d4d4d8',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="hero-stage" class="relative min-w-[300px] flex-[1_1_480px]">
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-[14px] border border-zinc-800 bg-zinc-950 shadow-hero"
|
||||||
|
>
|
||||||
|
<!-- barre de fenêtre -->
|
||||||
|
<div class="flex items-center gap-2 border-b border-[#1c1c1f] bg-[#0c0c0e] px-[14px] py-[11px]">
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||||
|
<span class="ml-2 font-mono text-[11.5px] text-zinc-600">arboretum · localhost:7317</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-[360px]">
|
||||||
|
<!-- sidebar -->
|
||||||
|
<div
|
||||||
|
class="flex w-[168px] flex-none flex-col border-r border-[#1c1c1f] bg-zinc-900/40 px-2.5 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-1 pb-3">
|
||||||
|
<img
|
||||||
|
src="/assets/arboretum-mark.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="h-5 w-5 object-contain"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-[13px] font-semibold text-zinc-200">Arboretum</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mb-3 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/50 px-[9px] py-[7px]"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
|
||||||
|
<span class="flex-1 text-[11.5px] text-zinc-600">{{ t('mSearch') }}</span>
|
||||||
|
<span class="rounded border border-zinc-800 px-[5px] py-px font-mono text-[10px] text-zinc-500">⌘K</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="flex items-center gap-[9px] rounded-lg bg-zinc-800 px-[9px] py-[7px] text-zinc-100">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></svg>
|
||||||
|
<span class="text-[12.5px]">{{ t('mDash') }}</span>
|
||||||
|
<span
|
||||||
|
v-show="anim.att > 0"
|
||||||
|
class="ml-auto inline-flex h-[17px] min-w-[17px] items-center justify-center rounded-full px-[5px] font-mono text-[10px] font-bold"
|
||||||
|
style="background: #f59e0b; color: #1c1300"
|
||||||
|
>{{ anim.att }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-[9px] rounded-lg px-[9px] py-[7px] text-zinc-400">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||||
|
<span class="text-[12.5px]">{{ t('mGroups') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5 border-t border-[#1c1c1f] pt-2.5">
|
||||||
|
<div class="flex items-center gap-[9px] rounded-lg px-[9px] py-1.5 text-zinc-400">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||||
|
<span class="text-[12.5px]">{{ t('mSettings') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-[9px] rounded-lg px-[9px] py-1.5 text-zinc-400">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></svg>
|
||||||
|
<span class="text-[12.5px]">{{ t('mHelp') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-[9px] pt-1.5 font-mono text-[10px] text-zinc-700">v0.4.2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- main -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-[11px] p-[13px]">
|
||||||
|
<!-- terminal pane -->
|
||||||
|
<div
|
||||||
|
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||||
|
<span class="font-mono text-[11.5px] text-zinc-400">api · feat/auth</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full px-[9px] py-[3px] font-mono text-[11px]"
|
||||||
|
:style="{ background: pill.bg, color: pill.fg }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 flex-none rounded-full"
|
||||||
|
:style="{ background: pill.dot, animation: pill.pulse }"
|
||||||
|
></span>
|
||||||
|
{{ sessText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-h-[150px] flex-1 flex-col justify-end overflow-hidden p-[11px] font-mono text-xs leading-[1.7] text-zinc-300"
|
||||||
|
>
|
||||||
|
<div v-for="(ln, i) in termLines" :key="i" :style="{ color: ln.color, whiteSpace: 'pre' }">
|
||||||
|
{{ ln.text }}
|
||||||
|
</div>
|
||||||
|
<div v-if="anim.cur">
|
||||||
|
<span class="text-emerald-400">$</span>
|
||||||
|
<span class="ml-1 inline-block h-[13px] w-[7px] animate-blink align-middle bg-zinc-200"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- dialogue de permission -->
|
||||||
|
<div
|
||||||
|
v-if="anim.dlg"
|
||||||
|
class="animate-dlg-in rounded-[10px] border border-[rgba(120,53,15,0.6)] bg-[rgba(69,26,3,0.3)] p-[11px]"
|
||||||
|
>
|
||||||
|
<div class="mb-[9px] flex items-center gap-2">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
||||||
|
<span class="text-[12.5px] text-zinc-200">{{ t('dlgQ') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-[5px]">
|
||||||
|
<div v-for="(op, i) in dlgOptions" :key="i" :style="op.rowStyle">
|
||||||
|
<span
|
||||||
|
class="w-[15px] flex-none text-center font-mono text-[11px] font-bold"
|
||||||
|
:style="{ color: op.numColor }"
|
||||||
|
>{{ op.num }}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs" :style="{ color: op.textColor }">{{ op.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- toast push -->
|
||||||
|
<div
|
||||||
|
v-if="anim.toast"
|
||||||
|
class="animate-toast-in absolute bottom-[14px] right-[14px] flex w-[248px] items-start gap-2.5 rounded-[11px] border border-[rgba(120,53,15,0.5)] bg-[#0c0c0e] p-3 shadow-toast"
|
||||||
|
>
|
||||||
|
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-emerald-500/15">
|
||||||
|
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-mono text-[11px] text-emerald-400">Arboretum</span>
|
||||||
|
<span class="text-[10px] text-zinc-600">now</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-[3px] text-[13px] font-semibold text-zinc-100">{{ t('toastTitle') }}</div>
|
||||||
|
<div class="mt-px text-[11.5px] leading-[1.4] text-zinc-400">{{ t('toastBody') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
71
packages/site/src/components/HeroSection.vue
Normal file
71
packages/site/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { REPO } from '../lib/links';
|
||||||
|
import HeroMockup from './HeroMockup.vue';
|
||||||
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-auto flex max-w-[1200px] flex-wrap items-center gap-12 px-6 pb-16 pt-[72px]">
|
||||||
|
<div class="min-w-[300px] flex-[1_1_430px]">
|
||||||
|
<div class="mb-[22px] flex items-center gap-[13px]">
|
||||||
|
<img
|
||||||
|
src="/assets/arboretum-mark.png"
|
||||||
|
alt=""
|
||||||
|
width="46"
|
||||||
|
height="46"
|
||||||
|
class="block h-[46px] w-[46px] object-contain"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/[0.07] px-[11px] py-[5px] font-mono text-[11.5px] uppercase tracking-[0.1em] text-emerald-400"
|
||||||
|
>
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_#34d399]"></span>
|
||||||
|
{{ t('heroBadge') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="m-0 text-[clamp(40px,5.6vw,64px)] font-bold leading-[1.04] tracking-[-0.035em] text-zinc-50">
|
||||||
|
{{ t('heroTitle') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-[22px] max-w-[520px] text-[clamp(17px,1.7vw,20px)] leading-[1.55] text-zinc-400">
|
||||||
|
{{ t('heroSub') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-[30px] flex max-w-[480px] items-stretch overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950/60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-[15px] font-mono text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
||||||
|
</div>
|
||||||
|
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap gap-[13px]">
|
||||||
|
<a
|
||||||
|
href="#how"
|
||||||
|
class="inline-flex items-center gap-[9px] rounded-[9px] bg-emerald-600 px-6 py-[13px] text-[15.5px] font-semibold text-white no-underline transition-[background,transform] duration-150 ease-in-out hover:-translate-y-px hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||||
|
>
|
||||||
|
{{ t('getStarted') }}
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
:href="REPO"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-[9px] rounded-[9px] border border-zinc-800 px-[22px] py-[13px] text-[15.5px] font-medium text-zinc-100 no-underline transition-colors hover:border-emerald-500/45 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||||
|
>
|
||||||
|
<IconGitea :size="17" />
|
||||||
|
{{ t('gitea') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeroMockup />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
53
packages/site/src/components/HowItWorksSection.vue
Normal file
53
packages/site/src/components/HowItWorksSection.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Port par défaut réel du daemon (cf. packages/server/src/config.ts).
|
||||||
|
const LOCAL_URL = 'http://localhost:7317';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="how" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div v-reveal class="mb-[46px] text-center">
|
||||||
|
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('howKicker') }}</div>
|
||||||
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">{{ t('howTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
||||||
|
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||||
|
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">01</div>
|
||||||
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step1Title') }}</h3>
|
||||||
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step1Desc') }}</p>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
||||||
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-zinc-200">
|
||||||
|
<span class="text-emerald-400">$ </span>{{ INSTALL_COMMAND }}
|
||||||
|
</code>
|
||||||
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||||
|
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">02</div>
|
||||||
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step2Title') }}</h3>
|
||||||
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step2Desc') }}</p>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
||||||
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-sky-300">
|
||||||
|
→ {{ LOCAL_URL }}
|
||||||
|
</code>
|
||||||
|
<CopyButton variant="icon" :text="LOCAL_URL" :label="t('copyUrl')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||||
|
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">03</div>
|
||||||
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step3Title') }}</h3>
|
||||||
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step3Desc') }}</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span class="rounded-md bg-emerald-500/10 px-[9px] py-1 font-mono text-[11px] text-emerald-400">{{ t('idle') }}</span>
|
||||||
|
<span class="rounded-md bg-[rgba(8,47,73,0.7)] px-[9px] py-1 font-mono text-[11px] text-sky-300">{{ t('busy') }}</span>
|
||||||
|
<span class="rounded-md bg-[rgba(69,26,3,0.6)] px-[9px] py-1 font-mono text-[11px] text-amber-300">{{ t('waiting') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
37
packages/site/src/components/LangToggle.vue
Normal file
37
packages/site/src/components/LangToggle.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { setLocale, type AppLocale } from '../i18n';
|
||||||
|
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
function set(l: AppLocale): void {
|
||||||
|
setLocale(l);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Language"
|
||||||
|
class="flex items-center overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950/40"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="locale === 'en'"
|
||||||
|
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-emerald-500/70"
|
||||||
|
:class="locale === 'en' ? 'bg-zinc-800 text-emerald-400' : 'bg-transparent text-zinc-500'"
|
||||||
|
@click="set('en')"
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="locale === 'fr'"
|
||||||
|
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-emerald-500/70"
|
||||||
|
:class="locale === 'fr' ? 'bg-zinc-800 text-emerald-400' : 'bg-transparent text-zinc-500'"
|
||||||
|
@click="set('fr')"
|
||||||
|
>
|
||||||
|
FR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
packages/site/src/components/ProblemSection.vue
Normal file
14
packages/site/src/components/ProblemSection.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-reveal class="mx-auto max-w-[920px] px-6 pb-[92px] pt-12 text-center">
|
||||||
|
<div class="mb-4 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('probKicker') }}</div>
|
||||||
|
<h2 class="m-0 text-[clamp(28px,3.6vw,42px)] font-semibold leading-[1.18] tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('probTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mx-auto mt-[22px] max-w-[640px] text-[clamp(17px,1.6vw,19px)] text-zinc-400">{{ t('probBody') }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
47
packages/site/src/components/SecuritySection.vue
Normal file
47
packages/site/src/components/SecuritySection.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="security" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div
|
||||||
|
v-reveal
|
||||||
|
class="rounded-[18px] border border-emerald-500/[0.18] p-[clamp(28px,4vw,48px)]"
|
||||||
|
style="background: linear-gradient(180deg, rgba(16, 185, 129, 0.05), rgba(16, 185, 129, 0))"
|
||||||
|
>
|
||||||
|
<div class="mb-[30px] flex flex-wrap items-end justify-between gap-3.5">
|
||||||
|
<div>
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('secKicker') }}</div>
|
||||||
|
<h2 class="m-0 text-[clamp(26px,3.2vw,38px)] font-semibold leading-[1.12] tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('secTitle') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 max-w-[380px] text-[15.5px] leading-[1.55] text-zinc-400">{{ t('secIntro') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3.5">
|
||||||
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||||
|
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec1Title') }}</div>
|
||||||
|
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec1Desc') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" /><path d="m21 2-9.6 9.6" /><circle cx="7.5" cy="15.5" r="5.5" /></svg>
|
||||||
|
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec2Title') }}</div>
|
||||||
|
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec2Desc') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
|
||||||
|
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec3Title') }}</div>
|
||||||
|
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec3Desc') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.55a11 11 0 0 1 14.08 0" /><path d="M1.42 9a16 16 0 0 1 21.16 0" /><path d="M8.53 16.11a6 6 0 0 1 6.95 0" /><path d="M12 20h.01" /></svg>
|
||||||
|
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec4Title') }}</div>
|
||||||
|
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec4Desc') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
170
packages/site/src/components/ShowcaseSection.vue
Normal file
170
packages/site/src/components/ShowcaseSection.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="showcase" class="mx-auto flex max-w-[1200px] scroll-mt-[84px] flex-col gap-[92px] px-6">
|
||||||
|
<!-- a) dashboard + needs attention -->
|
||||||
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
|
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scAKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('scATitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scABody') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-[280px] flex-[1_1_420px]">
|
||||||
|
<div class="overflow-hidden rounded-xl border border-zinc-800 bg-[#0c0c0e] shadow-card">
|
||||||
|
<div class="flex items-center gap-[9px] border-b border-[#1c1c1f] bg-[rgba(69,26,3,0.18)] px-[15px] py-3">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fcd34d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
||||||
|
<span class="text-sm font-semibold text-zinc-50">{{ t('mAttn') }}</span>
|
||||||
|
<span class="ml-auto rounded-full bg-amber-300/[0.12] px-[9px] py-0.5 font-mono text-[11px] text-amber-300">2</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 p-2.5">
|
||||||
|
<div class="flex items-center gap-3 rounded-[9px] border border-[rgba(120,53,15,0.55)] bg-[rgba(69,26,3,0.3)] px-[13px] py-[11px]">
|
||||||
|
<span class="h-2 w-2 flex-none animate-pulse-amber rounded-full bg-amber-300"></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-mono text-[13px] text-zinc-200">api · feat/auth</div>
|
||||||
|
<div class="text-xs text-[#d4a55a]">Approve change to login.ts?</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="flex-none rounded-[7px] border border-amber-300/30 bg-amber-300/[0.12] px-3 py-[5px] text-xs font-semibold text-amber-300">Answer</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 rounded-[9px] border border-[rgba(120,53,15,0.55)] bg-[rgba(69,26,3,0.3)] px-[13px] py-[11px]">
|
||||||
|
<span class="h-2 w-2 flex-none animate-pulse-amber rounded-full bg-amber-300"></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-mono text-[13px] text-zinc-200">web · checkout</div>
|
||||||
|
<div class="text-xs text-[#d4a55a]">Run the failing test again?</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="flex-none rounded-[7px] border border-amber-300/30 bg-amber-300/[0.12] px-3 py-[5px] text-xs font-semibold text-amber-300">Answer</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-0.5 my-[3px] h-px bg-[#1c1c1f]"></div>
|
||||||
|
<div class="flex items-center gap-3 rounded-[9px] px-[13px] py-[9px]">
|
||||||
|
<span class="h-2 w-2 flex-none animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||||
|
<div class="min-w-0 flex-1 font-mono text-[13px] text-zinc-400">db · refactor</div>
|
||||||
|
<span class="flex-none font-mono text-[11px] text-sky-300">{{ t('busy') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 rounded-[9px] px-[13px] py-[9px]">
|
||||||
|
<span class="h-2 w-2 flex-none rounded-full bg-emerald-400"></span>
|
||||||
|
<div class="min-w-0 flex-1 font-mono text-[13px] text-zinc-400">docs · main</div>
|
||||||
|
<span class="flex-none font-mono text-[11px] text-emerald-400">{{ t('idle') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- b) multi-terminal grid -->
|
||||||
|
<div v-reveal class="flex flex-wrap-reverse items-center gap-12">
|
||||||
|
<div class="min-w-[280px] flex-[1_1_420px]">
|
||||||
|
<div class="grid grid-cols-2 gap-2.5 overflow-hidden rounded-xl border border-zinc-800 bg-[#0c0c0e] p-3 shadow-card">
|
||||||
|
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||||
|
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||||
|
<span class="font-mono text-[11px] text-zinc-400">api · feat/auth</span>
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||||
|
<div class="text-emerald-400">● Edit session.ts</div>
|
||||||
|
<div class="text-emerald-400">+ const token = sign(...)</div>
|
||||||
|
<div class="text-red-400">- legacy cookie auth</div>
|
||||||
|
<div class="text-zinc-500">refactoring guards…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||||
|
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||||
|
<span class="font-mono text-[11px] text-zinc-400">web · fix/cart</span>
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400"></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||||
|
<div class="text-emerald-400">✓ all tests passing</div>
|
||||||
|
<div class="text-zinc-500">24 passed, 0 failed</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-emerald-400">$</span>
|
||||||
|
<span class="ml-1 inline-block h-[11px] w-1.5 animate-blink align-middle bg-zinc-200"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||||
|
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||||
|
<span class="font-mono text-[11px] text-zinc-400">db · refactor</span>
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||||
|
<div class="text-emerald-400">● Write migration</div>
|
||||||
|
<div class="text-zinc-500">0003_add_index.sql</div>
|
||||||
|
<div class="text-zinc-500">applying to schema…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||||
|
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||||
|
<span class="font-mono text-[11px] text-zinc-400">docs · api</span>
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||||
|
</div>
|
||||||
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||||
|
<div class="text-emerald-400">● Read README.md</div>
|
||||||
|
<div class="text-zinc-500">## Quick start</div>
|
||||||
|
<div class="text-zinc-500">writing examples…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scBKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('scBTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scBBody') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- c) mobile -->
|
||||||
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
|
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scCKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('scCTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scCBody') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-[280px] flex-[1_1_320px] justify-center">
|
||||||
|
<div class="relative w-[288px] overflow-hidden rounded-[40px] border-[9px] border-zinc-900 bg-zinc-950 shadow-phone">
|
||||||
|
<div class="flex h-[30px] items-center justify-center">
|
||||||
|
<div class="h-1.5 w-[90px] rounded-full bg-zinc-800"></div>
|
||||||
|
</div>
|
||||||
|
<div class="px-[13px] pb-[22px]">
|
||||||
|
<div class="flex items-start gap-[11px] rounded-[15px] border border-[rgba(120,53,15,0.5)] bg-[#0c0c0e] p-[13px] shadow-toast-sm">
|
||||||
|
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-emerald-500/[0.14]">
|
||||||
|
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-mono text-[11px] text-emerald-400">Arboretum</span>
|
||||||
|
<span class="text-[10px] text-zinc-600">now</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-[3px] text-[13.5px] font-semibold text-zinc-50">{{ t('toastTitle') }}</div>
|
||||||
|
<div class="text-xs leading-[1.45] text-zinc-400">{{ t('toastBody') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-[14px] overflow-hidden rounded-[15px] border border-zinc-800 bg-zinc-950">
|
||||||
|
<div class="flex items-center gap-2 border-b border-[#1c1c1f] px-[13px] py-[11px]">
|
||||||
|
<span class="h-[7px] w-[7px] animate-pulse-amber rounded-full bg-amber-300"></span>
|
||||||
|
<span class="font-mono text-[11px] text-zinc-300">feat/auth</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-[13px]">
|
||||||
|
<div class="text-[13px] leading-[1.5] text-zinc-300">{{ t('dlgQ') }}</div>
|
||||||
|
<div class="mt-2 rounded-lg border border-[#1c1c1f] bg-[#0c0c0e] p-[9px] font-mono text-[11px] leading-[1.6] text-zinc-500">
|
||||||
|
<div class="text-emerald-400">+ verifyToken(req)</div>
|
||||||
|
<div class="text-red-400">- skipAuth()</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-[13px] flex gap-2">
|
||||||
|
<button type="button" class="flex-1 rounded-lg bg-emerald-600 py-2.5 text-[13px] font-semibold text-white">{{ t('approve') }}</button>
|
||||||
|
<button type="button" class="flex-1 rounded-lg border border-zinc-800 bg-transparent py-2.5 text-[13px] font-medium text-zinc-300">{{ t('reject') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
55
packages/site/src/components/WorkGroupsSection.vue
Normal file
55
packages/site/src/components/WorkGroupsSection.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 py-[92px]">
|
||||||
|
<div
|
||||||
|
v-reveal
|
||||||
|
class="flex flex-wrap items-center gap-12 rounded-[18px] border border-zinc-800 p-[clamp(28px,4vw,52px)]"
|
||||||
|
style="background: linear-gradient(180deg, rgba(24, 24, 27, 0.55), rgba(12, 12, 14, 0.55))"
|
||||||
|
>
|
||||||
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('grpKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,36px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||||
|
{{ t('grpTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('grpBody') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-[280px] flex-[1_1_360px] justify-center">
|
||||||
|
<div class="flex w-full max-w-[380px] items-center">
|
||||||
|
<div class="flex flex-none flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="inline-flex h-[62px] w-[62px] items-center justify-center rounded-[15px] border border-emerald-500/35 bg-emerald-500/[0.12] text-emerald-400"
|
||||||
|
>
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-[11px] text-zinc-300">{{ t('grpHub') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative h-[96px] flex-1">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">
|
||||||
|
<path d="M0 48 H40 V14 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
||||||
|
<path d="M0 48 H40 V48 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
||||||
|
<path d="M0 48 H40 V82 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-none flex-col gap-2.5">
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
||||||
|
<span class="font-mono text-[11px] text-zinc-300">api</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
||||||
|
<span class="font-mono text-[11px] text-zinc-300">web</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
||||||
|
<span class="font-mono text-[11px] text-zinc-300">sdk</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
19
packages/site/src/components/icons/IconCheck.vue
Normal file
19
packages/site/src/components/icons/IconCheck.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ size?: number }>(), { size: 15 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
20
packages/site/src/components/icons/IconCopy.vue
Normal file
20
packages/site/src/components/icons/IconCopy.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ size?: number }>(), { size: 15 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.9"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect width="13" height="13" x="9" y="9" rx="2" />
|
||||||
|
<path d="M5 15c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
22
packages/site/src/components/icons/IconGitea.vue
Normal file
22
packages/site/src/components/icons/IconGitea.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ size?: number }>(), { size: 16 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.7"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 10h12v4a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z" />
|
||||||
|
<path d="M16 11h2.5a2.5 2.5 0 0 1 0 5H16" />
|
||||||
|
<path d="M7 3c-.5.8.5 1.6 0 2.5" />
|
||||||
|
<path d="M11 3c-.5.8.5 1.6 0 2.5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
24
packages/site/src/composables/useCopy.ts
Normal file
24
packages/site/src/composables/useCopy.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const INSTALL_COMMAND = 'npx @johanleroy/git-arboretum';
|
||||||
|
|
||||||
|
// Bouton « copier la commande » : copie + bascule le label 1800 ms (fidèle au design).
|
||||||
|
export function useCopy() {
|
||||||
|
const copied = ref(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function copy(text: string = INSTALL_COMMAND): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard) await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
/* clipboard indisponible (contexte non sécurisé) */
|
||||||
|
}
|
||||||
|
copied.value = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { copied, copy };
|
||||||
|
}
|
||||||
113
packages/site/src/composables/useHeroTimeline.ts
Normal file
113
packages/site/src/composables/useHeroTimeline.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
// Portage 1:1 de la timeline animée du mockup hero (Arboretum.dc.html).
|
||||||
|
export type SessionState = 'idle' | 'busy' | 'waiting';
|
||||||
|
|
||||||
|
export interface AnimLine {
|
||||||
|
text: string;
|
||||||
|
k: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroAnim {
|
||||||
|
sess: SessionState;
|
||||||
|
lines: AnimLine[];
|
||||||
|
att: number;
|
||||||
|
toast: boolean;
|
||||||
|
dlg: boolean;
|
||||||
|
hl: number;
|
||||||
|
ans: number;
|
||||||
|
cur: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOTAL = 13000;
|
||||||
|
const TICK = 80;
|
||||||
|
|
||||||
|
interface ScriptEntry {
|
||||||
|
at: number;
|
||||||
|
text: string;
|
||||||
|
k: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function script(): ScriptEntry[] {
|
||||||
|
return [
|
||||||
|
{ at: 850, text: '$ claude --resume feat/auth', k: 'cmd' },
|
||||||
|
{ at: 1400, text: '> Reading src/auth/login.ts', k: 'tool' },
|
||||||
|
{ at: 1950, text: '● Edit login.ts', k: 'edit' },
|
||||||
|
{ at: 2400, text: " + import { verifyToken } from './jwt'", k: 'add' },
|
||||||
|
{ at: 2850, text: ' + const user = await verifyToken(req)', k: 'add' },
|
||||||
|
{ at: 3350, text: '● Bash npm test -- auth', k: 'edit' },
|
||||||
|
{ at: 3850, text: ' ✓ 14 passed (1.24s)', k: 'ok' },
|
||||||
|
{ at: 4300, text: '● Permission required to edit login.ts', k: 'warn' },
|
||||||
|
{ at: 7950, text: '✓ approved · applying change', k: 'ok' },
|
||||||
|
{ at: 8500, text: '● Edit login.ts', k: 'edit' },
|
||||||
|
{ at: 9050, text: '● Running…', k: 'run' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveAnim(clock: number): HeroAnim {
|
||||||
|
const sess: SessionState = clock < 700 ? 'idle' : clock < 4500 ? 'busy' : clock < 7700 ? 'waiting' : 'busy';
|
||||||
|
const sc = script();
|
||||||
|
const lines: AnimLine[] = [];
|
||||||
|
for (const entry of sc) {
|
||||||
|
if (clock >= entry.at) lines.push({ text: entry.text, k: entry.k });
|
||||||
|
}
|
||||||
|
const att = clock >= 4500 && clock < 7700 ? 1 : 0;
|
||||||
|
const toast = clock >= 4650 && clock < 7700;
|
||||||
|
const dlg = clock >= 4600 && clock < 7700;
|
||||||
|
const hl = clock >= 6000 && clock < 7050 ? 0 : -1;
|
||||||
|
const ans = clock >= 7050 && clock < 7700 ? 0 : -1;
|
||||||
|
const cur = sess !== 'waiting';
|
||||||
|
return { sess, lines, att, toast, dlg, hl, ans, cur };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHeroTimeline(stageId = 'hero-stage') {
|
||||||
|
const anim = ref<HeroAnim>(deriveAnim(0));
|
||||||
|
let clock = 0;
|
||||||
|
let visible = true;
|
||||||
|
let everVisible = false;
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let io: IntersectionObserver | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
let reduced = false;
|
||||||
|
try {
|
||||||
|
reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (reduced) {
|
||||||
|
anim.value = deriveAnim(5200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stage = document.getElementById(stageId);
|
||||||
|
if (stage && 'IntersectionObserver' in window) {
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const e = entries[0];
|
||||||
|
if (!e) return;
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
visible = true;
|
||||||
|
everVisible = true;
|
||||||
|
} else if (everVisible) {
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0, rootMargin: '200px 0px 200px 0px' },
|
||||||
|
);
|
||||||
|
io.observe(stage);
|
||||||
|
}
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
clock += TICK;
|
||||||
|
if (clock >= TOTAL) clock = 0;
|
||||||
|
anim.value = deriveAnim(clock);
|
||||||
|
}, TICK);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
if (io) io.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { anim };
|
||||||
|
}
|
||||||
52
packages/site/src/composables/useScrollReveal.ts
Normal file
52
packages/site/src/composables/useScrollReveal.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Directive } from 'vue';
|
||||||
|
|
||||||
|
// Directive `v-reveal` : révèle un bloc quand il entre dans le viewport.
|
||||||
|
// Reprend l'intention du setupReveal() du design (seuil ~92% de la hauteur),
|
||||||
|
// via un IntersectionObserver partagé. Respecte prefers-reduced-motion.
|
||||||
|
let reducedMotion = false;
|
||||||
|
try {
|
||||||
|
reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
} catch {
|
||||||
|
/* matchMedia indisponible */
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
function getObserver(): IntersectionObserver | null {
|
||||||
|
if (typeof IntersectionObserver === 'undefined') return null;
|
||||||
|
if (!observer) {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('is-visible');
|
||||||
|
observer?.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// -8% en bas ≈ déclenche quand le haut du bloc franchit ~92% du viewport.
|
||||||
|
{ threshold: 0, rootMargin: '0px 0px -8% 0px' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reveal: Directive<HTMLElement> = {
|
||||||
|
mounted(el) {
|
||||||
|
el.classList.add('reveal');
|
||||||
|
if (reducedMotion) {
|
||||||
|
el.classList.add('is-visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const io = getObserver();
|
||||||
|
if (!io) {
|
||||||
|
// Pas d'IntersectionObserver : on révèle immédiatement (pas de contenu caché).
|
||||||
|
el.classList.add('is-visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
io.observe(el);
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
observer?.unobserve(el);
|
||||||
|
},
|
||||||
|
};
|
||||||
7
packages/site/src/env.d.ts
vendored
Normal file
7
packages/site/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
60
packages/site/src/i18n/content.ts
Normal file
60
packages/site/src/i18n/content.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Listes structurées (FAQ, options de dialogue) — gardées hors des messages
|
||||||
|
// vue-i18n et indexées par locale, pour rester du texte pur typé (pas d'innerHTML).
|
||||||
|
import type { AppLocale } from './index';
|
||||||
|
|
||||||
|
export interface FaqItem {
|
||||||
|
q: string;
|
||||||
|
a: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FAQS: Record<AppLocale, FaqItem[]> = {
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
q: 'Is Arboretum an IDE?',
|
||||||
|
a: "No — it's a command center. It doesn't replace your editor; it supervises the AI coding agents working across your repos: spawning sessions, surfacing their state, and letting you answer them.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Does it need the cloud?',
|
||||||
|
a: 'No. Arboretum is local-first. The daemon runs on your own machine and binds to 127.0.0.1 by default. Your code and sessions never leave your network unless you choose to expose the dashboard.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'How do I access it remotely?',
|
||||||
|
a: 'Through Tailscale Serve. Expose the dashboard to your tailnet and reach it securely from any device — no port forwarding, no public endpoint.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Does it work on mobile?',
|
||||||
|
a: 'Yes. The dashboard is an installable PWA. Add it to your home screen, get Web Push the moment a session needs you, and approve or reject a prompt with a single tap.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Is my code safe?',
|
||||||
|
a: 'Arboretum binds to localhost, hashes access tokens at rest, and enforces strict origin checks on every request. Remote access runs through Tailscale rather than an open port.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fr: [
|
||||||
|
{
|
||||||
|
q: 'Arboretum est-il un IDE ?',
|
||||||
|
a: "Non — c'est un poste de commandement. Il ne remplace pas votre éditeur ; il supervise les agents de code IA qui travaillent sur vos repos : lancement des sessions, affichage de leur état, et réponse à leurs questions.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'A-t-il besoin du cloud ?',
|
||||||
|
a: 'Non. Arboretum est local-first. Le daemon tourne sur votre propre machine et se lie à 127.0.0.1 par défaut. Votre code et vos sessions ne quittent jamais votre réseau sauf si vous exposez le dashboard.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Comment y accéder à distance ?',
|
||||||
|
a: "Via Tailscale Serve. Exposez le dashboard à votre tailnet et accédez-y en sécurité depuis n'importe quel appareil — sans redirection de port ni endpoint public.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Fonctionne-t-il sur mobile ?',
|
||||||
|
a: "Oui. Le dashboard est une PWA installable. Ajoutez-le à l'écran d'accueil, recevez un Web Push dès qu'une session a besoin de vous, et approuvez ou refusez une question d'un toucher.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Mon code est-il en sécurité ?',
|
||||||
|
a: "Arboretum se lie à localhost, hashe les tokens au repos, et applique une vérification d'origine stricte sur chaque requête. L'accès distant passe par Tailscale plutôt qu'un port ouvert.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DLG_OPTS: Record<AppLocale, string[]> = {
|
||||||
|
en: ['Yes, apply the change', "Yes, and don't ask again", 'No, keep current version'],
|
||||||
|
fr: ['Oui, appliquer le changement', 'Oui, et ne plus demander', 'Non, garder la version actuelle'],
|
||||||
|
};
|
||||||
96
packages/site/src/i18n/en.ts
Normal file
96
packages/site/src/i18n/en.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Messages EN — copie fidèle du dict() du design Arboretum.dc.html.
|
||||||
|
export default {
|
||||||
|
navFeatures: 'Features',
|
||||||
|
navHow: 'How it works',
|
||||||
|
navSecurity: 'Security',
|
||||||
|
navFaq: 'FAQ',
|
||||||
|
gitea: 'View on Gitea',
|
||||||
|
heroBadge: 'Mission control for AI coding agents',
|
||||||
|
heroTitle: 'Mission control for your AI coding agents',
|
||||||
|
heroSub:
|
||||||
|
'Run many Claude Code sessions across every git worktree — and supervise them from any device, even your phone.',
|
||||||
|
getStarted: 'Get started',
|
||||||
|
probKicker: 'The problem',
|
||||||
|
probTitle: "Many agents across worktrees is chaos — and you're chained to one terminal.",
|
||||||
|
probBody:
|
||||||
|
'Sessions scattered across tabs. No idea which agent is waiting on you. Step away from the desk and everything stalls. Arboretum is the single pane of glass.',
|
||||||
|
featKicker: 'Everything in one view',
|
||||||
|
featTitle: 'Built to supervise agents — not to be an IDE',
|
||||||
|
feat1Title: 'Worktree-first',
|
||||||
|
feat1Desc: 'Work on your main branch or spin up an isolated worktree — every session stays pinned to a branch. Branches, not chaos.',
|
||||||
|
feat2Title: 'Many sessions, one view',
|
||||||
|
feat2Desc: 'Run parallel Claude Code agents side by side in a single live grid.',
|
||||||
|
feat3Title: 'Fine-grained states',
|
||||||
|
feat3Desc: 'tell at a glance what each agent is doing.',
|
||||||
|
feat4Title: 'Supervise anywhere',
|
||||||
|
feat4Desc: 'Installable PWA. Check in from your laptop, tablet, or phone.',
|
||||||
|
feat5Title: 'Web Push alerts',
|
||||||
|
feat5Desc: 'Get pinged the moment a session needs a decision from you.',
|
||||||
|
feat6Title: 'Answer without a terminal',
|
||||||
|
feat6Desc: 'Respond to agent prompts and dialogs right from the dashboard.',
|
||||||
|
feat7Title: 'Work groups',
|
||||||
|
feat7Desc: 'Group repos and run one Claude session across all of them at once.',
|
||||||
|
feat8Title: 'Local-first & secure',
|
||||||
|
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
||||||
|
scAKicker: 'Worktree-first dashboard',
|
||||||
|
scATitle: 'See what needs you — before anything stalls',
|
||||||
|
scABody:
|
||||||
|
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere.',
|
||||||
|
scBKicker: 'Multi-terminal grid',
|
||||||
|
scBTitle: "Every agent's output, streaming live",
|
||||||
|
scBBody:
|
||||||
|
'Watch all your sessions at once — real terminal output, color-coded by state, updating in real time. Click any pane to take over, answer a prompt, or stop a run.',
|
||||||
|
scCKicker: 'On your phone',
|
||||||
|
scCTitle: 'Unblock an agent from the kitchen',
|
||||||
|
scCBody:
|
||||||
|
'Install the PWA and get a Web Push the second a session needs you. Tap the notification, read the prompt, answer the dialog — your agent keeps moving while you are away.',
|
||||||
|
grpHub: '1 session',
|
||||||
|
grpKicker: 'Work groups · cross-repo',
|
||||||
|
grpTitle: 'Drive every repo from one Claude session',
|
||||||
|
grpBody:
|
||||||
|
'Group the repos that move together — a service, its client, its docs. Launch a single Claude session that spans all of them at once: one conversation, one shared context working across every repo — instead of juggling one agent per repo.',
|
||||||
|
howKicker: 'How it works',
|
||||||
|
howTitle: 'Zero install. Three steps.',
|
||||||
|
step1Title: 'Launch the daemon',
|
||||||
|
step1Desc: 'One command. No install, no config, no account.',
|
||||||
|
step2Title: 'Open the dashboard',
|
||||||
|
step2Desc: 'It serves a web dashboard on localhost. Install it as a PWA on any device.',
|
||||||
|
step3Title: 'Spawn & supervise',
|
||||||
|
step3Desc: 'Start Claude Code sessions on your main branch or any worktree, and watch them from anywhere.',
|
||||||
|
secKicker: 'Security',
|
||||||
|
secTitle: 'A web terminal you can actually trust',
|
||||||
|
secIntro: 'Built local-first. Nothing is exposed unless you choose to — and even then, on your terms.',
|
||||||
|
sec1Title: 'Localhost by default',
|
||||||
|
sec1Desc: 'Binds to 127.0.0.1. Nothing leaves your machine unless you say so.',
|
||||||
|
sec2Title: 'Hashed tokens',
|
||||||
|
sec2Desc: 'Access tokens are hashed (sha256) at rest. No plaintext secrets on disk.',
|
||||||
|
sec3Title: 'Strict origin checks',
|
||||||
|
sec3Desc: 'Every request is origin-validated. No cross-site surprises.',
|
||||||
|
sec4Title: 'Remote via Tailscale',
|
||||||
|
sec4Desc: 'Reach it over Tailscale Serve — no public port, no port forwarding.',
|
||||||
|
faqTitle: 'Questions, answered',
|
||||||
|
ctaTitle: 'Take command of your agents',
|
||||||
|
ctaBody: 'One command to launch. Supervise everything from one place.',
|
||||||
|
ctaSource: 'View the source on Gitea',
|
||||||
|
footTag: 'a garden of branches, one pane of glass.',
|
||||||
|
license: 'MIT License',
|
||||||
|
coffee: 'Buy me a coffee',
|
||||||
|
mDash: 'Dashboard',
|
||||||
|
mGroups: 'Groups',
|
||||||
|
mSettings: 'Settings',
|
||||||
|
mHelp: 'Help',
|
||||||
|
mSearch: 'Search',
|
||||||
|
mAttn: 'Needs attention',
|
||||||
|
waiting: 'waiting',
|
||||||
|
busy: 'busy',
|
||||||
|
idle: 'idle',
|
||||||
|
approve: 'Approve',
|
||||||
|
reject: 'Reject',
|
||||||
|
toastTitle: 'Session waiting for you',
|
||||||
|
toastBody: 'api · feat/auth needs a decision',
|
||||||
|
dlgQ: 'Apply this change to login.ts?',
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied',
|
||||||
|
copyCommand: 'Copy command',
|
||||||
|
copyUrl: 'Copy URL',
|
||||||
|
};
|
||||||
96
packages/site/src/i18n/fr.ts
Normal file
96
packages/site/src/i18n/fr.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Messages FR — copie fidèle du dict() du design Arboretum.dc.html.
|
||||||
|
export default {
|
||||||
|
navFeatures: 'Fonctionnalités',
|
||||||
|
navHow: 'Comment ça marche',
|
||||||
|
navSecurity: 'Sécurité',
|
||||||
|
navFaq: 'FAQ',
|
||||||
|
gitea: 'Voir sur Gitea',
|
||||||
|
heroBadge: 'Poste de commandement pour agents de code IA',
|
||||||
|
heroTitle: 'Le poste de commandement de vos agents de code IA',
|
||||||
|
heroSub:
|
||||||
|
"Lancez plusieurs sessions Claude Code à travers chaque worktree git — et supervisez-les depuis n'importe quel appareil, même votre téléphone.",
|
||||||
|
getStarted: 'Commencer',
|
||||||
|
probKicker: 'Le problème',
|
||||||
|
probTitle: "Plein d'agents à travers les worktrees, c'est le chaos — et vous êtes cloué à un seul terminal.",
|
||||||
|
probBody:
|
||||||
|
"Des sessions éparpillées dans des onglets. Aucune idée de quel agent vous attend. Vous quittez le bureau et tout se fige. Arboretum, c'est la vitre unique.",
|
||||||
|
featKicker: 'Tout dans une seule vue',
|
||||||
|
featTitle: 'Conçu pour superviser les agents — pas pour être un IDE',
|
||||||
|
feat1Title: "Worktree d'abord",
|
||||||
|
feat1Desc: 'Travaillez sur votre branche principale ou créez un worktree isolé — chaque session reste rattachée à une branche. Des branches, pas du chaos.',
|
||||||
|
feat2Title: 'Plusieurs sessions, une vue',
|
||||||
|
feat2Desc: 'Lancez des agents Claude Code en parallèle, côte à côte dans une seule grille en direct.',
|
||||||
|
feat3Title: 'États fins',
|
||||||
|
feat3Desc: "sachez d'un coup d'œil ce que fait chaque agent.",
|
||||||
|
feat4Title: 'Supervisez partout',
|
||||||
|
feat4Desc: "PWA installable. Surveillez depuis l'ordinateur, la tablette ou le téléphone.",
|
||||||
|
feat5Title: 'Alertes Web Push',
|
||||||
|
feat5Desc: "Soyez prévenu dès qu'une session attend une décision.",
|
||||||
|
feat6Title: 'Répondre sans terminal',
|
||||||
|
feat6Desc: 'Répondez aux questions et dialogues des agents directement depuis le dashboard.',
|
||||||
|
feat7Title: 'Work groups',
|
||||||
|
feat7Desc: 'Regroupez des repos et pilotez-les via une seule session Claude, tous à la fois.',
|
||||||
|
feat8Title: 'Local-first & sécurisé',
|
||||||
|
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
||||||
|
scAKicker: 'Dashboard worktree-first',
|
||||||
|
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
||||||
|
scABody:
|
||||||
|
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs.",
|
||||||
|
scBKicker: 'Grille multi-terminaux',
|
||||||
|
scBTitle: 'La sortie de chaque agent, en direct',
|
||||||
|
scBBody:
|
||||||
|
"Surveillez toutes vos sessions en même temps — vraie sortie terminal, couleur selon l'état, mise à jour en temps réel. Cliquez sur un panneau pour reprendre la main, répondre, ou arrêter une exécution.",
|
||||||
|
scCKicker: 'Sur votre téléphone',
|
||||||
|
scCTitle: 'Débloquez un agent depuis la cuisine',
|
||||||
|
scCBody:
|
||||||
|
"Installez la PWA et recevez un Web Push dès qu'une session a besoin de vous. Touchez la notification, lisez la question, répondez au dialogue — votre agent continue pendant que vous êtes loin du bureau.",
|
||||||
|
grpHub: '1 session',
|
||||||
|
grpKicker: 'Work groups · cross-repo',
|
||||||
|
grpTitle: 'Pilotez tous vos repos depuis une seule session Claude',
|
||||||
|
grpBody:
|
||||||
|
'Regroupez les repos qui avancent ensemble — un service, son client, sa doc. Lancez une seule session Claude qui les couvre tous à la fois : une conversation, un contexte partagé travaillant à travers chaque repo — au lieu de jongler avec un agent par repo.',
|
||||||
|
howKicker: 'Comment ça marche',
|
||||||
|
howTitle: 'Zéro install. Trois étapes.',
|
||||||
|
step1Title: 'Lancez le daemon',
|
||||||
|
step1Desc: "Une commande. Pas d'install, pas de config, pas de compte.",
|
||||||
|
step2Title: 'Ouvrez le dashboard',
|
||||||
|
step2Desc: "Il sert un dashboard web sur localhost. Installez-le en PWA sur n'importe quel appareil.",
|
||||||
|
step3Title: 'Lancez & supervisez',
|
||||||
|
step3Desc: "Démarrez des sessions Claude Code sur votre branche principale ou n'importe quel worktree, et surveillez-les de partout.",
|
||||||
|
secKicker: 'Sécurité',
|
||||||
|
secTitle: 'Un terminal web en qui vous pouvez avoir confiance',
|
||||||
|
secIntro: "Conçu local-first. Rien n'est exposé sauf si vous le décidez — et toujours selon vos règles.",
|
||||||
|
sec1Title: 'Localhost par défaut',
|
||||||
|
sec1Desc: 'Se lie à 127.0.0.1. Rien ne quitte votre machine sans votre accord.',
|
||||||
|
sec2Title: 'Tokens hashés',
|
||||||
|
sec2Desc: 'Les tokens sont hashés (sha256) au repos. Aucun secret en clair sur le disque.',
|
||||||
|
sec3Title: "Vérification d'origine stricte",
|
||||||
|
sec3Desc: 'Chaque requête est validée par origine. Pas de surprise cross-site.',
|
||||||
|
sec4Title: 'Distant via Tailscale',
|
||||||
|
sec4Desc: 'Accédez-y via Tailscale Serve — aucun port public, aucune redirection de port.',
|
||||||
|
faqTitle: 'Vos questions, nos réponses',
|
||||||
|
ctaTitle: 'Prenez le commandement de vos agents',
|
||||||
|
ctaBody: 'Une commande pour lancer. Tout superviser depuis un seul endroit.',
|
||||||
|
ctaSource: 'Voir le code sur Gitea',
|
||||||
|
footTag: 'un jardin de branches, une seule vitre.',
|
||||||
|
license: 'Licence MIT',
|
||||||
|
coffee: 'Paye-moi un café',
|
||||||
|
mDash: 'Tableau de bord',
|
||||||
|
mGroups: 'Groupes',
|
||||||
|
mSettings: 'Réglages',
|
||||||
|
mHelp: 'Aide',
|
||||||
|
mSearch: 'Rechercher',
|
||||||
|
mAttn: 'À traiter',
|
||||||
|
waiting: 'en attente',
|
||||||
|
busy: 'occupé',
|
||||||
|
idle: 'au repos',
|
||||||
|
approve: 'Approuver',
|
||||||
|
reject: 'Refuser',
|
||||||
|
toastTitle: 'Une session vous attend',
|
||||||
|
toastBody: 'api · feat/auth attend une décision',
|
||||||
|
dlgQ: 'Appliquer ce changement à login.ts ?',
|
||||||
|
copy: 'Copier',
|
||||||
|
copied: 'Copié',
|
||||||
|
copyCommand: 'Copier la commande',
|
||||||
|
copyUrl: "Copier l'URL",
|
||||||
|
};
|
||||||
37
packages/site/src/i18n/index.ts
Normal file
37
packages/site/src/i18n/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import en from './en';
|
||||||
|
import fr from './fr';
|
||||||
|
|
||||||
|
export type AppLocale = 'en' | 'fr';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'arb-lang';
|
||||||
|
|
||||||
|
// Détection fidèle au design : localStorage prioritaire, sinon langue navigateur
|
||||||
|
// (fr → FR, sinon EN).
|
||||||
|
function initialLocale(): AppLocale {
|
||||||
|
let saved: string | null = null;
|
||||||
|
try {
|
||||||
|
saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* localStorage indisponible (mode privé, etc.) */
|
||||||
|
}
|
||||||
|
if (saved === 'fr' || saved === 'en') return saved;
|
||||||
|
const nav = (typeof navigator !== 'undefined' && navigator.language ? navigator.language : '').toLowerCase();
|
||||||
|
return nav.indexOf('fr') === 0 ? 'fr' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: initialLocale(),
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: { en, fr },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setLocale(locale: AppLocale): void {
|
||||||
|
i18n.global.locale.value = locale;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, locale);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/site/src/lib/links.ts
Normal file
6
packages/site/src/lib/links.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// URLs externes du site vitrine — source unique de vérité.
|
||||||
|
// Le dépôt Gitea est « arboretum » (et non « git-arboretum », qui est le nom du
|
||||||
|
// paquet npm `@johanleroy/git-arboretum`). Centralisé ici pour ne plus dériver.
|
||||||
|
export const REPO = 'https://git.lidge.fr/johanleroy/arboretum';
|
||||||
|
export const LICENSE = `${REPO}/src/branch/main/LICENSE`;
|
||||||
|
export const COFFEE = 'https://buymeacoffee.com/johanleroy';
|
||||||
12
packages/site/src/main.ts
Normal file
12
packages/site/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
// Polices self-host (subset latin) — bundlées par Vite, aucune requête Google Fonts.
|
||||||
|
import '@fontsource/jetbrains-mono/latin-400.css';
|
||||||
|
import '@fontsource/jetbrains-mono/latin-500.css';
|
||||||
|
import '@fontsource/jetbrains-mono/latin-600.css';
|
||||||
|
import '@fontsource/jetbrains-mono/latin-700.css';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { i18n } from './i18n';
|
||||||
|
import { reveal } from './composables/useScrollReveal';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
createApp(App).use(i18n).directive('reveal', reveal).mount('#app');
|
||||||
120
packages/site/src/style.css
Normal file
120
packages/site/src/style.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* La palette du design correspond à zinc/emerald/amber/sky/red de Tailwind.
|
||||||
|
On ajoute seulement les tokens hors échelle : ombres, police mono, animations. */
|
||||||
|
@theme {
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
|
--shadow-hero: 0 30px 80px -30px rgb(0 0 0 / 0.85);
|
||||||
|
--shadow-card: 0 24px 60px -30px rgb(0 0 0 / 0.8);
|
||||||
|
--shadow-phone: 0 30px 70px -28px rgb(0 0 0 / 0.9);
|
||||||
|
--shadow-toast: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
||||||
|
--shadow-toast-sm: 0 10px 24px -12px rgb(0 0 0 / 0.6);
|
||||||
|
|
||||||
|
--animate-blink: blink 1.05s steps(1) infinite;
|
||||||
|
--animate-pulse-sky: pulseSky 2s ease-in-out infinite;
|
||||||
|
--animate-pulse-amber: pulseAmber 2.2s ease-in-out infinite;
|
||||||
|
--animate-toast-in: toastIn 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
--animate-dlg-in: dlgIn 0.35s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono (Open Font License) : @font-face fournis par @fontsource,
|
||||||
|
importés dans main.ts et bundlés par Vite (self-host, pas de CDN Google). */
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #09090b;
|
||||||
|
color: #f4f4f5;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background: rgba(16, 185, 129, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
48% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
49%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes pulseSky {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.55);
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(125, 211, 252, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes pulseAmber {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(252, 211, 77, 0.55);
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(252, 211, 77, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes dlgIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* Tailwind v4 ne met plus cursor:pointer sur les boutons → on le rétablit. */
|
||||||
|
button:not(:disabled),
|
||||||
|
[role='button']:not([aria-disabled='true']) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Révélation au scroll (directive v-reveal) */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(22px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s ease,
|
||||||
|
transform 0.7s ease;
|
||||||
|
}
|
||||||
|
.reveal.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/site/tsconfig.json
Normal file
16
packages/site/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"useDefineForClassFields": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
15
packages/site/vite.config.ts
Normal file
15
packages/site/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
// Site vitrine statique (git-arboretum.com) : aucune dépendance au daemon, donc
|
||||||
|
// pas de proxy /api /ws (contrairement à packages/web). `base: '/'` car servi
|
||||||
|
// à la racine d'un domaine dédié.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
base: '/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
target: 'es2020',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
|
||||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
||||||
<header class="flex items-center gap-2">
|
|
||||||
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
|
||||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
|
||||||
</header>
|
|
||||||
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
|
||||||
|
|
||||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('crossRepo.branchLabel') }}
|
|
||||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-wrap items-end gap-3">
|
|
||||||
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
|
||||||
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('crossRepo.baseRefLabel') }}
|
|
||||||
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('crossRepo.startLabel') }}
|
|
||||||
<select v-model="startSession" class="input">
|
|
||||||
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
|
||||||
<option value="claude">claude</option>
|
|
||||||
<option value="bash">bash</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('crossRepo.reposLabel') }}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
v-for="repo in repos"
|
|
||||||
:key="repo.id"
|
|
||||||
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
|
||||||
>
|
|
||||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
|
||||||
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
|
||||||
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
|
||||||
{{ statusText(repo.id) }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
|
||||||
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
|
||||||
</button>
|
|
||||||
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
|
||||||
{{ t('crossRepo.retryFailed') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import type { RepoSummary } from '@arboretum/shared';
|
|
||||||
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
|
||||||
|
|
||||||
const props = defineProps<{ repos: RepoSummary[] }>();
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const groups = useGroupsStore();
|
|
||||||
|
|
||||||
const branch = ref('');
|
|
||||||
const newBranch = ref(true);
|
|
||||||
const baseRef = ref('');
|
|
||||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
|
||||||
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
|
||||||
const results = ref<Record<string, CrossRepoResult>>({});
|
|
||||||
const running = ref(false);
|
|
||||||
|
|
||||||
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
|
||||||
|
|
||||||
function statusClass(repoId: string): string {
|
|
||||||
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
|
||||||
}
|
|
||||||
function statusText(repoId: string): string {
|
|
||||||
const r = results.value[repoId];
|
|
||||||
if (!r) return '';
|
|
||||||
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(repoIds: string[]): Promise<void> {
|
|
||||||
if (repoIds.length === 0) return;
|
|
||||||
running.value = true;
|
|
||||||
for (const id of repoIds) delete results.value[id];
|
|
||||||
try {
|
|
||||||
await groups.createCrossRepoFeature(
|
|
||||||
repoIds,
|
|
||||||
{
|
|
||||||
branch: branch.value.trim(),
|
|
||||||
newBranch: newBranch.value,
|
|
||||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
|
||||||
startSession: startSession.value,
|
|
||||||
},
|
|
||||||
(result) => {
|
|
||||||
results.value = { ...results.value, [result.repoId]: result };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
running.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmit(): void {
|
|
||||||
void run([...selectedRepoIds.value]);
|
|
||||||
}
|
|
||||||
function retryFailed(): void {
|
|
||||||
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
148
packages/web/src/components/GroupSessionModal.vue
Normal file
148
packages/web/src/components/GroupSessionModal.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||||
|
</header>
|
||||||
|
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
|
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.modeLabel') }}
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
<input v-model="mode" type="radio" value="feature" :disabled="running" /> {{ t('crossRepo.modeFeature') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
<input v-model="mode" type="radio" value="main" :disabled="running" /> {{ t('crossRepo.modeMain') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mode feature : branche commune créée dans chaque repo -->
|
||||||
|
<template v-if="mode === 'feature'">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.branchLabel') }}
|
||||||
|
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||||
|
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
|
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.commandLabel') }}
|
||||||
|
<select v-model="command" class="input">
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.reposLabel') }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-for="repo in repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
||||||
|
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
||||||
|
{{ repoStatus(repo.id) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
|
||||||
|
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
||||||
|
{{ running ? t('crossRepo.launching') : t('crossRepo.launch') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||||
|
|
||||||
|
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
const mode = ref<'feature' | 'main'>('feature');
|
||||||
|
const branch = ref('');
|
||||||
|
const newBranch = ref(true);
|
||||||
|
const baseRef = ref('');
|
||||||
|
const command = ref<'claude' | 'bash'>('claude');
|
||||||
|
const wtResults = ref<Record<string, CrossRepoResult>>({});
|
||||||
|
const outcome = ref<GroupFeatureOutcome | null>(null);
|
||||||
|
const errorMsg = ref<string | null>(null);
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
|
const skippedById = computed<Record<string, string>>(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const s of outcome.value?.skipped ?? []) m[s.repoId] = s.reason;
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
function repoStatus(repoId: string): string {
|
||||||
|
const wt = wtResults.value[repoId];
|
||||||
|
if (wt) return wt.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${wt.message ?? ''}`;
|
||||||
|
const skip = skippedById.value[repoId];
|
||||||
|
if (skip) return `${t('crossRepo.skippedLabel')}: ${skip}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function repoStatusClass(repoId: string): string {
|
||||||
|
const wt = wtResults.value[repoId];
|
||||||
|
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
||||||
|
return skippedById.value[repoId] ? 'text-amber-400' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
if (running.value || props.repos.length === 0) return;
|
||||||
|
if (mode.value === 'feature' && branch.value.trim() === '') return;
|
||||||
|
running.value = true;
|
||||||
|
wtResults.value = {};
|
||||||
|
outcome.value = null;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
outcome.value = await groups.createGroupFeature(
|
||||||
|
props.groupId,
|
||||||
|
props.repos.map((r) => r.id),
|
||||||
|
{
|
||||||
|
command: command.value,
|
||||||
|
...(mode.value === 'feature'
|
||||||
|
? { branch: branch.value.trim(), newBranch: newBranch.value, ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
(result) => {
|
||||||
|
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
running.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
|
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
|
||||||
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</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">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.new') }}</BaseButton>
|
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.startWork') }}</BaseButton>
|
||||||
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
|
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -18,7 +18,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form v-if="creating" class="card-inset flex flex-wrap items-end gap-2" @submit.prevent="onCreate">
|
<form v-if="creating" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
|
||||||
|
<!-- mode : créer un worktree dédié, ou bosser directement sur le checkout principal -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
|
||||||
|
{{ t('worktrees.modeLabel') }}
|
||||||
|
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" /> {{ t('worktrees.modeWorktree') }}</label>
|
||||||
|
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" /> {{ t('worktrees.modeMain') }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end gap-2">
|
||||||
|
<!-- mode worktree : branche + créer/existante -->
|
||||||
|
<template v-if="mode === 'worktree'">
|
||||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('worktrees.branch') }}
|
{{ t('worktrees.branch') }}
|
||||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
||||||
@@ -32,9 +42,34 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
|
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
|
||||||
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="branch.trim() === ''">
|
</template>
|
||||||
{{ t('worktrees.create') }}
|
|
||||||
|
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
|
||||||
|
<template v-else>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('worktrees.branchModeLabel') }}
|
||||||
|
<select v-model="mainBranchMode" class="input">
|
||||||
|
<option value="current">{{ t('worktrees.currentBranch') }}</option>
|
||||||
|
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="mainBranchMode === 'new'" 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="feature/…" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('worktrees.commandLabel') }}
|
||||||
|
<select v-model="mainCommand" class="input">
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="!canSubmit">
|
||||||
|
{{ mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
@@ -66,20 +101,37 @@ const toasts = useToastsStore();
|
|||||||
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
|
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
|
||||||
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
|
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
|
const mode = ref<'worktree' | 'main'>('worktree');
|
||||||
const branch = ref('');
|
const branch = ref('');
|
||||||
const newBranch = ref(true);
|
const newBranch = ref(true);
|
||||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||||
|
const mainBranchMode = ref<'current' | 'new'>('current');
|
||||||
|
const mainCommand = ref<'claude' | 'bash'>('claude');
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
|
||||||
|
);
|
||||||
|
|
||||||
async function onCreate(): Promise<void> {
|
async function onCreate(): Promise<void> {
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
|
if (mode.value === 'worktree') {
|
||||||
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
|
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
|
||||||
|
toasts.success(t('toast.worktreeCreated'));
|
||||||
|
} else {
|
||||||
|
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
|
||||||
|
await store.startMainSession(props.repo.id, {
|
||||||
|
command: mainCommand.value,
|
||||||
|
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
|
||||||
|
});
|
||||||
|
toasts.success(t('toast.sessionCreated'));
|
||||||
|
}
|
||||||
branch.value = '';
|
branch.value = '';
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
toasts.success(t('toast.worktreeCreated'));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||||
<SessionStateBadge :session="session" />
|
<SessionStateBadge :session="session" />
|
||||||
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="spannedCount > 1"
|
||||||
|
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
|
||||||
|
:title="spannedDirs.join('\n')"
|
||||||
|
>
|
||||||
|
{{ t('groups.spanBadge', spannedCount) }}
|
||||||
|
</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||||
</header>
|
</header>
|
||||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||||
@@ -15,6 +22,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import SessionStateBadge from './SessionStateBadge.vue';
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
@@ -23,6 +31,11 @@ import TerminalView from './TerminalView.vue';
|
|||||||
|
|
||||||
const props = defineProps<{ session: SessionSummary }>();
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
|
||||||
|
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
|
||||||
|
const spannedCount = computed(() => spannedDirs.value.length);
|
||||||
|
|
||||||
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user