diff --git a/.gitea/scripts/attach-release-assets.sh b/.gitea/scripts/attach-release-assets.sh new file mode 100755 index 0000000..6ed2561 --- /dev/null +++ b/.gitea/scripts/attach-release-assets.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Attache des fichiers à une release Gitea, de façon idempotente (re-run friendly). +# +# Usage : attach-release-assets.sh +# Env : RELEASE_TOKEN (token Gitea avec write:repository), GITHUB_SERVER_URL, GITHUB_REPOSITORY. +# +# Partagé par tous les jobs de release desktop (Linux, Windows, canal flottant) : la logique était +# dupliquée dans chaque job, et toute correction devait être faite trois fois. +set -uo pipefail + +tag="${1:?tag manquant}" +release_name="${2:?nom de release manquant}" +shift 2 + +if [ -z "${RELEASE_TOKEN:-}" ]; then + echo "::notice::RELEASE_TOKEN absent, aucun asset attaché (les artefacts du run restent disponibles)." + exit 0 +fi + +api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" +auth="Authorization: token ${RELEASE_TOKEN}" + +release_id=$(curl -fsSL -H "$auth" "${api}/releases/tags/${tag}" \ + | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''" || true) + +if [ -z "$release_id" ]; then + release_id=$(curl -fsSL -X POST -H "$auth" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"${tag}\",\"name\":\"${release_name}\"}" \ + "${api}/releases" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''") +fi + +if [ -z "$release_id" ]; then + echo "::warning::impossible de résoudre ou créer la release ${tag}" + exit 0 +fi + +for f in "$@"; do + [ -f "$f" ] || continue + name=$(basename "$f") + # L'API Gitea refuse un asset de même nom : on supprime l'ancien pour que le dernier build gagne. + existing=$(curl -fsSL -H "$auth" "${api}/releases/${release_id}/assets" \ + | node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));const m=Array.isArray(a)?a.find(x=>x.name===process.argv[1]):null;process.stdout.write(m?String(m.id):'')" "$name" || true) + if [ -n "$existing" ]; then + echo "replacing existing $name (asset $existing)" + curl -fsSL -X DELETE -H "$auth" "${api}/releases/${release_id}/assets/${existing}" || true + fi + echo "attaching $name" + curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${release_id}/assets?name=${name}" +done + +echo "Assets attachés à la release ${tag}." diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 66dedbf..4f97ec4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,6 +29,12 @@ jobs: - run: npm run build - run: npm run build:site - run: npx vitest run + # packages/desktop est HORS des workspaces (CI daemon allégée) : sans cette étape, son code + # n'était JAMAIS typechecké avant un tag de release. + - name: Typecheck desktop shell + run: | + npm --prefix packages/desktop ci + npm run typecheck:desktop pack-smoke: name: Pack & boot smoke (Node 22) @@ -86,15 +92,16 @@ jobs: lint-dashes: # Interdit tout tiret cadratin (U+2014) ou demi-cadratin (U+2013) dans les fichiers suivis. # Utiliser a la place : point median, deux-points, virgule, ou tiret simple pour les plages. - # Exclusions : logo binaire + captures brutes du terminal (fidelite des fixtures de dialogue). + # `-I` ignore les fichiers BINAIRES : une icone PNG/ICO peut contenir ces octets par hasard, ce + # qui faisait echouer la garde sans aucun texte fautif. Exclusion restante : les captures brutes + # du terminal (fichiers texte, fidelite des fixtures de dialogue). name: No em/en dashes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Fail on U+2014 / U+2013 (outside allow-list) run: | - if git grep -nP '[\x{2014}\x{2013}]' -- . \ - ':(exclude)brand/arboretum-logo-source.png' \ + if git grep -nPI '[\x{2014}\x{2013}]' -- . \ ':(exclude)packages/server/test/fixtures/dialogs/*.raw'; then echo "::error::Tiret cadratin/demi-cadratin trouve. Utiliser point median, deux-points, virgule ou tiret simple (plages)." exit 1 diff --git a/.gitea/workflows/desktop-release.yml b/.gitea/workflows/desktop-release.yml index afe5fad..3907eec 100644 --- a/.gitea/workflows/desktop-release.yml +++ b/.gitea/workflows/desktop-release.yml @@ -1,12 +1,23 @@ -# Packaging de l'app de bureau Electron, déclenché UNIQUEMENT par un tag desktop-vX.Y.Z (séparé de -# la release du daemon qui écoute v*, et du VSIX qui écoute vscode-v*). Linux (AppImage + deb) est -# automatisé ici ; Windows et macOS se buildent sur ces OS (voir packages/desktop/README.md) et -# leurs artefacts sont attachés manuellement à la release. +# Packaging de l'app de bureau Electron, déclenché par un tag desktop-vX.Y.Z (séparé de la release du +# daemon qui écoute v*, et du VSIX qui écoute vscode-v*). +# +# Linux (AppImage + deb) : toujours automatisé. +# Windows (NSIS + portable) : job dédié, ACTIVÉ par la variable de dépôt ENABLE_WINDOWS_BUILD=true une +# fois qu'un runner labellisé `windows-latest` est enregistré sur le forge. Procédure complète dans +# docs/CI_RUNNERS.md. Tant que la variable est absente, le job est sauté et la release Linux part +# normalement ; le repli reste un build manuel attaché à la release. +# Le cross-build depuis Linux est IMPOSSIBLE : node-pty ne copie conpty.dll / OpenConsole.exe que si +# l'hôte de build est Windows, et son tarball ne contient que les prebuilds linux. +# macOS (dmg + zip) : non automatisé (aucun runner) ; build manuel documenté dans le README desktop. +# +# `workflow_dispatch` permet de tester les jobs sans créer de tag (le garde-fou tag == version est +# alors ignoré, puisqu'il n'y a pas de tag à comparer). name: Desktop Release on: push: tags: ['desktop-v*'] + workflow_dispatch: permissions: contents: write @@ -26,6 +37,7 @@ jobs: cache: npm # Garde-fou : le tag (sans "desktop-v") doit correspondre à la version du paquet desktop. - name: Verify tag matches desktop version + if: github.event_name == 'push' run: | pkg=$(node -p "require('./packages/desktop/package.json').version") tag="${GITHUB_REF_NAME#desktop-v}" @@ -41,6 +53,11 @@ jobs: # Build complet : shell + daemon empaqueté + Node standalone + AppImage/deb (electron-builder). - name: Build installers run: cd packages/desktop && npm run dist:linux + - name: Compute checksums + run: | + cd packages/desktop/release + sha256sum *.AppImage *.deb > SHA256SUMS-linux.txt + cat SHA256SUMS-linux.txt # Artefacts du run : canal fiable, indépendant de l'API release. - uses: actions/upload-artifact@v3 with: @@ -48,40 +65,134 @@ jobs: path: | packages/desktop/release/*.AppImage packages/desktop/release/*.deb + packages/desktop/release/*.blockmap packages/desktop/release/latest-linux.yml - # Best-effort : attache les installeurs (+ latest-linux.yml pour l'auto-update) à la release - # Gitea du tag (crée la release si absente). Réutilise NPM_TOKEN (même token Gitea) : ce token - # doit porter la portée write:repository en plus de write:package, sinon l'API release renvoie - # un 403 (l'attache est ignorée, les installeurs restent disponibles en artefact du run). - - name: Attach installers to Gitea release + packages/desktop/release/SHA256SUMS-linux.txt + # Best-effort : attache les installeurs (+ latest-linux.yml pour l'auto-update) à la release du + # tag. Réutilise NPM_TOKEN (même token Gitea) : il doit porter write:repository en plus de + # write:package, sinon l'API release renvoie 403 (les artefacts du run restent disponibles). + - name: Attach installers to the tag release + if: github.event_name == 'push' continue-on-error: true + env: + RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + version=$(node -p "require('./packages/desktop/package.json').version") + bash .gitea/scripts/attach-release-assets.sh "${GITHUB_REF_NAME}" "Arboretum Desktop ${version}" \ + packages/desktop/release/*.AppImage \ + packages/desktop/release/*.deb \ + packages/desktop/release/*.blockmap \ + packages/desktop/release/latest-linux.yml \ + packages/desktop/release/SHA256SUMS-linux.txt + + windows: + name: Build Windows (NSIS + portable) + # Activé par la variable de dépôt ENABLE_WINDOWS_BUILD (voir docs/CI_RUNNERS.md). Sans runner + # Windows enregistré, un job non conditionné resterait en attente indéfiniment et bloquerait la + # release entière. + if: vars.ENABLE_WINDOWS_BUILD == 'true' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Verify tag matches desktop version + if: github.event_name == 'push' + shell: bash + run: | + pkg=$(node -p "require('./packages/desktop/package.json').version") + tag="${GITHUB_REF_NAME#desktop-v}" + if [ "$pkg" != "$tag" ]; then + echo "ERREUR: tag '$tag' != version desktop '$pkg'" + exit 1 + fi + echo "OK: tag $tag == version $pkg" + - run: npm ci + - name: Install desktop deps + shell: bash + run: cd packages/desktop && npm ci + # `dist:win` sur hôte Windows : c'est le SEUL chemin qui produit un node-pty utilisable (ConPTY, + # conpty.dll + OpenConsole.exe copiés par le post-install de node-pty). + - name: Build installers + shell: bash + run: cd packages/desktop && npm run dist:win + - name: Compute checksums + shell: bash + run: | + cd packages/desktop/release + sha256sum *.exe > SHA256SUMS-windows.txt + cat SHA256SUMS-windows.txt + - uses: actions/upload-artifact@v3 + with: + name: desktop-windows + path: | + packages/desktop/release/*.exe + packages/desktop/release/*.blockmap + packages/desktop/release/latest.yml + packages/desktop/release/SHA256SUMS-windows.txt + - name: Attach installers to the tag release + if: github.event_name == 'push' + continue-on-error: true + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + version=$(node -p "require('./packages/desktop/package.json').version") + bash .gitea/scripts/attach-release-assets.sh "${GITHUB_REF_NAME}" "Arboretum Desktop ${version}" \ + packages/desktop/release/*.exe \ + packages/desktop/release/*.blockmap \ + packages/desktop/release/latest.yml \ + packages/desktop/release/SHA256SUMS-windows.txt + + # Canal d'auto-update : electron-updater interroge une URL FIXE + # (.../releases/download/desktop-latest, cf. electron-builder.yml). Ce tag flottant doit donc exister + # et porter les latest*.yml de la dernière version, sinon l'updater reçoit un 404 · c'était le cas + # jusqu'en 0.1.3, où l'auto-update annoncé ne fonctionnait pour personne. + latest-channel: + name: Publish floating desktop-latest release + if: github.event_name == 'push' + runs-on: ubuntu-latest + needs: [linux] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: actions/download-artifact@v3 + with: + name: desktop-linux + path: dl + # Les artefacts Windows n'existent que si le job correspondant a tourné : téléchargement toléré + # en échec pour ne jamais bloquer la publication du canal Linux. + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: desktop-windows + path: dl + # On repart d'une release flottante VIERGE : sinon les assets de la version précédente y + # restent (mêmes noms de fichiers uniquement remplacés, un ancien numéro de version subsisterait). + # La recréation est faite par le script suivant, via l'API (Gitea crée le tag au besoin). + - name: Reset the floating release env: RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }} run: | if [ -z "$RELEASE_TOKEN" ]; then - echo "::notice::NPM_TOKEN absent : installeurs disponibles en artefact uniquement." + echo "::notice::NPM_TOKEN absent, canal desktop-latest non publié." exit 0 fi api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" auth="Authorization: token ${RELEASE_TOKEN}" - version=$(node -p "require('./packages/desktop/package.json').version") - rid=$(curl -fsSL -H "$auth" "${api}/releases/tags/${GITHUB_REF_NAME}" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''" || true) - if [ -z "$rid" ]; then - rid=$(curl -fsSL -X POST -H "$auth" -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"Arboretum Desktop ${version}\"}" \ - "${api}/releases" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''") + old=$(curl -fsSL -H "$auth" "${api}/releases/tags/desktop-latest" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''" || true) + if [ -n "$old" ]; then + echo "suppression de l'ancienne release flottante (id ${old})" + curl -fsSL -X DELETE -H "$auth" "${api}/releases/${old}" || true fi - for f in packages/desktop/release/*.AppImage packages/desktop/release/*.deb packages/desktop/release/latest-linux.yml; do - [ -f "$f" ] || continue - name=$(basename "$f") - # Re-run idempotent : supprimer un asset existant du même nom avant de ré-uploader, pour - # que le dernier build gagne (l'API Gitea refuse sinon un asset déjà présent). - existing=$(curl -fsSL -H "$auth" "${api}/releases/${rid}/assets" | node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));const m=Array.isArray(a)?a.find(x=>x.name===process.argv[1]):null;process.stdout.write(m?String(m.id):'')" "$name" || true) - if [ -n "$existing" ]; then - echo "replacing existing $name (asset $existing)" - curl -fsSL -X DELETE -H "$auth" "${api}/releases/${rid}/assets/${existing}" || true - fi - echo "attaching $name" - curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${rid}/assets?name=${name}" - done - echo "Installeurs Linux attachés à la release ${GITHUB_REF_NAME}." + curl -fsSL -X DELETE -H "$auth" "${api}/tags/desktop-latest" || true + - name: Attach installers to the floating release + env: + RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + version=$(node -p "require('./packages/desktop/package.json').version") + bash .gitea/scripts/attach-release-assets.sh desktop-latest "Arboretum Desktop (latest, ${version})" \ + dl/*.AppImage dl/*.deb dl/*.exe dl/*.blockmap dl/latest-linux.yml dl/latest.yml dl/SHA256SUMS-*.txt diff --git a/README.fr.md b/README.fr.md index ea1ef56..fb9401e 100644 --- a/README.fr.md +++ b/README.fr.md @@ -129,6 +129,19 @@ Vous préférez une app native au daemon-dans-un-terminal ? Arboretum fournit un L'app de bureau n'est qu'une coquille autour du même daemon et de la même interface web : tout ce qui suit (espace de travail, git, sessions) fonctionne à l'identique. +### Installer selon la plateforme + +| Plateforme | Artefact | Notes | +|---|---|---| +| **Debian / Ubuntu** | `Arboretum--amd64.deb` | `sudo apt install ./Arboretum-*.deb`. Installe la dépendance `git`. À préférer à l'AppImage sous Debian : il pose l'entrée de lanceur et ses icônes. | +| **Autres Linux** | `Arboretum--x86_64.AppImage` | `chmod +x` puis lancer. Aucune entrée de menu sans un outil d'intégration comme `appimaged`. | +| **Windows** | `Arboretum--x64.exe` (NSIS) ou le build portable | Non signé : SmartScreen affiche « éditeur inconnu », choisissez **Informations complémentaires → Exécuter quand même**. Nécessite Windows 10 1809+ (ConPTY). | +| **macOS** | `Arboretum-.dmg` | Ni signé ni notarisé : clic droit sur l'app → **Ouvrir**, ou `xattr -dr com.apple.quarantine /Applications/Arboretum.app`. Buildé à la demande, voir `packages/desktop/README.md`. | + +Sous Windows aussi, le CLI `claude` doit être dans votre PATH ; si l'app ne le trouve pas, renseignez son +chemin dans **Réglages → CLI Claude**. Le lancement du daemon à l'ouverture de session y est également +géré (`arboretum install` enregistre une tâche planifiée). + ## Utiliser Arboretum 1. **Ajoutez un dépôt.** Depuis le dashboard, enregistrez un repo git local par son chemin. Configurez éventuellement des **hooks post-création** (ex. `npm ci`, `cp ../.env .env`) exécutés automatiquement à chaque création d'un nouveau worktree pour ce repo. @@ -164,10 +177,10 @@ Elle est distribuée en **VSIX privé**. Buildez-la et packagez-la depuis le mon ```bash npm run build:vscode -cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.3.0.vsix +cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-.vsix ``` -Puis installez-la via **Extensions : Installer à partir d'un VSIX…** (ou `code --install-extension git-arboretum-0.3.0.vsix`), lancez **Arboretum: Sign In** et collez un token. Détails complets dans [`packages/vscode/README.md`](packages/vscode/README.md). +Puis installez-la via **Extensions : Installer à partir d'un VSIX…** (ou `code --install-extension git-arboretum-.vsix`), lancez **Arboretum: Sign In** et collez un token. Détails complets dans [`packages/vscode/README.md`](packages/vscode/README.md). ## Accès distant depuis votre téléphone @@ -188,6 +201,47 @@ Ouvrez `https://..ts.net` depuis n'importe quel appareil de vo > ⚠️ Un terminal web, c'est de l'exécution de code à distance **par conception**. N'exposez jamais Arboretum directement sur l'internet public. +### Mode serveur web (réseau local, reverse proxy) + +Quel que soit le front que vous mettez devant, retenez la règle qui piège tout le monde en premier : **le +daemon rejette toute requête dont il ne connaît pas l'`Origin`**, avec un `403 BAD_ORIGIN`. L'adresse que +vous tapez dans le navigateur doit être passée en `--allow-origin` (répétable). Réglages → **Accès +distant** affiche l'origine courante, la liste autorisée, et la commande exacte pour en ajouter une. + +**Derrière un reverse proxy** (nginx, Caddy, Traefik), avec terminaison TLS sur votre domaine : + +```nginx +# nginx : l'upgrade WebSocket ET X-Forwarded-Proto sont nécessaires +location / { + proxy_pass http://127.0.0.1:7317; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; # rend le cookie de session Secure + proxy_read_timeout 3600s; # terminaux longue durée +} +``` + +```bash +npx @johanleroy/git-arboretum --allow-origin https://arboretum.exemple.com +``` + +C'est `X-Forwarded-Proto: https` qui indique à Arboretum de marquer son cookie de session `Secure` ; sans +cet en-tête, le cookie reste non-Secure derrière votre front HTTPS. Gardez un timeout de lecture large : +un WebSocket de terminal reste inactif de longs moments. + +**Sur le réseau local, sans proxy** (le moins recommandé : HTTP simple, pas de Web Push, pas d'install PWA) : + +```bash +npx @johanleroy/git-arboretum \ + --bind 0.0.0.0 --i-know-this-exposes-a-terminal \ + --allow-origin http://192.168.1.42:7317 +``` + +Le flag d'acquittement est obligatoire et n'est jamais ajouté pour vous : sortir de la boucle locale doit +être un acte délibéré. Restreignez l'accès au niveau réseau (pare-feu, VPN) et préférez Tailscale. + ## Le faire tourner en service d'arrière-plan Le plus rapide pour faire tourner Arboretum en service qui survit à la déconnexion et redémarre au boot, c'est l'installeur intégré. Installez une version figée globalement, puis lancez `install`. Il détecte votre OS, écrit le fichier de service, le démarre et affiche le token unique : @@ -197,7 +251,7 @@ npm i -g @johanleroy/git-arboretum arboretum install --allow-origin https://MACHINE.TAILNET.ts.net ``` -Cela met en place un **service systemd utilisateur** sous Linux (`~/.config/systemd/user/arboretum.service`) ou un **LaunchAgent launchd** sous macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Tous les flags du daemon (`--port`, `--allow-origin`, `--db`, …) sont propagés au service. Gérez-le avec : +Cela met en place un **service systemd utilisateur** sous Linux (`~/.config/systemd/user/arboretum.service`), un **LaunchAgent launchd** sous macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`), ou une **tâche planifiée** sous Windows (`Arboretum`, déclenchée à l'ouverture de session, enregistrée par `schtasks`). Toujours sous votre compte utilisateur, jamais en root ni SYSTEM. Tous les flags du daemon (`--port`, `--allow-origin`, `--db`, …) sont propagés au service. Gérez-le avec : ```bash arboretum status # état du service (+ où lire les logs) @@ -253,7 +307,9 @@ Les options du daemon sont des flags CLI : | `--allow-origin ` | aucun | Origine `Origin` autorisée supplémentaire (répétable). Nécessaire pour l'accès Tailscale/HTTPS. | | `--db ` | `/arboretum.db` | Chemin de la base SQLite. | | `--vapid-contact ` | `mailto:arboretum@localhost` | Sujet de contact VAPID pour le Web Push. | -| `--print-token` | `false` | Indication sur le réaffichage du token (les tokens sont hashés et ne peuvent pas être réaffichés). | +| `--print-token` | `false` | Affiche le jeton d'accès au démarrage (et le crée si la base n'en a aucun). | +| `--claude-home ` | `~/.claude` | Surcharge la racine d'installation de Claude (registre de sessions et transcripts). | +| `--no-discover` | `false` | Désactive la découverte auto des dépôts (scan au démarrage et re-scan périodique). | | `--i-know-this-exposes-a-terminal` | `false` | Reconnaître le bind sur une adresse non-loopback. **À éviter** : préférez Tailscale Serve. | `arboretum install` accepte tous les flags du daemon ci-dessus (propagés tels quels au service), plus : @@ -265,7 +321,18 @@ Les options du daemon sont des flags CLI : | `--dry-run` | Affiche le unit/plist et les commandes sans rien appliquer. | | `--no-enable` | Écrit le fichier de service sans l'activer/le démarrer. | -L'état (la base SQLite) vit dans `$XDG_DATA_HOME/arboretum` (par défaut `~/.local/share/arboretum`). +L'état (la base SQLite) vit dans `$XDG_DATA_HOME/arboretum`, avec pour défaut +`~/.local/share/arboretum` sous Linux et macOS, et `%APPDATA%\arboretum` sous Windows. + +Variables d'environnement : + +| Variable | Utilisée par | Description | +|---|---|---| +| `ARBORETUM_LOG` | daemon | Niveau de log (`fatal`, `error`, `warn`, `info`, `debug`, `trace`). Défaut `info`. | +| `ARBORETUM_SECRET_KEY` | daemon | Clé de 32 octets (base64 ou hex) chiffrant les identifiants git stockés. Générée et conservée en base si absente. | +| `ARBORETUM_EMIT_TOKEN_FD` | daemon | Écrit le jeton d'accès sur ce descripteur de fichier au démarrage. Utilisé par l'app de bureau pour s'auto-connecter ; pas destiné à un usage manuel. | +| `XDG_DATA_HOME` | daemon | Racine du répertoire de données (voir ci-dessus). | +| `ARBORETUM_SHELL` | daemon (Windows) | Shell utilisé pour les commandes de projet. Défaut `powershell.exe`. | Les réglages au-delà des flags CLI (les répertoires qu'Arboretum scanne pour trouver des repos et à quelle fréquence, le chemin et le home du binaire `claude`, et les fenêtres de rétention / purge des sessions) vivent dans les **Réglages** de l'UI. Ils sont diffusés via le WebSocket, donc chaque navigateur connecté reflète un changement en temps réel, sans rechargement. @@ -291,6 +358,19 @@ Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareil 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é. +## Dépannage + +| Symptôme | Cause & correction | +|---|---| +| `npm error 404 Not Found @johanleroy/git-arboretum` | Le paquet vit sur un registre privé. Déclarez le scope dans votre `~/.npmrc` : `@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/` | +| `403 BAD_ORIGIN` dans la console, interface blanche | L'adresse utilisée n'est pas dans la liste autorisée. Redémarrez avec `--allow-origin ` (schéma, hôte et port doivent correspondre). | +| `ERR_UNKNOWN_BUILTIN_MODULE node:sqlite` ou plantage au démarrage | Node est antérieur à 22.16. Vérifiez avec `node --version` : `node:sqlite` n'est stable qu'à partir de là. L'app de bureau embarque son runtime et n'est pas concernée. | +| « Claude Code CLI not found in PATH » | Le daemon tourne avec un PATH minimal (cas typique sous systemd/launchd). Renseignez le chemin du binaire dans **Réglages → CLI Claude**, ou réinstallez le service avec `arboretum install`, qui fige votre PATH interactif. | +| Impossible d'activer les notifications | Le Web Push exige HTTPS. Utilisez Tailscale Serve ou un reverse proxy ; sur iOS, installez d'abord la PWA. | +| Le lanceur affiche une icône générique (Linux) | Corrigé en desktop 0.2.0 : les paquets antérieurs installaient une taille d'icône non standard, ignorée par la spécification freedesktop. Mettez le `.deb` à jour ; si l'icône persiste, lancez `gtk-update-icon-cache -f /usr/share/icons/hicolor` ou reconnectez-vous. | +| SmartScreen ou Gatekeeper bloque l'app | Attendu : les binaires ne sont pas signés. Voir le tableau par plateforme plus haut. | +| Un terminal reste vide après « Démarrer le projet » | La commande a été tapée dans un shell de login qui n'a pas démarré. Regardez l'onglet : le shell survit volontairement à l'échec, l'erreur y est donc visible. | + ## Ce qui le distingue | | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control | @@ -334,8 +414,17 @@ node packages/server/scripts/acceptance-p9.mjs # commit/push avancé : staging node packages/server/scripts/acceptance-p10.mjs # archivage automatique des sessions node packages/server/scripts/acceptance-p11.mjs # synchronisation des réglages en temps réel node packages/server/scripts/acceptance-p12.mjs # services git distants + clone HTTPS +node packages/server/scripts/acceptance-p13.mjs # démarrer le projet : commandes de lancement, multi-terminaux +node packages/server/scripts/acceptance-p14.mjs # temps réel armé : watcher épinglé par session, corrélation cwd +node packages/server/scripts/acceptance-p15.mjs # historisation : log de commits & diff par commit ``` +Contrôle de rendu (Chromium headless via CDP, sans Playwright) : après `npm run build`, lancez +`node packages/server/scripts/copy-web.mjs` puis +`node packages/server/scripts/verify-ui.mjs [dossier]`. Le script démarre un daemon isolé, crée un dépôt +de démonstration et écrit des captures de l'IDE dans les deux thèmes, en largeurs desktop et mobile, en +échouant sur toute erreur console. + Le protocole s'est enrichi (de façon additive, sans bump de version) pour porter ces nouveautés : messages client `watch` / `unwatch` et signal ciblé `worktree_changes` (P7), plus les broadcasts `session_archived` (P10), `settings_update` (P11) et `clone_update` (P12). Côté serveur, le tout s'appuie sur `core/git.ts` (le moteur git pur), `core/fs-watcher.ts` (chokidar), `core/git-credentials.ts` + `core/clone-manager.ts` (identifiants chiffrés & clone), et les services d'archivage de sessions et de réglages. ## Soutenir le projet diff --git a/README.md b/README.md index 19bc22c..e834edb 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,19 @@ Prefer a native app to the daemon-in-a-terminal? Arboretum ships an **Electron d The desktop app is just a shell around the same daemon and web UI, so everything below (workspace, git, sessions) works identically. +### Installing per platform + +| Platform | Artifact | Notes | +|---|---|---| +| **Debian / Ubuntu** | `Arboretum--amd64.deb` | `sudo apt install ./Arboretum-*.deb`. Pulls in `git`. Preferred over the AppImage on Debian: it installs the launcher entry and its icons. | +| **Other Linux** | `Arboretum--x86_64.AppImage` | `chmod +x` then run. No desktop entry unless you use a tool like `appimaged`. | +| **Windows** | `Arboretum--x64.exe` (NSIS) or the portable build | Not code-signed: SmartScreen shows "unknown publisher", choose **More info → Run anyway**. Needs Windows 10 1809+ (ConPTY). | +| **macOS** | `Arboretum-.dmg` | Not signed or notarized: right-click the app → **Open**, or `xattr -dr com.apple.quarantine /Applications/Arboretum.app`. Built on demand, see `packages/desktop/README.md`. | + +Windows also needs the `claude` CLI on your PATH like any other platform; if the app cannot find it, +set its path in **Settings → Claude CLI**. Running the daemon at logon is supported there too +(`arboretum install` registers a scheduled task). + ## Using Arboretum 1. **Add a repository.** From the dashboard, register a local git repo by its path. Optionally configure **post-create hooks** (e.g. `npm ci`, `cp ../.env .env`) that run automatically every time you create a new worktree for that repo. @@ -164,10 +177,10 @@ It is distributed as a **private VSIX**. Build and package it from the monorepo: ```bash npm run build:vscode -cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.3.0.vsix +cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-.vsix ``` -Then install it via **Extensions: Install from VSIX…** (or `code --install-extension git-arboretum-0.3.0.vsix`), run **Arboretum: Sign In** and paste a token. Full details in [`packages/vscode/README.md`](packages/vscode/README.md). +Then install it via **Extensions: Install from VSIX…** (or `code --install-extension git-arboretum-.vsix`), run **Arboretum: Sign In** and paste a token. Full details in [`packages/vscode/README.md`](packages/vscode/README.md). ## Remote access from your phone @@ -188,6 +201,47 @@ Open `https://..ts.net` from any device on your tailnet. **Web > ⚠️ A web terminal is remote code execution **by design**. Never expose Arboretum directly to the public internet. +### Web server mode (LAN, reverse proxy) + +Whatever front you put in place, remember the rule that trips everyone up first: **the daemon rejects any +request whose `Origin` it does not know**, with `403 BAD_ORIGIN`. The address you type in the browser must +be passed with `--allow-origin` (repeatable). Settings → **Remote access** shows the current origin, the +allowed list, and the exact command to add one. + +**Behind a reverse proxy** (nginx, Caddy, Traefik), terminating TLS on your own domain: + +```nginx +# nginx: the WebSocket upgrade and X-Forwarded-Proto are both required +location / { + proxy_pass http://127.0.0.1:7317; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; # makes the session cookie Secure + proxy_read_timeout 3600s; # long-lived terminals +} +``` + +```bash +npx @johanleroy/git-arboretum --allow-origin https://arboretum.example.com +``` + +`X-Forwarded-Proto: https` is what tells Arboretum to mark its session cookie `Secure`; without it the +cookie stays non-Secure behind your HTTPS front. Keep the proxy read timeout generous, a terminal +WebSocket is idle for long stretches. + +**On the LAN, without a proxy** (least recommended: plain HTTP, no Web Push, no PWA install): + +```bash +npx @johanleroy/git-arboretum \ + --bind 0.0.0.0 --i-know-this-exposes-a-terminal \ + --allow-origin http://192.168.1.42:7317 +``` + +The acknowledgement flag is mandatory and never added for you: binding beyond loopback must be a +deliberate act. Restrict access at the network level (firewall, VPN) and prefer Tailscale. + ## Running it as a background service The quickest way to run Arboretum as a service that survives logout and restarts on boot is the built-in installer. Install a pinned version globally, then run `install`. It detects your OS, writes the service file, starts it, and prints the one-time token: @@ -197,7 +251,7 @@ npm i -g @johanleroy/git-arboretum arboretum install --allow-origin https://MACHINE.TAILNET.ts.net ``` -This sets up a **systemd user service** on Linux (`~/.config/systemd/user/arboretum.service`) or a **launchd LaunchAgent** on macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Every daemon flag (`--port`, `--allow-origin`, `--db`, …) is propagated to the service. Manage it with: +This sets up a **systemd user service** on Linux (`~/.config/systemd/user/arboretum.service`), a **launchd LaunchAgent** on macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`), or a **scheduled task** on Windows (`Arboretum`, triggered at logon, registered with `schtasks`). Always as your user, never as root or SYSTEM. Every daemon flag (`--port`, `--allow-origin`, `--db`, …) is propagated to the service. Manage it with: ```bash arboretum status # service status (+ where to read logs) @@ -253,7 +307,9 @@ Daemon options are CLI flags: | `--allow-origin ` | none | Additional allowed `Origin` (repeatable). Needed for Tailscale/HTTPS access. | | `--db ` | `/arboretum.db` | SQLite database path. | | `--vapid-contact ` | `mailto:arboretum@localhost` | VAPID contact subject for Web Push. | -| `--print-token` | `false` | Hint about token re-printing (tokens are hashed and cannot be re-shown). | +| `--print-token` | `false` | Print the access token on start (bootstraps one if the database has none). | +| `--claude-home ` | `~/.claude` | Override the Claude install root (session registry and transcripts). | +| `--no-discover` | `false` | Disable repository auto-discovery (start-up scan and periodic re-scan). | | `--i-know-this-exposes-a-terminal` | `false` | Acknowledge binding to a non-loopback address. **Avoid**: prefer Tailscale Serve. | `arboretum install` accepts every daemon flag above (propagated verbatim to the service) plus: @@ -265,7 +321,18 @@ Daemon options are CLI flags: | `--dry-run` | Print the unit/plist and commands without applying anything. | | `--no-enable` | Write the service file but do not enable/start it. | -State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum` (default `~/.local/share/arboretum`). +State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum`, defaulting to +`~/.local/share/arboretum` on Linux and macOS and `%APPDATA%\arboretum` on Windows. + +Environment variables: + +| Variable | Used by | Description | +|---|---|---| +| `ARBORETUM_LOG` | daemon | Log level (`fatal`, `error`, `warn`, `info`, `debug`, `trace`). Default `info`. | +| `ARBORETUM_SECRET_KEY` | daemon | 32-byte key (base64 or hex) encrypting stored git credentials. Generated and stored in the database when absent. | +| `ARBORETUM_EMIT_TOKEN_FD` | daemon | Write the access token to this file descriptor at start-up. Used by the desktop app to sign itself in; not meant for manual use. | +| `XDG_DATA_HOME` | daemon | Root of the data directory (see above). | +| `ARBORETUM_SHELL` | daemon (Windows) | Shell used to run project commands. Default `powershell.exe`. | Settings beyond CLI flags (the directories Arboretum scans for repos and how often, the `claude` binary path and home, and the session retention / purge windows) live in **Settings** in the UI. They are broadcast over the WebSocket, so every connected browser reflects a change in real time, no reload needed. @@ -291,6 +358,19 @@ Tailscale Serve is **the** way to reach Arboretum from other devices, not just a See [`SECURITY.md`](SECURITY.md) for the full threat model and [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) for hardening in regulated environments. +## Troubleshooting + +| Symptom | Cause & fix | +|---|---| +| `npm error 404 Not Found @johanleroy/git-arboretum` | The package lives on a private registry. Add the scope to your `~/.npmrc`: `@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/` | +| `403 BAD_ORIGIN` in the browser console, blank UI | The address you are using is not in the allowed list. Restart with `--allow-origin ` (scheme, host and port must match). | +| `ERR_UNKNOWN_BUILTIN_MODULE node:sqlite` or a crash on start | Node is older than 22.16. Check with `node --version`; `node:sqlite` is only stable from there. The desktop app bundles its own runtime and is immune. | +| "Claude Code CLI not found in PATH" | The daemon runs with a minimal PATH (typical under systemd/launchd). Set the binary path in **Settings → Claude CLI**, or reinstall the service with `arboretum install`, which freezes your interactive PATH. | +| Notifications cannot be enabled | Web Push requires HTTPS. Use Tailscale Serve or a reverse proxy; on iOS, install the PWA first. | +| The app launcher shows a generic icon (Linux) | Fixed in desktop 0.2.0: earlier packages installed a single non-standard icon size that the freedesktop spec ignores. Upgrade the `.deb`; if the icon persists, run `gtk-update-icon-cache -f /usr/share/icons/hicolor` or log out and back in. | +| SmartScreen or Gatekeeper blocks the app | Expected: the binaries are not signed. See the per-platform table above. | +| A terminal stays blank after "Start project" | The command was typed into a login shell that failed to start. Check the tab: the shell survives the failure on purpose, so the error is visible in it. | + ## What makes it different | | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control | @@ -334,8 +414,17 @@ node packages/server/scripts/acceptance-p9.mjs # advanced commit/push: selecti node packages/server/scripts/acceptance-p10.mjs # automatic session archival node packages/server/scripts/acceptance-p11.mjs # real-time settings sync node packages/server/scripts/acceptance-p12.mjs # remote git services + HTTPS clone +node packages/server/scripts/acceptance-p13.mjs # start the project: launch commands & multi-terminal +node packages/server/scripts/acceptance-p14.mjs # armed real-time: session-pinned watcher, cwd correlation +node packages/server/scripts/acceptance-p15.mjs # history: commit log & per-commit diff ``` +Rendering check (headless Chromium over CDP, no Playwright): after `npm run build`, run +`node packages/server/scripts/copy-web.mjs` then +`node packages/server/scripts/verify-ui.mjs [outdir]`. It starts an isolated daemon, seeds a demo repo, +and writes screenshots of the IDE in both themes at desktop and mobile widths, failing on any console +error. + The protocol grew (additively, no version bump) to carry the new surface: client `watch` / `unwatch` messages and the targeted `worktree_changes` signal (P7), plus `session_archived` (P10), `settings_update` (P11) and `clone_update` (P12) broadcasts. Server-side, the work is backed by `core/git.ts` (the pure git engine), `core/fs-watcher.ts` (chokidar), `core/git-credentials.ts` + `core/clone-manager.ts` (encrypted credentials & clone), and the session-archive and settings services. ## Support diff --git a/brand/build-assets.py b/brand/build-assets.py index 9f8b4a0..9e623d1 100644 --- a/brand/build-assets.py +++ b/brand/build-assets.py @@ -16,9 +16,17 @@ Sorties : packages/web/public/icon-512.png icône PWA maskable (arbre, fond #09090b) packages/web/public/apple-touch-icon.png icône iOS 180 (arbre, fond #09090b) packages/web/public/favicon.ico favicon transparent (arbre, 16/32/48) + packages/web/public/screenshot-ide-{dark,light}.png captures du manifeste PWA (copiées de brand/) + packages/desktop/resources/icon.png source 1024 (electron-builder : macOS + dérivations) + packages/desktop/resources/icon.ico icône Windows multi-tailles (NSIS + fenêtre) + packages/desktop/resources/icons/NNxNN.png jeu Linux aux TAILLES STANDARD hicolor + packages/desktop/resources/trayTemplate.png (+@2x) icône de barre de menus macOS (monochrome) + packages/vscode/media/icon.png icône du VSIX (128, requise par tout marketplace) Usage : python3 brand/build-assets.py """ +import os +import shutil import sys import numpy as np from PIL import Image @@ -94,6 +102,40 @@ def main(): fav = square(tree, 0.04) fav.save("packages/web/public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)]) print(" packages/web/public/favicon.ico 16/32/48") + # captures utilisées par le manifeste PWA (installation enrichie Chrome/Edge) + for theme in ("dark", "light"): + src = f"brand/screenshot-ide-{theme}.png" + if os.path.exists(src): + shutil.copyfile(src, f"packages/web/public/screenshot-ide-{theme}.png") + print(f" packages/web/public/screenshot-ide-{theme}.png (copié)") + + # --- app de bureau ------------------------------------------------------------------- + # electron-builder n'invente RIEN pour Linux : sans un dossier d'icônes aux tailles standard + # hicolor, il installe l'unique taille source (ex. 895x895), répertoire que la spécification + # freedesktop ignore → aucun logo au lanceur. D'où la génération explicite ci-dessous. + print("packages/desktop/resources/") + os.makedirs("packages/desktop/resources/icons", exist_ok=True) + desk = square(tree, 0.08) + save(desk, "packages/desktop/resources/icon.png", 1024) + for size in (16, 24, 32, 48, 64, 128, 256, 512): + save(desk, f"packages/desktop/resources/icons/{size}x{size}.png", size) + desk.resize((256, 256), Image.LANCZOS).save( + "packages/desktop/resources/icon.ico", sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] + ) + print(" packages/desktop/resources/icon.ico 16→256") + # macOS : la barre de menus exige une image TEMPLATE (monochrome + alpha), sinon l'icône est + # illisible et ne suit pas le thème clair/sombre du système. + tpl = tree.copy() + tpl_alpha = tpl.getchannel("A") + template = Image.new("RGBA", tpl.size, (0, 0, 0, 0)) + template.putalpha(tpl_alpha) + save(template, "packages/desktop/resources/trayTemplate.png", 16) + save(template, "packages/desktop/resources/trayTemplate@2x.png", 32) + + # --- extension VS Code --------------------------------------------------------------- + print("packages/vscode/media/") + os.makedirs("packages/vscode/media", exist_ok=True) + save(square(tree, 0.10, bg=BG), "packages/vscode/media/icon.png", 128) if __name__ == "__main__": diff --git a/docs/CI_RUNNERS.md b/docs/CI_RUNNERS.md new file mode 100644 index 0000000..fd5e36f --- /dev/null +++ b/docs/CI_RUNNERS.md @@ -0,0 +1,134 @@ +# Runners Gitea Actions · ajouter Windows (et macOS) + +Ce document explique comment activer le build **Windows** de l'app de bureau dans la CI. Il est écrit +pour être appliqué tel quel sur `git.lidge.fr` (Gitea 1.25). + +## Pourquoi un runner Windows est obligatoire + +Le cross-build Windows depuis Linux **ne peut pas fonctionner**, pour deux raisons vérifiées dans +`node_modules/@homebridge/node-pty-prebuilt-multiarch` : + +1. `scripts/check-prebuild.js` sort en succès dès que le binaire de l'hôte existe, donc + `prebuild-install` n'est jamais appelé et aucun binaire `win32` n'est téléchargé (le tarball publié + ne contient que `prebuilds/linux-*`) ; +2. `scripts/post-install.js` ne copie `conpty.dll` et `OpenConsole.exe` **que si la plateforme de build + est win32**. Sans eux, pas de ConPTY, donc **aucun terminal** dans l'app. + +Un build produit sous Wine serait donc installable mais inutilisable. C'est pour cela que +`packages/desktop/README.md` ne propose plus cette voie. + +## État actuel + +| Plateforme | Runner | Build | +|---|---|---| +| Linux | `ubuntu-latest` (déjà en place) | automatique à chaque tag `desktop-v*` | +| Windows | **à enregistrer** | job `windows`, activé par la variable `ENABLE_WINDOWS_BUILD` | +| macOS | aucun | manuel (`npm run dist:mac` sur un Mac) | + +Le job Windows est conditionné par `if: vars.ENABLE_WINDOWS_BUILD == 'true'`. Tant que la variable +n'existe pas, le job est **sauté** : la release Linux part normalement. Sans cette condition, un job +`runs-on: windows-latest` sans runner disponible resterait en attente et bloquerait la release entière. + +## 1. Préparer la machine Windows + +Prérequis (Windows 10 1809+ ou Windows 11, x64) : + +- **Git pour Windows** (fournit aussi `bash`, utilisé par les étapes `shell: bash` du workflow) ; +- **Node.js 22.21.1** (même version que `NODE_VERSION` dans le workflow) ; +- rien d'autre : `node-pty` s'installe via des binaires précompilés, aucun compilateur C++ n'est requis. + +Vérification rapide dans PowerShell : + +```powershell +node --version # v22.21.1 +git --version +bash --version # fourni par Git for Windows +``` + +## 2. Enregistrer le runner + +Récupérer un jeton d'enregistrement dans Gitea : **Site Administration → Actions → Runners → Create new +runner** (jeton d'instance), ou au niveau du dépôt : **Settings → Actions → Runners**. + +Puis, dans PowerShell (répertoire dédié, par exemple `C:\actions-runner`) : + +```powershell +mkdir C:\actions-runner; cd C:\actions-runner +# Binaire act_runner pour Windows (adapter la version à celle de votre Gitea) +Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-windows-amd64.exe" -OutFile act_runner.exe + +.\act_runner.exe register --no-interactive ` + --instance https://git.lidge.fr ` + --token ` + --name windows-builder ` + --labels windows-latest:host +``` + +Le label **`windows-latest:host`** est essentiel : `:host` signifie « exécuter directement sur la +machine », sans conteneur (il n'y a pas d'image Docker Windows utilisable ici), et `windows-latest` est +le nom attendu par `runs-on` dans le workflow. + +Démarrage manuel pour un premier essai : + +```powershell +.\act_runner.exe daemon +``` + +## 3. Exécuter le runner en service + +Pour qu'il survive aux redémarrages, créer une tâche planifiée « à l'ouverture de session » (même +principe que `arboretum install` sur Windows) : + +```powershell +schtasks /Create /TN "GiteaActRunner" /TR "C:\actions-runner\act_runner.exe daemon" ` + /SC ONLOGON /RL LIMITED /F +schtasks /Run /TN "GiteaActRunner" +``` + +Alternative : [NSSM](https://nssm.cc/) pour un vrai service Windows, si le runner doit tourner sans +session ouverte. Attention : un service hors session n'a pas accès au profil utilisateur. + +## 4. Activer le job dans la CI + +Dans Gitea, sur le dépôt `johanleroy/arboretum` : **Settings → Actions → Variables → Add Variable** + +| Nom | Valeur | +|---|---| +| `ENABLE_WINDOWS_BUILD` | `true` | + +## 5. Vérifier sans créer de tag + +Le workflow accepte `workflow_dispatch` : **Actions → Desktop Release → Run workflow**. Dans ce mode, le +garde-fou « tag == version » est ignoré et rien n'est attaché à une release ; les installeurs sont +récupérables dans les artefacts du run (`desktop-windows`). + +Contrôles à faire sur l'installeur produit : + +1. l'installeur NSIS s'exécute et propose le répertoire d'installation ; +2. l'app démarre et affiche l'IDE **sans écran de connexion** (le token passe par le descripteur 3 ; + c'est le point le plus susceptible de différer sur Windows, cf. `packages/desktop/src/main/daemon.ts`) ; +3. un terminal s'ouvre et répond (ConPTY présent) ; +4. le CLI `claude` est trouvé (sinon renseigner son chemin dans Réglages → Claude CLI) ; +5. « Démarrer le projet » lance bien les commandes sous PowerShell. + +## 6. Signature de code + +Aucun binaire n'est signé. SmartScreen affichera « éditeur inconnu » au premier lancement : choisir +« Informations complémentaires » puis « Exécuter quand même ». Pour signer plus tard, ajouter les +secrets `CSC_LINK` (certificat .pfx encodé en base64) et `CSC_KEY_PASSWORD` au dépôt : electron-builder +les utilise automatiquement, sans changement de workflow. + +## Repli si aucun runner n'est possible + +Sur une machine Windows, avec le dépôt cloné : + +```powershell +npm ci +cd packages\desktop +npm ci +npm run dist:win +``` + +Puis attacher `packages\desktop\release\*.exe`, `latest.yml` et les `.blockmap` à la release +`desktop-vX.Y.Z` depuis l'interface Gitea. Le canal d'auto-update (`desktop-latest`) doit recevoir les +mêmes fichiers, sinon les utilisateurs Windows ne verront pas la mise à jour. diff --git a/package-lock.json b/package-lock.json index 7829dd5..3cc33a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7933,7 +7933,7 @@ }, "packages/server": { "name": "@johanleroy/git-arboretum", - "version": "3.3.0", + "version": "3.4.0", "license": "MIT", "dependencies": { "@fastify/cookie": "^11.0.0", @@ -7967,7 +7967,7 @@ }, "packages/site": { "name": "@arboretum/site", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "vue": "^3.5.38", "vue-i18n": "^11.4.5" @@ -8063,7 +8063,7 @@ }, "packages/vscode": { "name": "git-arboretum", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "devDependencies": { "@arboretum/shared": "0.1.0", diff --git a/package.json b/package.json index ed0ad45..b930d7a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "dev:site": "npm run dev -w @arboretum/site", "preview:site": "npm run preview -w @arboretum/site", "build:vscode": "npm run build -w @arboretum/shared -w git-arboretum", - "dev:vscode": "npm run dev -w git-arboretum" + "dev:vscode": "npm run dev -w git-arboretum", + "typecheck:desktop": "npm --prefix packages/desktop run typecheck", + "build:desktop": "npm --prefix packages/desktop run build" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md new file mode 100644 index 0000000..9ee024c --- /dev/null +++ b/packages/desktop/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon and the VS Code +extension keep their own changelogs in `packages/server/CHANGELOG.md` and +`packages/vscode/CHANGELOG.md`. + +## 0.2.0 + +Distribution release: the Linux launcher icon finally shows up, Windows becomes a first-class target, +and the embedded runtime loses a third of its weight. + +- **Launcher icon fixed (Linux).** Earlier packages installed a single 895×895 icon. That size is not + declared in `hicolor/index.theme`, so by the freedesktop spec every desktop environment ignored it and + the launcher fell back to a generic icon. The build now generates the standard set (16 → 512) plus a + proper `.ico` for Windows, and forces `executableName: arboretum` (the scoped package name was + producing `@arboretumdesktop` as binary, `.desktop` file and icon name). +- **Package metadata.** A non-empty short description in `apt show` (`deb.synopsis` was missing), + `Section: devel` instead of `default`, a single-line `Comment` in the desktop entry (it was multi-line, + hence invalid), plus `GenericName` and `Keywords` for search. +- **Windows.** Build scripts run on a Windows host again (`npm`/`npx` are `.cmd` shims that + `execFileSync` cannot resolve; the Node extraction used `unzip` and `bash -c cp/rm`, none of which + exist there). The daemon side gained what it needed to actually work: `where.exe` to find the Claude + CLI, PowerShell as the launch shell, a `.cmd` askpass so HTTPS clone/push with a token works, and + `taskkill /T` so stopping a terminal takes its whole process tree down. CI has a `windows-latest` job, + enabled by the `ENABLE_WINDOWS_BUILD` repository variable, see `docs/CI_RUNNERS.md`. +- **Auto-update repaired.** Shipped binaries point at a `desktop-latest` release that never existed, so + no client could ever see an update. The release workflow now recreates that floating release on every + version and attaches the `latest*.yml` files and installers to it, with `.blockmap`s for differential + updates and `SHA256SUMS`. +- **Smaller download.** The bundled Node runtime is pruned to the binary and its licence (no headers, no + docs, no `npm`/`corepack`): ~205 MB → ~118 MB. Nothing at runtime used them, the daemon's dependencies + being installed at build time. +- **macOS integration.** An application menu (without it ⌘C / ⌘V / ⌘A were not bound anywhere in the + app), `app.on('activate')` so the Dock icon brings back a hidden window, and a monochrome template tray + icon that follows the menu-bar theme. +- **PATH enrichment on Windows.** `%LOCALAPPDATA%\Programs` and `%APPDATA%\npm` are added to the daemon's + PATH, where the Claude CLI and global npm binaries live (this was POSIX-only). + +## 0.1.3 + +Ships the 3.3.0 daemon ("Start the project": launch commands and multi-terminal boot). + +## 0.1.2 + +Ships the 3.2.0 daemon (Emerald visual overhaul, light and dark themes). + +## 0.1.1 + +- Fixed the missing window/launcher logo under Debian and Wayland by pinning the runtime app id + (`app.setName('Arboretum')`) to the `StartupWMClass` written in the desktop entry. + +## 0.1.0 + +First desktop release: an Electron shell that runs the daemon as a child process and opens its UI +already authenticated, with a bundled Node runtime, a tray icon, launch-at-login and auto-update. diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 1e6bcda..5e17f5c 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -15,7 +15,8 @@ not by the main `npm run build`. 2. The daemon mints a fresh token and writes `{token, url}` on file descriptor 3 (private stdio pipe). 3. The shell posts that token to `/api/v1/auth/login` from the window's session (server to server), which drops the `arb_session` cookie into the session jar, then loads the SPA on `127.0.0.1`. -4. On quit, the daemon child gets `SIGTERM` (then `SIGKILL` after a grace delay). +4. On quit, the daemon child is asked to stop (`SIGTERM` on POSIX, `taskkill /T` on Windows, which + Windows requires to take the whole process tree down rather than leaving PTY grandchildren behind). A standalone Node runtime (pinned, >= 22.16) is bundled instead of reusing Electron's Node, so `node:sqlite` works without a flag and the `node-pty` prebuild keeps the `node.` ABI prefix. @@ -56,24 +57,61 @@ Fully supported. `dist:linux` runs on a Linux host or the Gitea CI runner. ### Windows -Build on a Windows host (recommended): the `node-pty` win32 native binary and the installer -(`makensis`) are most reliable there. Cross-building from Linux via Wine is a best-effort fallback. -The app uses ConPTY (Windows 10 1809+). The installer is not code-signed yet, so SmartScreen shows +**Must be built on a Windows host.** Cross-building from Linux (including via Wine) does not work, and +the option has been removed from this document to stop people losing time on it: + +- `node-pty`'s `check-prebuild.js` exits successfully as soon as the *host* binary exists, so + `prebuild-install` never runs and no win32 binary is fetched (its published tarball only ships + `prebuilds/linux-*`); +- its `post-install.js` copies `conpty.dll` and `OpenConsole.exe` **only when the build platform is + win32**. Without them there is no ConPTY, hence no terminal at all. + +In CI this is a dedicated job on a `windows-latest` runner, enabled by the `ENABLE_WINDOWS_BUILD` +repository variable. Full procedure to register such a runner: [`docs/CI_RUNNERS.md`](../../docs/CI_RUNNERS.md). + +The app requires Windows 10 1809+ (ConPTY). The installer is not code-signed, so SmartScreen shows "unknown publisher": choose "More info" then "Run anyway". ### macOS (best-effort) -Build on a Mac (`dmg`/`zip` cannot be produced elsewhere). The app is **not** signed or notarized, -so Gatekeeper blocks the first launch: right-click the app then "Open", or run -`xattr -dr com.apple.quarantine /Applications/Arboretum.app`. +Build on a Mac (`dmg`/`zip` cannot be produced elsewhere); there is no macOS runner, so it is a manual +step. The app is **not** signed or notarized, so Gatekeeper blocks the first launch: right-click the app +then "Open", or run `xattr -dr com.apple.quarantine /Applications/Arboretum.app`. + +## What the shell adds beyond the window + +- **Tray icon** (`src/main/tray.ts`): open the window, toggle launch-at-login, quit. On macOS it uses a + monochrome *template* image so it follows the menu-bar theme. +- **Application menu** (`src/main/app-menu.ts`): required on macOS, where without it ⌘C / ⌘V / ⌘A are not + bound anywhere in the app. Closing the window hides it; `app.on('activate')` brings it back from the Dock. +- **Launch at login** (`src/main/autostart.ts`): a `.desktop` file under `~/.config/autostart` on Linux, + `app.setLoginItemSettings` on Windows/macOS. +- **Auto-update** (`src/main/updater.ts`): see below. +- **PATH enrichment** (`src/main/env.ts`): a GUI app starts with a minimal PATH. On POSIX we add + `/usr/local/bin`, `/opt/homebrew/bin`, `~/.local/bin`; on Windows `%LOCALAPPDATA%\Programs` and + `%APPDATA%\npm`, where the Claude CLI and global npm binaries actually live. ## Auto-update -electron-builder emits `latest*.yml` next to the artifacts; `electron-updater` (wired in a later -change) points at the Gitea release assets. Auto-update works for Windows (NSIS) and Linux -(AppImage); macOS updates are manual while the app is unsigned. +electron-builder emits `latest*.yml` next to the artifacts and `electron-updater` reads them from a +**floating `desktop-latest` release** on Gitea, which the release workflow recreates on every version +(that URL is baked into shipped binaries, so it must always exist). Auto-update covers Windows (NSIS) +and Linux (AppImage); macOS updates are manual while the app is unsigned. -## Icon +## Bundled Node runtime -`resources/icon.png` (square, >= 512px) is the single source; electron-builder derives every -platform icon from it. +`scripts/fetch-node.mjs` downloads a pinned Node (SHA256 verified) and **prunes it** to the binary and +its licence: headers, docs and `npm`/`corepack` are removed, since the daemon's dependencies are +installed at build time, never at runtime. That takes the embedded runtime from ~205 MB to ~118 MB. + +## Icons + +Generated by `python3 brand/build-assets.py` from the source logo, into `resources/`: + +- `icons/{16,24,32,48,64,128,256,512}x*.png` : the Linux set, at **standard hicolor sizes**. This is not + cosmetic: with a single non-standard size (the old 895×895), the directory is not declared in + `hicolor/index.theme` and the freedesktop spec makes desktops ignore it, so the launcher showed no + icon at all. +- `icon.png` (1024) : macOS source and generic fallback. +- `icon.ico` : Windows (NSIS installer and window). +- `trayTemplate.png` (+`@2x`) : monochrome macOS menu-bar icon. diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7d68fc2..367e368 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -21,27 +21,59 @@ extraResources: to: node - from: resources/icon.png to: icon.png + - from: resources/trayTemplate.png + to: trayTemplate.png + - from: resources/trayTemplate@2x.png + to: trayTemplate@2x.png -# Icône : electron-builder dérive toutes les tailles/formats par OS depuis resources/icon.png -# (buildResources), pas besoin de .ico/.icns séparés. +# Icônes : générées par `python3 brand/build-assets.py` depuis le logo source. +# - `resources/icons/` : jeu Linux aux TAILLES STANDARD hicolor (16→512). Indispensable : sans lui, +# electron-builder installe l'unique taille du PNG source (895x895), or `hicolor/index.theme` ne +# déclare pas ce répertoire, donc la spécification freedesktop l'ignore et AUCUN logo n'apparaît +# au lanceur (c'était le bug du .deb 0.1.x). +# - `resources/icon.png` (1024) : source macOS et dérivations. +# - `resources/icon.ico` : Windows (installeur NSIS + fenêtre). linux: target: [AppImage, deb] category: Development + icon: resources/icons + # `executableName` explicite : sinon electron-builder le dérive du `name` SCOPÉ du package + # (@arboretum/desktop → « @arboretumdesktop »), qui se retrouvait dans /usr/bin, le .desktop et son + # `Icon=` · un nom d'icône commençant par « @ » n'est pas résoluble. + executableName: arboretum artifactName: ${productName}-${version}-${arch}.${ext} + synopsis: Self-hosted multi-project AI IDE for git worktrees # Entrée .desktop (forme plate, mergée telle quelle par electron-builder 25). StartupWMClass DOIT # correspondre à l'app_id runtime (posé par app.setName('Arboretum') dans src/main/main.ts) pour # que GNOME/Wayland associe la fenêtre au lanceur et affiche le logo. Redondant avec le défaut # (productName) mais explicite et robuste à un futur changement de productName. desktop: StartupWMClass: Arboretum - # Note : le .deb installe l'icône et rafraîchit le cache (postinst electron-builder). L'AppImage, - # lui, n'installe aucun .desktop sans intégration (appimaged) : sur Debian, préférer le .deb. + GenericName: AI IDE for git worktrees + Keywords: git;worktree;claude;ide;terminal; + # Pas de `Comment` ici : electron-builder l'écrase systématiquement après la surcharge + # (LinuxTargetHelper : desktopMeta.Comment = deb.description || package.json description). C'est + # donc la description du package.json qui fait foi, et elle DOIT rester sur une seule ligne : un + # texte multi-lignes produirait une entrée .desktop invalide (lignes suivantes lues comme clés). + # Note : avec des tailles standard, GTK/KDE résolvent l'icône même sans cache d'icônes rafraîchi + # (le postinst d'electron-builder n'appelle pas gtk-update-icon-cache). L'AppImage, lui, n'installe + # aucun .desktop sans intégration (appimaged) : sur Debian, préférer le .deb. deb: # git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente). depends: [git] # Mainteneur .deb explicite (electron-builder l'exige ; sinon derive de author.email du package.json). maintainer: Johan LEROY + # `synopsis` alimente la description COURTE du paquet : sans lui, `apt show` affichait une ligne + # vide (electron-builder concatène `synopsis || ''` puis la description longue). + # `synopsis` = description COURTE du paquet : sans elle, `apt show` affichait une ligne vide + # (electron-builder concatène `synopsis || ''` puis la description longue). La description longue + # reste celle du package.json, volontairement sur une seule ligne (cf. note sur Comment ci-dessus). + synopsis: Self-hosted multi-project AI IDE for git worktrees + # electron-builder nomme ce champ `packageCategory` (et non `section`) : il alimente le champ + # Section: du paquet, qui valait « default » jusqu'ici. + packageCategory: devel + priority: optional win: target: @@ -49,22 +81,33 @@ win: arch: [x64] - target: portable arch: [x64] + icon: resources/icon.ico + # Affiché par SmartScreen et dans les métadonnées de l'exécutable. Le binaire n'est PAS signé : + # SmartScreen montrera « éditeur inconnu » (documenté dans le README). + publisherName: Johan LEROY artifactName: ${productName}-${version}-${arch}.${ext} nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true + shortcutName: Arboretum + uninstallDisplayName: Arboretum ${version} + createDesktopShortcut: true + license: ../../LICENSE mac: target: [dmg, zip] + icon: resources/icon.png category: public.app-category.developer-tools # macOS best-effort : non signe (documente : clic droit -> Ouvrir, ou xattr -dr com.apple.quarantine) identity: null hardenedRuntime: false -# Auto-update (electron-updater, cable en C5) : provider generic pointant sur les assets de release -# Gitea. Genere latest*.yml a cote des artefacts. +# Auto-update (electron-updater) : provider generic pointant sur un tag FLOTTANT `desktop-latest`, +# que la CI recrée à chaque release en y attachant les installeurs et les `latest*.yml`. Ce tag doit +# exister, sinon l'updater reçoit un 404 (c'était le cas jusqu'en 0.1.3) : voir +# .gitea/workflows/desktop-release.yml, étape « Publish floating desktop-latest release ». publish: provider: generic url: https://git.lidge.fr/johanleroy/arboretum/releases/download/desktop-latest diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index c074380..cb2361c 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@arboretum/desktop", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@arboretum/desktop", - "version": "0.1.3", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 428381c..9a00064 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,9 +1,25 @@ { "name": "@arboretum/desktop", "private": true, - "version": "0.1.3", - "description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI", + "version": "0.2.0", + "description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions", "homepage": "https://git-arboretum.com", + "repository": { + "type": "git", + "url": "git+https://git.lidge.fr/johanleroy/arboretum.git" + }, + "bugs": { + "url": "https://git.lidge.fr/johanleroy/arboretum/issues" + }, + "keywords": [ + "arboretum", + "git", + "worktree", + "claude", + "ide", + "electron", + "desktop" + ], "license": "MIT", "author": { "name": "Johan LEROY", diff --git a/packages/desktop/resources/icon.ico b/packages/desktop/resources/icon.ico new file mode 100644 index 0000000..e248dc4 Binary files /dev/null and b/packages/desktop/resources/icon.ico differ diff --git a/packages/desktop/resources/icon.png b/packages/desktop/resources/icon.png index 442652c..1d2037e 100644 Binary files a/packages/desktop/resources/icon.png and b/packages/desktop/resources/icon.png differ diff --git a/packages/desktop/resources/icons/128x128.png b/packages/desktop/resources/icons/128x128.png new file mode 100644 index 0000000..05a4b10 Binary files /dev/null and b/packages/desktop/resources/icons/128x128.png differ diff --git a/packages/desktop/resources/icons/16x16.png b/packages/desktop/resources/icons/16x16.png new file mode 100644 index 0000000..c436b6a Binary files /dev/null and b/packages/desktop/resources/icons/16x16.png differ diff --git a/packages/desktop/resources/icons/24x24.png b/packages/desktop/resources/icons/24x24.png new file mode 100644 index 0000000..fb32883 Binary files /dev/null and b/packages/desktop/resources/icons/24x24.png differ diff --git a/packages/desktop/resources/icons/256x256.png b/packages/desktop/resources/icons/256x256.png new file mode 100644 index 0000000..f8a3286 Binary files /dev/null and b/packages/desktop/resources/icons/256x256.png differ diff --git a/packages/desktop/resources/icons/32x32.png b/packages/desktop/resources/icons/32x32.png new file mode 100644 index 0000000..1d5562c Binary files /dev/null and b/packages/desktop/resources/icons/32x32.png differ diff --git a/packages/desktop/resources/icons/48x48.png b/packages/desktop/resources/icons/48x48.png new file mode 100644 index 0000000..e73576f Binary files /dev/null and b/packages/desktop/resources/icons/48x48.png differ diff --git a/packages/desktop/resources/icons/512x512.png b/packages/desktop/resources/icons/512x512.png new file mode 100644 index 0000000..b101c93 Binary files /dev/null and b/packages/desktop/resources/icons/512x512.png differ diff --git a/packages/desktop/resources/icons/64x64.png b/packages/desktop/resources/icons/64x64.png new file mode 100644 index 0000000..c282449 Binary files /dev/null and b/packages/desktop/resources/icons/64x64.png differ diff --git a/packages/desktop/resources/trayTemplate.png b/packages/desktop/resources/trayTemplate.png new file mode 100644 index 0000000..a889a89 Binary files /dev/null and b/packages/desktop/resources/trayTemplate.png differ diff --git a/packages/desktop/resources/trayTemplate@2x.png b/packages/desktop/resources/trayTemplate@2x.png new file mode 100644 index 0000000..4e9e6fc Binary files /dev/null and b/packages/desktop/resources/trayTemplate@2x.png differ diff --git a/packages/desktop/scripts/fetch-node.mjs b/packages/desktop/scripts/fetch-node.mjs index 7342fb3..1c933be 100644 --- a/packages/desktop/scripts/fetch-node.mjs +++ b/packages/desktop/scripts/fetch-node.mjs @@ -2,7 +2,7 @@ // SHA256. Le daemon tourne SUR ce Node (pas celui d'Electron) pour garantir node:sqlite sans flag // et l'ABI node-pty attendue (prefixe `node.`). Options : --platform / --arch (défaut : hôte). import { execFileSync } from 'node:child_process'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -41,9 +41,45 @@ if (expected !== actual) throw new Error(`SHA256 mismatch pour ${name}.${ext}`); const archive = join(BUILD, `${name}.${ext}`); writeFileSync(archive, tarball); -if (ext === 'zip') execFileSync('unzip', ['-q', archive, '-d', BUILD], { stdio: 'inherit' }); -else execFileSync('tar', ['-xJf', archive, '-C', BUILD], { stdio: 'inherit' }); -// aplatir node-vX-os-arch/ -> build/node/ -execFileSync('bash', ['-c', `cp -R "${join(BUILD, name)}/." "${NODE_DIR}/" && rm -rf "${join(BUILD, name)}" "${archive}"`], { stdio: 'inherit' }); +// `tar` de Windows 10+ (bsdtar) lit aussi les .zip : une seule commande pour les trois plateformes, +// là où `unzip` n'existe pas sur un Windows standard. +execFileSync('tar', [ext === 'zip' ? '-xf' : '-xJf', archive, '-C', BUILD], { stdio: 'inherit' }); -console.log(`fetch-node: Node ${NODE_VERSION} (${OS}-${arch}) -> build/node`); +// Aplatir node-vX-os-arch/ -> build/node/ avec l'API Node (l'ancien `bash -c 'cp -R … && rm -rf …'` +// rendait ce script inexécutable sur Windows, où il n'y a ni bash, ni cp, ni rm). +const extracted = join(BUILD, name); +cpSync(extracted, NODE_DIR, { recursive: true }); +rmSync(extracted, { recursive: true, force: true }); +rmSync(archive, { force: true }); + +// --- élagage --------------------------------------------------------------------------------- +// On n'embarque QUE de quoi exécuter le daemon. La distribution complète pèse ~205 Mo, dont l'essentiel +// est inutile ici : en-têtes de compilation, docs, et surtout npm/corepack (le `npm install --omit=dev` +// du daemon a lieu au BUILD, jamais au runtime). +const PRUNE = ['include', 'share', 'lib', 'CHANGELOG.md', 'README.md']; +for (const rel of PRUNE) rmSync(join(NODE_DIR, rel), { recursive: true, force: true }); +// les shims npm/npx/corepack (POSIX : bin/, Windows : racine) +for (const shim of ['npm', 'npx', 'corepack', 'npm.cmd', 'npx.cmd', 'corepack.cmd', 'npm.ps1', 'npx.ps1', 'corepack.ps1']) { + rmSync(join(NODE_DIR, 'bin', shim), { force: true }); + rmSync(join(NODE_DIR, shim), { force: true }); +} + +// Garde-fou : le binaire doit avoir survécu à l'élagage. +const nodeBin = platform === 'win32' ? join(NODE_DIR, 'node.exe') : join(NODE_DIR, 'bin', 'node'); +if (!existsSync(nodeBin)) throw new Error(`binaire Node introuvable apres extraction: ${nodeBin}`); + +console.log(`fetch-node: Node ${NODE_VERSION} (${OS}-${arch}) -> build/node (${duMb(NODE_DIR)} Mo)`); + +/** Taille approximative d'un dossier, en Mo (diagnostic de l'élagage). */ +function duMb(dir) { + let total = 0; + const walk = (d) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const p = join(d, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.isFile()) total += statSync(p).size; + } + }; + walk(dir); + return Math.round(total / 1024 / 1024); +} diff --git a/packages/desktop/scripts/prepare-server.mjs b/packages/desktop/scripts/prepare-server.mjs index adb8dee..d52b8d4 100644 --- a/packages/desktop/scripts/prepare-server.mjs +++ b/packages/desktop/scripts/prepare-server.mjs @@ -20,8 +20,12 @@ const arg = (name) => args.find((a) => a.startsWith(`--${name}=`))?.split('=')[1 const platform = arg('platform'); const arch = arg('arch'); +// Sur Windows, `npm`/`npx` sont des shims `.cmd` : `execFileSync` ne les résout pas (ENOENT), il faut +// leur nom complet. Sans ça, tout le chemin de build documenté échouait sur un hôte Windows. +const winShim = (cmd) => (process.platform === 'win32' && (cmd === 'npm' || cmd === 'npx') ? `${cmd}.cmd` : cmd); + const run = (cmd, cmdArgs, cwd, env) => - execFileSync(cmd, cmdArgs, { cwd, stdio: 'inherit', env: { ...process.env, ...env } }); + execFileSync(winShim(cmd), cmdArgs, { cwd, stdio: 'inherit', env: { ...process.env, ...env } }); rmSync(SERVER_DIR, { recursive: true, force: true }); mkdirSync(SERVER_DIR, { recursive: true }); diff --git a/packages/desktop/src/main/app-menu.ts b/packages/desktop/src/main/app-menu.ts new file mode 100644 index 0000000..6872681 --- /dev/null +++ b/packages/desktop/src/main/app-menu.ts @@ -0,0 +1,82 @@ +import { app, Menu, shell, type MenuItemConstructorOptions } from 'electron'; + +/** + * Menu applicatif. Sur macOS il n'est PAS optionnel : sans lui, aucun raccourci d'édition n'est + * enregistré et ⌘C / ⌘V / ⌘A / ⌘Z ne fonctionnent nulle part dans l'app (y compris dans les terminaux + * et l'éditeur). Sur Linux/Windows on garde un menu minimal, masqué par défaut (`setMenuBarVisibility` + * côté fenêtre) mais qui enregistre quand même les accélérateurs standard. + */ +export function installAppMenu(opts: { url: string; onQuit: () => void }): void { + const isMac = process.platform === 'darwin'; + + const macAppMenu: MenuItemConstructorOptions[] = isMac + ? [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { label: 'Quit Arboretum', accelerator: 'Command+Q', click: opts.onQuit }, + ], + }, + ] + : []; + + const template: MenuItemConstructorOptions[] = [ + ...macAppMenu, + { + label: 'File', + submenu: isMac ? [{ role: 'close' }] : [{ label: 'Quit', accelerator: 'Ctrl+Q', click: opts.onQuit }], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + { role: 'toggleDevTools' }, + ], + }, + { + label: 'Window', + submenu: isMac ? [{ role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }] : [{ role: 'minimize' }], + }, + { + role: 'help', + submenu: [ + { label: 'Open in browser', click: () => void shell.openExternal(opts.url) }, + { label: 'Website', click: () => void shell.openExternal('https://git-arboretum.com') }, + ], + }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + + app.setAboutPanelOptions({ + applicationName: 'Arboretum', + applicationVersion: app.getVersion(), + copyright: 'Copyright © 2026 Johan Leroy', + website: 'https://git-arboretum.com', + }); +} diff --git a/packages/desktop/src/main/env.ts b/packages/desktop/src/main/env.ts index 769dd96..7608bc5 100644 --- a/packages/desktop/src/main/env.ts +++ b/packages/desktop/src/main/env.ts @@ -6,10 +6,30 @@ import { homedir } from 'node:os'; // `claude`. Le réglage `claude_bin_path` (UI) reste le filet de secours. export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, ...extra }; - if (process.platform !== 'win32') { - const extras = ['/usr/local/bin', '/opt/homebrew/bin', join(homedir(), '.local', 'bin'), '/usr/bin', '/bin']; + const extras = pathExtras(process.platform, env); + if (extras.length > 0) { const current = env.PATH ? env.PATH.split(delimiter) : []; env.PATH = [...new Set([...extras, ...current])].join(delimiter); } return env; } + +/** + * Répertoires à ajouter au PATH du daemon, par plateforme. Windows était entièrement ignoré : or + * l'installeur natif de Claude Code se pose dans %LOCALAPPDATA%\Programs et npm global dans + * %APPDATA%\npm, deux emplacements absents du PATH d'une app lancée depuis le menu Démarrer. + */ +export function pathExtras(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = process.env): string[] { + const home = env.USERPROFILE ?? homedir(); + if (platform === 'win32') { + const local = env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'); + const roaming = env.APPDATA ?? join(home, 'AppData', 'Roaming'); + return [ + join(local, 'Programs'), + join(local, 'Programs', 'claude'), + join(roaming, 'npm'), + join(home, '.local', 'bin'), + ]; + } + return ['/usr/local/bin', '/opt/homebrew/bin', join(home, '.local', 'bin'), '/usr/bin', '/bin']; +} diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index a3e699f..3aa886f 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -4,6 +4,7 @@ import { startDaemon, type DaemonHandle } from './daemon'; import { seedSessionCookie } from './auth'; import { loadWindowState, saveWindowState } from './window-state'; import { createTray } from './tray'; +import { installAppMenu } from './app-menu'; import { initUpdater } from './updater'; import { resolveIconPath } from './paths'; @@ -39,10 +40,15 @@ async function bootstrap(): Promise { daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) }); await seedSessionCookie(PARTITION, daemon.url, daemon.token); createWindow(daemon.url); + installAppMenu({ url: daemon.url, onQuit: quitApp }); tray = createTray({ show: showWindow, quit: quitApp }); initUpdater(); } +// macOS : la fenêtre est cachée (pas détruite) à la fermeture. Sans ce handler, cliquer l'icône du +// Dock ne la ramenait jamais et l'app paraissait bloquée en arrière-plan. +app.on('activate', showWindow); + function showWindow(): void { if (!win) return; if (win.isMinimized()) win.restore(); diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 5c7b84c..2ba31c2 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -1,5 +1,6 @@ import { app } from 'electron'; import { join } from 'node:path'; +import { existsSync } from 'node:fs'; // Résolution des chemins runtime : dev (depuis le repo) vs packagé (extraResources). // __dirname pointe sur dist/ (bundle esbuild) une fois construit. @@ -23,6 +24,19 @@ export function resolveIconPath(): string { : join(__dirname, '..', 'resources', 'icon.png'); } +/** + * Icône de barre système. macOS exige une image « template » (monochrome) dans la barre de menus ; + * ailleurs on retombe sur le logo couleur. `null` si l'asset n'est pas présent (build sans + * régénération des icônes) : l'appelant utilise alors resolveIconPath(). + */ +export function resolveTrayIconPath(): string | null { + if (process.platform !== 'darwin') return null; + const path = app.isPackaged + ? join(process.resourcesPath, 'trayTemplate.png') + : join(__dirname, '..', 'resources', 'trayTemplate.png'); + return existsSync(path) ? path : null; +} + /** Binaire Node qui exécute le daemon (>= 22.16 : node:sqlite + ABI node-pty maîtrisé). */ export function resolveNodeBin(): string { if (app.isPackaged) { diff --git a/packages/desktop/src/main/tray.ts b/packages/desktop/src/main/tray.ts index 93c4782..526c2d4 100644 --- a/packages/desktop/src/main/tray.ts +++ b/packages/desktop/src/main/tray.ts @@ -1,11 +1,17 @@ import { Menu, Tray, nativeImage } from 'electron'; import { isAutoStartEnabled, setAutoStart } from './autostart'; -import { resolveIconPath } from './paths'; +import { resolveIconPath, resolveTrayIconPath } from './paths'; /** Icône de barre système : ouvrir la fenêtre, basculer le lancement au login, quitter. */ export function createTray(opts: { show: () => void; quit: () => void }): Tray { - const image = nativeImage.createFromPath(resolveIconPath()); - const tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image.resize({ width: 18, height: 18 })); + // macOS exige une image TEMPLATE (monochrome + alpha) dans la barre de menus : elle s'inverse + // automatiquement selon le thème système. Une icône couleur y est illisible. Windows attend 16px. + const trayPath = resolveTrayIconPath() ?? resolveIconPath(); + const raw = nativeImage.createFromPath(trayPath); + const size = process.platform === 'darwin' ? 16 : process.platform === 'win32' ? 16 : 18; + const image = raw.isEmpty() ? nativeImage.createEmpty() : raw.resize({ width: size, height: size }); + if (process.platform === 'darwin' && !image.isEmpty()) image.setTemplateImage(true); + const tray = new Tray(image); tray.setToolTip('Arboretum'); const buildMenu = (): void => { diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index d68b7dc..616e05f 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -3,6 +3,44 @@ Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code extension keeps its own changelog in `packages/vscode/CHANGELOG.md`. +## 3.4.0 + +Visibility release: the real-time machinery is now actually armed, worktrees show what they are worth, +and history is served. Fully additive, no protocol version bump. + +- **Real-time that no longer depends on which panel is open.** A live session now pins the FS watcher of + its worktree, so a worktree an agent is writing into refreshes on its own even when nobody is looking at + it (`pinSession` existed but was never called). On the client side, `watch` subscriptions moved out of + the Git panel, which was unmounted as soon as you left its tab, taking the app's only subscription with + it; they now follow what you actually look at (active worktree plus expanded repositories). +- **Reconnection no longer loses state.** The protocol replays nothing, so every event missed during a + WebSocket outage was lost for good. The client reloads repos, worktrees, sessions and settings whenever + the connection comes back. +- **Session correlation by containment.** A terminal started in a *subdirectory* of a worktree (which + "Start the project" allows) and a group session covering a worktree through `--add-dir` are now listed + under that worktree, instead of vanishing from the tree. The rule lives in `@arboretum/shared`, shared by + the daemon, the web UI and the VS Code extension; the most specific worktree wins. +- **History API.** `GET /api/v1/repos/:id/worktrees/log` serves the branch commits with the count of + unpushed ones, and `GET .../worktrees/diff?commit=` the full diff of a commit (hash strictly + validated, same size limits as file diffs). The UI unfolds them in place under the Git panel. +- **Full git counters where they matter.** `ahead`/`behind`, staged, unstaged and conflict counts were + only visible in the status bar, for the active worktree. They are now on every worktree row of the tree + and of the Groups panel, with upstream and last commit in the tooltip. `locked`, `prunable` and an + invalid repository are surfaced too. +- **Groups show their composition.** A group lists its repositories with their worktrees and git state, + its sessions (live and recent) and the directories a group session spans. Its colour tints those repos + in the explorer. +- **Actionable `403 BAD_ORIGIN`.** The error now names the exact `--allow-origin` flag to add, and logs + it. It is the first wall of any LAN or reverse-proxy access. +- **Windows support in the daemon.** `where.exe` for CLI discovery, PowerShell as the project launch + shell, a `.cmd` askpass so token-based HTTPS clone/push works, `taskkill /T` for process-tree + termination, `%APPDATA%` for the data directory, and `arboretum install` registering a scheduled task. +- **UI fixes.** Error toasts were painted behind modals (they are sticky, so they piled up invisible); + on mobile, opening a terminal or switching activity had no visible effect; the dock could push the + status bar out of the viewport; panels showed "nothing here" instead of a loading or error state; + modals had no dialog role, focus trap or focus restore; the splitters are now keyboard operable; diff + line numbers stay pinned while scrolling. + ## 3.3.0 "Start the project": boot a project's long-running commands (dev server, API, database) in one click. Fully additive, no protocol or API change. diff --git a/packages/server/package.json b/packages/server/package.json index 6af27f3..f2ffad2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@johanleroy/git-arboretum", - "version": "3.3.0", - "description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them", + "version": "3.4.0", + "description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them", "license": "MIT", "type": "module", "author": "Johan LEROY ", diff --git a/packages/server/scripts/acceptance-p14.mjs b/packages/server/scripts/acceptance-p14.mjs new file mode 100644 index 0000000..a70b3d8 --- /dev/null +++ b/packages/server/scripts/acceptance-p14.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +// Acceptation P14 (sans navigateur, sans quota Claude) : temps réel « armé ». Vrai daemon + vrai repo +// git tmp + vrai client WS. Couvre les trois trous de visibilité corrigés : +// 1. une session vivante épingle le watcher FS de SON worktree → les compteurs git d'un worktree +// secondaire restent temps réel même si AUCUN client ne le regarde (avant : point « modifié » figé +// sur le dernier listing REST) ; +// 2. corrélation par contenance : un terminal lancé dans un SOUS-répertoire du worktree y est +// rattaché (« Démarrer le projet »), et pas au checkout principal ; +// 3. `watch` explicite → `worktree_changes` ciblé sur ce worktree secondaire. +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const WebSocket = require('ws'); + +const PORT = 7554; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`); +}; + +const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p14-')); +const repo = join(tmp, 'demo-repo'); +mkdirSync(repo, { recursive: true }); +const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); +git('init', '-b', 'main'); +git('config', 'user.email', 'test@arboretum.dev'); +git('config', 'user.name', 'Test'); +mkdirSync(join(repo, 'packages', 'api'), { recursive: true }); +writeFileSync(join(repo, 'README.md'), '# demo\n'); +writeFileSync(join(repo, 'packages', 'api', 'index.js'), 'console.log(1)\n'); +git('add', '-A'); +git('commit', '-m', 'init'); + +const srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], + { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, +); +let srvOut = ''; +srv.stdout.on('data', (d) => (srvOut += d)); +srv.stderr.on('data', (d) => (srvOut += d)); + +function wsClient(cookie) { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); + const state = { msgs: [] }; + ws.on('message', (data, isBinary) => { + if (!isBinary) state.msgs.push(JSON.parse(String(data))); + }); + const waitMsg = async (pred, timeout = 8000) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + const m = state.msgs.find(pred); + if (m) return m; + await sleep(50); + } + return null; + }; + return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) }; +} + +const j = (path, method, cookie, body) => + fetch(`${ORIGIN}${path}`, { + method, + headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + +try { + await sleep(1500); + const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; + check('boot + token bootstrap', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; + check('login → cookie', login.status === 200); + + const c = wsClient(cookie); + await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej))); + c.send({ type: 'hello', protocol: 1 }); + await c.waitMsg((m) => m.type === 'hello_ok'); + c.send({ type: 'sub', topics: ['worktrees', 'sessions'] }); + + const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo }); + const repoId = (await addRepo.json()).repo.id; + check('POST /repos → 201', addRepo.status === 201 && !!repoId); + + // ---- worktree secondaire (feature) avec un sous-répertoire ---- + const created = await j(`/api/v1/repos/${repoId}/worktrees`, 'POST', cookie, { branch: 'feature/live', runHooks: false }); + const wtPath = (await created.json()).worktree?.path; + check('POST /worktrees → worktree secondaire créé', created.status === 201 && !!wtPath); + const subDir = join(wtPath, 'packages', 'api'); + + // ---- 2. corrélation par contenance : session lancée DANS un sous-répertoire ---- + const sess = await j('/api/v1/sessions', 'POST', cookie, { cwd: subDir, command: 'bash' }); + const session = (await sess.json()).session; + check('POST /sessions (cwd = sous-répertoire) → 201', sess.status === 201 && !!session?.id); + await sleep(600); + + const list = await (await j('/api/v1/worktrees', 'GET', cookie)).json(); + const secondary = (list.worktrees ?? []).find((w) => w.path === wtPath); + const main = (list.worktrees ?? []).find((w) => w.isMain); + check( + 'la session du sous-répertoire est rattachée au worktree secondaire', + (secondary?.sessions ?? []).some((s) => s.id === session.id), + `sessions=${(secondary?.sessions ?? []).length}`, + ); + check( + 'elle n’est PAS rattachée au checkout principal (désambiguïsation)', + !(main?.sessions ?? []).some((s) => s.id === session.id), + ); + + // ---- 1. session vivante → watcher épinglé SANS aucun watch client ---- + // Aucun `watch` n'a été envoyé : seul `pinSession` peut produire cet événement. + await sleep(900); // laisse chokidar finir son scan initial + c.state.msgs.length = 0; + const t0 = Date.now(); + appendFileSync(join(wtPath, 'README.md'), 'edited by the agent\n'); + const upd = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.path === wtPath && m.worktree?.git?.dirtyCount > 0, 6000); + check('worktree secondaire non regardé : worktree_update reçu (pinSession)', !!upd, upd ? `${Date.now() - t0}ms` : 'timeout'); + check('les compteurs git du worktree secondaire sont frais', (upd?.worktree?.git?.unstagedCount ?? 0) >= 1); + + // ---- pas de worktree_changes sans watch (le détail reste ciblé) ---- + const changesWithoutWatch = c.state.msgs.find((m) => m.type === 'worktree_changes'); + check('sans watch : aucun worktree_changes (push ciblé préservé)', !changesWithoutWatch); + + // ---- 3. watch explicite → worktree_changes ciblé ---- + c.send({ type: 'watch', repoId, path: wtPath }); + await sleep(900); + c.state.msgs.length = 0; + writeFileSync(join(wtPath, 'live.txt'), 'live\n'); + const changesMsg = await c.waitMsg((m) => m.type === 'worktree_changes' && m.path === wtPath, 6000); + check('watch → worktree_changes ciblé sur le worktree secondaire', !!changesMsg); + + c.send({ type: 'unwatch', repoId, path: wtPath }); + await j(`/api/v1/sessions/${session.id}`, 'DELETE', cookie); + await sleep(500); + + // ---- contraposée : le temps réel reste PILOTÉ (ni session, ni watch → pas de surveillance) ---- + // Un watcher déjà ouvert est volontairement conservé en cache (évincé par la LRU) : on vérifie donc + // sur un worktree neuf, jamais épinglé ni regardé, qu'aucun événement n'est émis. + const idle = await j(`/api/v1/repos/${repoId}/worktrees`, 'POST', cookie, { branch: 'feature/idle', runHooks: false }); + const idlePath = (await idle.json()).worktree?.path; + check('POST /worktrees → second worktree (sans session)', idle.status === 201 && !!idlePath); + await sleep(700); + c.state.msgs.length = 0; + writeFileSync(join(idlePath, 'unwatched.txt'), 'x\n'); + const idleMsg = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.path === idlePath, 2500); + check('worktree sans session ni watch → aucune surveillance (coût piloté par l’attention)', !idleMsg); + + c.ws.close(); +} catch (err) { + check('exception', false, String(err)); +} finally { + srv.kill('SIGTERM'); + await sleep(1500); + check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + console.log(failed.length === 0 ? '\nACCEPTANCE P14: ALL GREEN' : `\nACCEPTANCE P14: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/scripts/acceptance-p15.mjs b/packages/server/scripts/acceptance-p15.mjs new file mode 100644 index 0000000..ea211e6 --- /dev/null +++ b/packages/server/scripts/acceptance-p15.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Acceptation P15 (sans navigateur, sans quota Claude) : historisation. Vrai daemon + vrai repo git +// tmp. Couvre GET /worktrees/log (ordre, champs, limit/skip, marquage non poussé) et la forme +// `diff?commit=` (diff unifié complet d'un commit, hash invalide et inconnu rejetés). +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PORT = 7555; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`); +}; + +const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p15-')); +const repo = join(tmp, 'demo-repo'); +mkdirSync(repo, { recursive: true }); +const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); +git('init', '-b', 'main'); +git('config', 'user.email', 'test@arboretum.dev'); +git('config', 'user.name', 'Test'); +writeFileSync(join(repo, 'README.md'), '# demo\n'); +git('add', '-A'); +git('commit', '-m', 'init'); +// un sujet contenant un guillemet et un caractère accentué : piège classique de parsing +appendFileSync(join(repo, 'README.md'), 'deuxième ligne\n'); +git('commit', '-am', 'ajoute la « deuxième » ligne'); +writeFileSync(join(repo, 'feature.txt'), 'contenu de la feature\n'); +git('add', '-A'); +git('commit', '-m', 'ajoute feature.txt'); + +const srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], + { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, +); +let srvOut = ''; +srv.stdout.on('data', (d) => (srvOut += d)); +srv.stderr.on('data', (d) => (srvOut += d)); + +const j = (path, method, cookie, body) => + fetch(`${ORIGIN}${path}`, { + method, + headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + +try { + await sleep(1500); + const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; + check('boot + token bootstrap', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; + check('login → cookie', login.status === 200); + + const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo }); + const repoId = (await addRepo.json()).repo.id; + check('POST /repos → 201', addRepo.status === 201 && !!repoId); + const enc = encodeURIComponent(repo); + + // ---- GET /log : ordre, champs, sujet non trivial ---- + const log = await (await j(`/api/v1/repos/${repoId}/worktrees/log?path=${enc}`, 'GET', cookie)).json(); + const subjects = (log.commits ?? []).map((c) => c.subject); + check('GET /log : 3 commits, du plus récent au plus ancien', subjects.length === 3 && subjects[0] === 'ajoute feature.txt' && subjects[2] === 'init'); + check('GET /log : sujet accentué et guillemets préservés', subjects[1] === 'ajoute la « deuxième » ligne'); + const head = log.commits?.[0]; + check('GET /log : champs hash/shortHash/auteur/date remplis', /^[0-9a-f]{40}$/.test(head?.hash ?? '') && (head?.shortHash?.length ?? 0) >= 7 && head?.author === 'Test' && !Number.isNaN(Date.parse(head?.date ?? ''))); + check('GET /log : branche locale sans remote → hasUpstream=false', log.hasUpstream === false && log.unpushedCount === 0); + + // ---- limit / skip ---- + const page = await (await j(`/api/v1/repos/${repoId}/worktrees/log?path=${enc}&limit=1&skip=1`, 'GET', cookie)).json(); + check('GET /log : limit + skip bornent la fenêtre', page.commits?.length === 1 && page.commits[0].subject === 'ajoute la « deuxième » ligne'); + const bad = await j(`/api/v1/repos/${repoId}/worktrees/log?path=${enc}&limit=abc`, 'GET', cookie); + check('GET /log : limit non numérique → 400', bad.status === 400); + const noPath = await j(`/api/v1/repos/${repoId}/worktrees/log`, 'GET', cookie); + check('GET /log : path manquant → 400', noPath.status === 400); + + // ---- diff d'un commit ---- + const cd = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=${head.hash}`, 'GET', cookie)).json(); + check('GET /diff?commit= : diff unifié du commit', typeof cd.diff === 'string' && cd.diff.includes('feature.txt') && cd.diff.includes('+contenu de la feature')); + check('GET /diff?commit= : ni binaire ni tronqué', cd.binary === false && cd.tooLarge === false); + const shortHash = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=${head.shortHash}`, 'GET', cookie)).json(); + check('GET /diff?commit= : hash court accepté', typeof shortHash.diff === 'string' && shortHash.diff.includes('feature.txt')); + + const invalid = await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=${encodeURIComponent('--upload-pack=x')}`, 'GET', cookie); + check('GET /diff?commit= : révision non hexadécimale refusée', invalid.status === 400); + const unknown = await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=deadbeef`, 'GET', cookie); + check('GET /diff?commit= : commit inconnu → 404', unknown.status === 404); + const neither = await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}`, 'GET', cookie); + check('GET /diff : ni file ni commit → 400', neither.status === 400); + + // ---- la forme fichier reste intacte (non-régression P7/P9) ---- + appendFileSync(join(repo, 'README.md'), 'travail en cours\n'); + const fileDiff = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&file=README.md`, 'GET', cookie)).json(); + check('GET /diff?file= : toujours fonctionnel', typeof fileDiff.diff === 'string' && fileDiff.diff.includes('+travail en cours')); +} catch (err) { + check('exception', false, String(err)); +} finally { + srv.kill('SIGTERM'); + await sleep(1500); + check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + console.log(failed.length === 0 ? '\nACCEPTANCE P15: ALL GREEN' : `\nACCEPTANCE P15: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/scripts/verify-ui.mjs b/packages/server/scripts/verify-ui.mjs new file mode 100644 index 0000000..518c564 --- /dev/null +++ b/packages/server/scripts/verify-ui.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// Vérification VISUELLE de la SPA authentifiée, sans Playwright : daemon temporaire isolé + Chromium +// headless piloté en CDP + cookie de session injecté. Produit des captures PNG (thème sombre et clair, +// largeurs desktop et mobile) et échoue si une erreur console / exception Vue survient. +// +// Usage : node packages/server/scripts/verify-ui.mjs [dossier-de-sortie] +// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs` (le daemon sert la SPA +// depuis packages/server/public, que le build NE rafraîchit PAS). +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const WebSocket = require('ws'); + +const PORT = 7998; +const CDP_PORT = 9333; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = resolvePath(process.argv[2] ?? join(serverDir, '..', '..', '.ui-shots')); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`); +}; + +function findChromium() { + for (const bin of ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) { + try { + return execFileSync('which', [bin]).toString().trim(); + } catch { + /* essai suivant */ + } + } + return null; +} + +/** Client CDP minimal : un seul socket, corrélation par id, sessionId pour la cible attachée. */ +function cdp(url) { + const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 }); + let nextId = 1; + const pending = new Map(); + const events = []; + ws.on('message', (raw) => { + const msg = JSON.parse(String(raw)); + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result); + return; + } + if (msg.method) events.push(msg); + }); + const ready = new Promise((res, rej) => (ws.on('open', res), ws.on('error', rej))); + const send = (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000); + }); + return { ws, ready, send, events }; +} + +const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-ui-')); +mkdirSync(outDir, { recursive: true }); +let srv = null; +let browser = null; + +try { + // La SPA servie vient de packages/server/public : garde-fou contre la vérification d'un ancien build. + const publicIndex = join(serverDir, 'public', 'index.html'); + check('SPA copiée dans packages/server/public', existsSync(publicIndex), publicIndex); + + // --- dépôt de démonstration : un checkout principal, un worktree de feature, du travail en cours --- + const repo = join(tmp, 'demo-repo'); + mkdirSync(repo, { recursive: true }); + const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); + git('init', '-b', 'main'); + git('config', 'user.email', 'test@arboretum.dev'); + git('config', 'user.name', 'Test'); + writeFileSync(join(repo, 'README.md'), '# demo\n'); + mkdirSync(join(repo, 'src'), { recursive: true }); + writeFileSync(join(repo, 'src', 'app.ts'), 'export const version = 1\n'); + git('add', '-A'); + git('commit', '-m', 'commit initial'); + writeFileSync(join(repo, 'src', 'app.ts'), 'export const version = 2\n'); + + srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], + { env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let srvOut = ''; + srv.stdout.on('data', (d) => (srvOut += d)); + srv.stderr.on('data', (d) => (srvOut += d)); + + for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150); + const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0]; + check('daemon temporaire démarré + token', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const setCookie = login.headers.getSetCookie?.() ?? []; + const sessionCookie = setCookie.map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session=')); + check('login → cookie de session', !!sessionCookie); + const cookieValue = sessionCookie?.slice('arb_session='.length) ?? ''; + + const j = (path, method, body) => + fetch(`${ORIGIN}${path}`, { + method, + headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + const repoId = (await (await j('/api/v1/repos', 'POST', { path: repo })).json()).repo?.id; + check('dépôt de démonstration enregistré', !!repoId); + const wtRes = await (await j(`/api/v1/repos/${repoId}/worktrees`, 'POST', { branch: 'feature/demo', runHooks: false })).json(); + check('worktree de feature créé', !!wtRes.worktree?.path); + // du travail non commité dans le worktree de feature, pour peupler les compteurs git de l'arbre + if (wtRes.worktree?.path) writeFileSync(join(wtRes.worktree.path, 'wip.txt'), 'travail en cours\n'); + const groupRes = await (await j('/api/v1/groups', 'POST', { label: 'Démo', color: '#34d399', repoIds: [repoId] })).json(); + check('groupe de démonstration créé', !!groupRes.group?.id); + const sess = await (await j('/api/v1/sessions', 'POST', { cwd: repo, command: 'bash' })).json(); + check('session bash de démonstration', !!sess.session?.id); + + // --- Chromium headless en CDP --- + const chromeBin = findChromium(); + check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable'); + if (!chromeBin) throw new Error('Chromium introuvable : impossible de vérifier le rendu'); + browser = spawn( + chromeBin, + [ + '--headless=new', + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=${join(tmp, 'chrome')}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-gpu', + '--hide-scrollbars', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let wsUrl = null; + for (let i = 0; i < 80 && !wsUrl; i++) { + await sleep(200); + try { + wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl; + } catch { + /* pas encore prêt */ + } + } + check('Chromium en écoute CDP', !!wsUrl); + + const client = cdp(wsUrl); + await client.ready; + + // État de vue injecté avant le premier paint : on veut des captures qui MONTRENT le contenu + // (arbre déplié, worktree actif), pas un IDE vide. + const expanded = JSON.stringify(JSON.stringify([repoId])); + const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo })); + const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`; + const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');localStorage.setItem('arb.history.open', 'true');`; + + const shots = [ + { name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer }, + { name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer }, + { name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit }, + { name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit }, + { name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer }, + { name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer }, + { name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' }, + ]; + + for (const shot of shots) { + const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true }); + await client.send('Runtime.enable', {}, sessionId); + await client.send('Log.enable', {}, sessionId); + await client.send('Network.enable', {}, sessionId); + await client.send('Emulation.setDeviceMetricsOverride', { width: shot.width, height: shot.height, deviceScaleFactor: 1, mobile: shot.width < 500 }, sessionId); + await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId); + // Thème : la SPA lit `arb.theme` avant le premier paint (script anti-FOUC). + await client.send('Page.enable', {}, sessionId); + await client.send( + 'Page.addScriptToEvaluateOnNewDocument', + { source: `localStorage.setItem('arb.theme', ${JSON.stringify(JSON.stringify(shot.theme))});${shot.seed ?? ''}` }, + sessionId, + ); + const before = client.events.length; + await client.send('Page.navigate', { url: `${ORIGIN}${shot.path ?? '/ide'}` }, sessionId); + await sleep(3500); // laisse le temps au bootstrap REST + WS et au rendu + + const text = await client.send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true }, sessionId); + const rendered = String(text.result?.value ?? ''); + check(`${shot.name} : page rendue`, rendered.length > 20, `${rendered.length} caractères`); + + const errs = client.events + .slice(before) + .filter((e) => e.sessionId === sessionId) + .filter((e) => (e.method === 'Runtime.consoleAPICalled' && e.params?.type === 'error') || e.method === 'Runtime.exceptionThrown') + .map((e) => e.params?.exceptionDetails?.text ?? (e.params?.args ?? []).map((a) => a.value ?? a.description).join(' ')) + // Les erreurs réseau des favicons/manifest en headless ne concernent pas l'app. + .filter((m) => m && !/favicon|manifest\.webmanifest/i.test(m)); + check(`${shot.name} : aucune erreur console`, errs.length === 0, errs.slice(0, 3).join(' | ')); + + const { data } = await client.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, sessionId); + const file = join(outDir, `${shot.name}.png`); + writeFileSync(file, Buffer.from(data, 'base64')); + check(`${shot.name} : capture écrite`, true, file); + await client.send('Target.closeTarget', { targetId }); + } + + client.ws.close(); +} catch (err) { + check('exception', false, String(err)); +} finally { + browser?.kill('SIGTERM'); + srv?.kill('SIGTERM'); + await sleep(1200); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + console.log(failed.length === 0 ? `\nVERIFY UI: ALL GREEN (captures dans ${outDir})` : `\nVERIFY UI: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 19cd5ad..0280013 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -183,7 +183,12 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login) const origin = req.headers.origin; if (origin && !allowedOrigins.has(origin)) { - return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: `Origin not allowed: ${origin}` } }); + // Message ACTIONNABLE : c'est le premier mur de tout accès non-loopback (LAN, reverse proxy, + // Tailscale). Un « Origin not allowed » sec laissait chercher pendant des heures, alors que la + // correction tient en un flag. Le log serveur porte la même consigne. + const hint = `Origin not allowed: ${origin}. Restart the daemon with --allow-origin ${origin} (repeatable) to permit it.`; + req.log.warn({ origin, allowed: [...allowedOrigins] }, hint); + return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: hint } }); } req.authContext = authenticate(req); if (req.routeOptions.config.public) return; diff --git a/packages/server/src/cli/install.ts b/packages/server/src/cli/install.ts index 3a95c24..5d0c620 100644 --- a/packages/server/src/cli/install.ts +++ b/packages/server/src/cli/install.ts @@ -12,7 +12,7 @@ import { AuthService } from '../auth/service.js'; const SERVICE_NAME = 'arboretum'; const LAUNCHD_LABEL = 'fr.lidge.arboretum'; -export type SupportedPlatform = 'linux' | 'darwin'; +export type SupportedPlatform = 'linux' | 'darwin' | 'win32'; export interface InstallFlags { port?: string | undefined; @@ -29,15 +29,31 @@ export interface InstallFlags { // ─── Fonctions pures (génération de contenu / chemins) ──────────────────────────────── -/** macOS (launchd) et Linux (systemd) uniquement ; sinon throw avec un message pédagogique. */ +/** + * Superviseur par plateforme : systemd (Linux), launchd (macOS), Planificateur de tâches (Windows). + * Toujours en tant qu'utilisateur, jamais en root/SYSTEM. + */ export function detectPlatform(platform: NodeJS.Platform = process.platform): SupportedPlatform { - if (platform === 'linux' || platform === 'darwin') return platform; + if (platform === 'linux' || platform === 'darwin' || platform === 'win32') return platform; throw new Error( - `Automatic service installation is supported on Linux (systemd) and macOS (launchd) only.\n` + - `On ${platform}, run \`arboretum\` manually or set up your own supervisor.`, + `Automatic service installation is supported on Linux (systemd), macOS (launchd) and Windows ` + + `(Task Scheduler) only.\nOn ${platform}, run \`arboretum\` manually or set up your own supervisor.`, ); } +/** Nom de la tâche planifiée Windows (visible dans taskschd.msc). */ +export const WINDOWS_TASK_NAME = 'Arboretum'; + +/** + * Arguments `schtasks /Create` d'une tâche « au démarrage de session utilisateur ». `/RL LIMITED` + * garde les privilèges de l'utilisateur (jamais d'élévation), `/F` rend la commande idempotente. + * `/TR` attend UNE chaîne de commande : chaque token à espaces est donc quoté. + */ +export function windowsCreateArgs(input: { taskName: string; exec: string; scriptArgs: string[] }): string[] { + const command = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' '); + return ['/Create', '/TN', input.taskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F']; +} + export function parseInstallArgs(argv: string[]): InstallFlags { const { values } = parseArgs({ args: argv, @@ -207,7 +223,8 @@ export function printUsage(version: string): void { Usage: arboretum [flags] Start the daemon (default) arboretum serve [flags] Start the daemon (explicit alias) - arboretum install [flags] Install & start a user service (systemd on Linux, launchd on macOS) + arboretum install [flags] Install & start a user service (systemd on Linux, launchd on macOS, + Task Scheduler on Windows) arboretum uninstall Stop & remove the user service arboretum status Show the service status arboretum help Show this help @@ -218,6 +235,9 @@ Daemon flags: --allow-origin Additional allowed Origin (repeatable) --db SQLite database path --vapid-contact VAPID contact subject for Web Push + --claude-home Override the Claude install root (default ~/.claude) + --print-token Print the access token on start (bootstrap it if missing) + --no-discover Disable repository auto-discovery (startup + periodic scan) --i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid, prefer Tailscale Serve) Install flags (daemon flags above are propagated to the service): @@ -301,6 +321,23 @@ export async function runInstall(argv: string[]): Promise { return; } + if (platform === 'win32') { + // Windows : Planificateur de tâches, déclenchement à l'ouverture de session. Pas de service NT + // (il tournerait hors session utilisateur, donc sans accès au profil ni au CLI `claude`). + const createArgs = windowsCreateArgs({ taskName: WINDOWS_TASK_NAME, exec, scriptArgs }); + if (flags.dryRun) { + console.log(`# commands:\nschtasks ${createArgs.join(' ')}`); + if (!flags.noEnable) console.log(`schtasks /Run /TN ${WINDOWS_TASK_NAME}`); + return; + } + bootstrapToken(serviceArgs); + run('schtasks.exe', createArgs, { check: true }); + console.log(`Registered scheduled task "${WINDOWS_TASK_NAME}" (runs at logon).`); + if (!flags.noEnable) run('schtasks.exe', ['/Run', '/TN', WINDOWS_TASK_NAME], { check: true }); + console.log(`\nArboretum task installed. Manage it with: schtasks /Query /TN ${WINDOWS_TASK_NAME}`); + return; + } + // macOS (launchd) const logs = launchdLogPaths(); const programArguments = [exec, ...scriptArgs]; @@ -352,6 +389,12 @@ export async function runUninstall(argv: string[]): Promise { console.log('Arboretum service removed.'); return; } + if (platform === 'win32') { + run('schtasks.exe', ['/End', '/TN', WINDOWS_TASK_NAME]); // best-effort : arrête l'instance courante + run('schtasks.exe', ['/Delete', '/TN', WINDOWS_TASK_NAME, '/F']); + console.log('Arboretum scheduled task removed.'); + return; + } const plistPath = launchAgentPlistPath(flags.label); const uid = process.getuid?.() ?? 0; run('launchctl', ['bootout', `gui/${uid}/${flags.label}`]); // best-effort @@ -371,6 +414,10 @@ export async function runStatus(argv: string[]): Promise { process.exitCode = code; return; } + if (platform === 'win32') { + process.exitCode = run('schtasks.exe', ['/Query', '/TN', WINDOWS_TASK_NAME, '/V', '/FO', 'LIST']); + return; + } const uid = process.getuid?.() ?? 0; const code = run('launchctl', ['print', `gui/${uid}/${flags.label}`]); console.log(`\nLogs: ${launchdLogPaths().out}`); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index f2828a8..c02f9cb 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -25,6 +25,21 @@ export interface Config { autoDiscover: boolean; } +/** + * Racine des données applicatives, par plateforme. `XDG_DATA_HOME` reste prioritaire partout (l'app de + * bureau s'en sert pour isoler ses données). Sinon : `%APPDATA%` sur Windows (`~/.local/share` n'y a + * aucun sens et n'est ni sauvegardé ni migré par l'OS), `~/.local/share` ailleurs. + */ +export function defaultDataRoot( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string { + if (env.XDG_DATA_HOME) return env.XDG_DATA_HOME; + if (platform === 'win32') return env.APPDATA ?? join(home, 'AppData', 'Roaming'); + return join(home, '.local', 'share'); +} + export function loadConfig(argv = process.argv.slice(2)): Config { const { values } = parseArgs({ args: argv, @@ -55,7 +70,7 @@ 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(defaultDataRoot(), 'arboretum'); 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 diff --git a/packages/server/src/core/claude-launcher.ts b/packages/server/src/core/claude-launcher.ts index 6df84dd..28236f5 100644 --- a/packages/server/src/core/claude-launcher.ts +++ b/packages/server/src/core/claude-launcher.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { accessSync, constants } from 'node:fs'; +import { accessSync, constants, existsSync } from 'node:fs'; export interface SpawnSpec { file: string; @@ -13,7 +13,7 @@ export interface SpawnOptions { resume?: { claudeSessionId: string; fork?: boolean }; /** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir ` répété. */ addDirs?: string[]; - /** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */ + /** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via le PATH. */ claudeBinPath?: string | null; /** * Lancement de projet (« Démarrer le projet ») : au lieu de `bash --norc`, lance le shell de @@ -22,6 +22,8 @@ export interface SpawnOptions { * (PATH minimal, cf. resolveClaudeBin) : sinon `npm`/`docker` seraient introuvables. Ignoré pour claude. */ login?: boolean; + /** plateforme cible (injectable pour les tests) ; défaut `process.platform`. */ + platform?: NodeJS.Platform; } /** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */ @@ -36,16 +38,32 @@ export interface ClaudeBinDiagnostic { let cachedClaudeBin: string | null = null; +/** + * Commande de recherche dans le PATH selon la plateforme : `which` n'existe PAS sur Windows, c'est + * `where.exe` (qui peut renvoyer plusieurs lignes, la première étant la retenue). + */ +export function whichCommand(platform: NodeJS.Platform = process.platform): { file: string; args: string[] } { + return platform === 'win32' ? { file: 'where.exe', args: ['claude'] } : { file: 'which', args: ['claude'] }; +} + /** Recherche `claude` dans le PATH (sans throw). null si absent. */ -function findClaudeOnPath(): string | null { +function findClaudeOnPath(platform: NodeJS.Platform = process.platform): string | null { + const { file, args } = whichCommand(platform); try { - return execFileSync('which', ['claude'], { encoding: 'utf8' }).trim() || null; + const out = execFileSync(file, args, { encoding: 'utf8' }); + // `where.exe` liste toutes les correspondances : on garde la première. + return out.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? null; } catch { return null; } } -function isExecutable(path: string): boolean { +/** + * « Est-ce lançable ? ». Sur Windows, le bit d'exécution POSIX n'a aucun sens (NTFS n'en a pas) et + * `accessSync(X_OK)` y répond au hasard : on se contente donc de l'existence du fichier. + */ +function isExecutable(path: string, platform: NodeJS.Platform = process.platform): boolean { + if (platform === 'win32') return existsSync(path); try { accessSync(path, constants.X_OK); return true; @@ -57,9 +75,9 @@ function isExecutable(path: string): boolean { /** * Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel * (validé exécutable, message clair sinon) et JAMAIS mis en cache (modifiable à chaud). Sinon : - * `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans - * ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par - * `arboretum install`). + * recherche dans le PATH (`which` / `where.exe`), mise en cache. Un service systemd/launchd démarre + * avec un PATH minimal sans ~/.local/bin → la recherche y échoue ; d'où le réglage de chemin explicite + * (et le PATH figé par `arboretum install`). */ export function resolveClaudeBin(configuredPath?: string | null): string { if (configuredPath) { @@ -92,32 +110,48 @@ export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiag const KNOWN_LOGIN_SHELLS = new Set(['bash', 'zsh', 'fish']); /** - * Shell de login pour « Démarrer le projet » : `$SHELL` s'il est un shell interactif connu - * (bash/zsh/fish), sinon fallback `bash`. Évite qu'un `$SHELL` exotique (dash…) sorte aussitôt - * avec `-l -i` et laisse un terminal vide. + * Shell interactif pour « Démarrer le projet ». + * + * POSIX : `$SHELL -l -i` s'il fait partie des shells connus supportant ces options (bash/zsh/fish), + * sinon `bash` (un `$SHELL=dash` sortirait aussitôt avec `-l -i`, laissant un terminal vide). + * + * Windows : PowerShell, en restant attaché après la commande auto-tapée (`-NoExit`), avec repli sur + * `cmd.exe /K`. `%COMSPEC%` n'est PAS utilisé comme shell de lancement : il pointe cmd.exe, qui ne + * charge aucun profil utilisateur. La commande est ensuite auto-tapée par le PtyManager, exactement + * comme sous POSIX · le mécanisme est indépendant du shell. */ -function loginShell(): string { - const shell = process.env.SHELL; - if (shell && KNOWN_LOGIN_SHELLS.has(shell.split('/').pop() ?? '')) return shell; - return 'bash'; +export function resolveInteractiveShell( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): { file: string; args: string[] } { + if (platform === 'win32') { + const pwsh = env.ARBORETUM_SHELL ?? 'powershell.exe'; + return { file: pwsh, args: ['-NoLogo', '-NoExit'] }; + } + const shell = env.SHELL; + const file = shell && KNOWN_LOGIN_SHELLS.has(shell.split('/').pop() ?? '') ? shell : 'bash'; + return { file, args: ['-l', '-i'] }; +} + +/** Shell non interactif « neutre » (terminal simple, hors lancement de projet). */ +export function resolvePlainShell(platform: NodeJS.Platform = process.platform): { file: string; args: string[] } { + if (platform === 'win32') return { file: 'powershell.exe', args: ['-NoLogo', '-NoExit'] }; + return { file: 'bash', args: ['--norc'] }; } /** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { + const platform = opts.platform ?? process.platform; const env: NodeJS.ProcessEnv = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', }; if (opts.command === 'bash') { - // Lancement de projet : shell de login interactif de l'utilisateur (charge PATH/nvm/asdf). - // `-l` (login) exécute les profils, `-i` (interactif) reste attaché après la commande auto-tapée. - // On n'utilise `$SHELL` que s'il fait partie des shells interactifs connus supportant `-l -i` - // (bash/zsh/fish) ; sinon fallback bash (ex. `$SHELL=dash` sortirait avec `-l -i`). - if (opts.login) { - return { file: loginShell(), args: ['-l', '-i'], env }; - } - return { file: 'bash', args: ['--norc'], env }; + // `'bash'` désigne « le shell de la machine », pas littéralement bash : le contrat d'API reste + // stable (claude|bash) et c'est ici qu'on choisit le shell réel par plateforme. + const { file, args } = opts.login ? resolveInteractiveShell(platform) : resolvePlainShell(platform); + return { file, args, env }; } const args: string[] = []; if (opts.resume) { diff --git a/packages/server/src/core/fs-watcher.ts b/packages/server/src/core/fs-watcher.ts index a50936a..efd196b 100644 --- a/packages/server/src/core/fs-watcher.ts +++ b/packages/server/src/core/fs-watcher.ts @@ -7,9 +7,33 @@ import { resolve, sep, join } from 'node:path'; import chokidar, { type FSWatcher } from 'chokidar'; import { resolveGitDir } from './git.js'; -const DEFAULT_MAX_WATCHERS = 32; +// Plafond du pool : l'arbre de projets peut désormais « regarder » tous les worktrees des dépôts +// dépliés (et non plus le seul worktree du panneau Git), il faut donc de la marge. Les entrées +// épinglées (session vivante, checkout principal) ne sont jamais évincées, cf. evictIfNeeded. +const DEFAULT_MAX_WATCHERS = 64; const DEBOUNCE_MS = 200; +/** + * Répertoires lourds ignorés en plus de `.git` : ils concentrent l'essentiel des descripteurs inotify + * sans jamais rien apprendre sur le statut git. Liste volontairement CONSERVATRICE (pas de `dist`, + * `build`, `out` ni `vendor`, qui sont versionnés dans certains projets : les ignorer ferait manquer + * un vrai changement). + */ +const IGNORED_DIRS = [ + 'node_modules', + '.venv', + 'venv', + '__pycache__', + '.turbo', + '.cache', + '.pnpm-store', + 'coverage', + '.next', + '.nuxt', + '.output', + 'target', +]; + export interface FsWatcherEvents { /** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */ worktree_fs_change: [{ repoId: string; path: string }]; @@ -33,11 +57,14 @@ interface WatchEntry { /** * Ignore tout sous `.git/` SAUF `HEAD` et `index` (⇒ on détecte le `git checkout` externe et le - * staging) ainsi que `node_modules`. chokidar n'ignore pas le dossier `.git` lui-même afin de - * pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers volumineux (objects…). + * staging) ainsi que les répertoires de `IGNORED_DIRS`. chokidar n'ignore pas le dossier `.git` + * lui-même afin de pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers + * volumineux (objects…). */ export function isIgnoredPath(p: string): boolean { - if (p.includes(`${sep}node_modules${sep}`) || p.endsWith(`${sep}node_modules`)) return true; + for (const dir of IGNORED_DIRS) { + if (p.includes(`${sep}${dir}${sep}`) || p.endsWith(`${sep}${dir}`)) return true; + } if (p.includes(`${sep}.git${sep}`)) { return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`)); } diff --git a/packages/server/src/core/git-auth.ts b/packages/server/src/core/git-auth.ts index 4072b96..f5d9ab9 100644 --- a/packages/server/src/core/git-auth.ts +++ b/packages/server/src/core/git-auth.ts @@ -1,7 +1,7 @@ // Préparation d'un environnement d'authentification git ÉPHÉMÈRE (P12). HTTPS (pat/app_password) : -// les identifiants sont fournis via GIT_ASKPASS (script 0o700 lisant deux variables d'env), JAMAIS -// dans l'URL ni dans `.git/config`. GIT_TERMINAL_PROMPT=0 (pas d'invite bloquante). Le script est -// supprimé en `finally` ; le secret ne transite que par l'env du process enfant (jamais loggé). +// les identifiants sont fournis via GIT_ASKPASS (script à permissions restreintes lisant deux variables +// d'env), JAMAIS dans l'URL ni dans `.git/config`. GIT_TERMINAL_PROMPT=0 (pas d'invite bloquante). Le +// script est supprimé en `finally` ; le secret ne transite que par l'env du process enfant (jamais loggé). import { mkdtemp, writeFile, rm, chmod } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,20 +11,40 @@ import type { GitAuth } from './git-clients/index.js'; // Identité HTTPS par défaut quand l'utilisateur n'a pas fourni de username (token-as-password). const SERVICE_DEFAULT_USER: Record = { github: 'x-access-token', gitlab: 'oauth2', gitea: 'oauth2' }; +const ASKPASS_SH = "#!/bin/sh\ncase \"$1\" in\n Username*) printf '%s' \"$ARB_GIT_USER\" ;;\n *) printf '%s' \"$ARB_GIT_PASS\" ;;\nesac\n"; + +// Équivalent Windows : git appelle GIT_ASKPASS avec l'invite en argument. `echo` de cmd.exe ajoute un +// saut de ligne que git tolère (il trime la réponse). `~1` = premier argument sans les guillemets. +const ASKPASS_CMD = [ + '@echo off', + 'echo %~1 | findstr /b /i "Username" >nul', + 'if %errorlevel%==0 (echo %ARB_GIT_USER%) else (echo %ARB_GIT_PASS%)', + '', +].join('\r\n'); + +/** + * Nom et contenu du script askpass selon la plateforme. Un `.sh` avec shebang n'est PAS exécutable sur + * Windows : sans cette variante `.cmd`, tout clone/push HTTPS par jeton y échouait silencieusement + * (git n'obtenait aucun identifiant et abandonnait, GIT_TERMINAL_PROMPT étant à 0). + */ +export function askpassScript(platform: NodeJS.Platform = process.platform): { name: string; content: string; mode: number } { + return platform === 'win32' + ? { name: 'askpass.cmd', content: ASKPASS_CMD, mode: 0o700 } + : { name: 'askpass.sh', content: ASKPASS_SH, mode: 0o700 }; +} + export async function withGitAuth( service: GitService, auth: GitAuth, fn: (env: NodeJS.ProcessEnv) => Promise, ): Promise { const dir = await mkdtemp(join(tmpdir(), 'arb-gitauth-')); - const askpass = join(dir, 'askpass.sh'); + const script = askpassScript(); + const askpass = join(dir, script.name); const user = auth.username || SERVICE_DEFAULT_USER[service]; - await writeFile( - askpass, - "#!/bin/sh\ncase \"$1\" in\n Username*) printf '%s' \"$ARB_GIT_USER\" ;;\n *) printf '%s' \"$ARB_GIT_PASS\" ;;\nesac\n", - { mode: 0o700 }, - ); - await chmod(askpass, 0o700); + await writeFile(askpass, script.content, { mode: script.mode }); + // chmod best-effort : sans effet sur NTFS (comme ailleurs dans le code, cf. config.ts). + await chmod(askpass, script.mode).catch(() => {}); const env: NodeJS.ProcessEnv = { ...process.env, GIT_ASKPASS: askpass, diff --git a/packages/server/src/core/git.ts b/packages/server/src/core/git.ts index 1cad80d..d060de6 100644 --- a/packages/server/src/core/git.ts +++ b/packages/server/src/core/git.ts @@ -2,7 +2,7 @@ // les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant. import { execFile, spawn } from 'node:child_process'; import { resolve, sep } from 'node:path'; -import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode, FileChange } from '@arboretum/shared'; +import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode, FileChange, CommitEntry } from '@arboretum/shared'; const GIT_TIMEOUT_MS = 10_000; // `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large. @@ -502,6 +502,81 @@ export async function lastCommit(worktreePath: string): Promise<{ hash: string; return { hash: r.stdout.slice(0, idx), subject: r.stdout.slice(idx + 1).replace(/\n$/, '') }; } +const MAX_LOG_LIMIT = 200; + +/** + * Hash de commit : hexadécimal, 4 à 64 caractères. Bornage strict AVANT de le passer à git · un + * identifiant libre ouvrirait la porte à des révisions arbitraires ou à des options déguisées (`-…`). + */ +export function isValidCommitish(hash: string): boolean { + return /^[0-9a-f]{4,64}$/i.test(hash); +} + +/** + * Découpe la sortie de `git log -z --format=` en enregistrements. Isolée et + * pure pour être testable sans dépôt : c'est le point délicat (avec `-z`, les séparateurs de champs et + * d'enregistrements sont tous des NUL, il faut donc compter les champs). + */ +export function parseLogZ(stdout: string, fieldsPerCommit: number): string[][] { + const fields = stdout.split('\0'); + const out: string[][] = []; + for (let i = 0; i + fieldsPerCommit - 1 < fields.length; i += fieldsPerCommit) { + const rec = fields.slice(i, i + fieldsPerCommit); + if ((rec[0] ?? '').trim() === '') continue; + out.push(rec); + } + return out; +} + +/** + * Historique de la branche du worktree. `-z` + champs séparés par NUL : un sujet contenant un saut de + * ligne ne peut pas casser le parsing. `unpushedCount` = commits de tête pas encore poussés + * (`@{u}..HEAD`) ; `hasUpstream: false` signifie qu'AUCUN commit n'est publié (branche purement locale), + * ce que l'UI marque en bloc plutôt que de compter tout l'historique. + */ +export async function commitLog( + worktreePath: string, + opts: { limit?: number; skip?: number } = {}, +): Promise<{ commits: CommitEntry[]; unpushedCount: number; hasUpstream: boolean }> { + const limit = Math.min(Math.max(1, Math.trunc(opts.limit ?? 30)), MAX_LOG_LIMIT); + const skip = Math.max(0, Math.trunc(opts.skip ?? 0)); + const r = await gitRaw(worktreePath, [ + 'log', + `--max-count=${limit}`, + `--skip=${skip}`, + '-z', + '--format=%H%x00%h%x00%an%x00%aI%x00%s', + ]); + if (r.code !== 0) return { commits: [], unpushedCount: 0, hasUpstream: false }; // dépôt sans commit + const commits: CommitEntry[] = parseLogZ(r.stdout, 5).map((f) => ({ + hash: (f[0] ?? '').trim(), + shortHash: f[1] ?? '', + author: f[2] ?? '', + date: f[3] ?? '', + subject: (f[4] ?? '').replace(/\n$/, ''), + })); + const upstream = await gitRaw(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); + if (upstream.code !== 0) return { commits, unpushedCount: 0, hasUpstream: false }; + const count = await gitRaw(worktreePath, ['rev-list', '--count', '@{u}..HEAD']); + return { commits, unpushedCount: count.code === 0 ? Number(count.stdout.trim()) || 0 : 0, hasUpstream: true }; +} + +/** + * Diff complet d'un commit (`git show`), borné exactement comme `fileDiff` : refus des binaires, + * troncature au-delà de MAX_DIFF_BYTES. Le résultat étant un diff unifié, il passe dans le même + * parseur et la même vue que les diffs de fichiers. + */ +export async function commitDiff(worktreePath: string, hash: string): Promise<{ diff: string; binary: boolean; tooLarge: boolean }> { + if (!isValidCommitish(hash)) throw new Error(`Invalid commit hash: ${hash}`); + const out = await gitRaw(worktreePath, ['show', '--no-color', '--format=', hash]); + if (out.code !== 0) throw new Error(`Unknown commit: ${hash}`); + const raw = out.stdout; + const binary = /^Binary files .* differ$/m.test(raw) || raw.includes('GIT binary patch'); + if (binary) return { diff: '', binary: true, tooLarge: false }; + if (raw.length > MAX_DIFF_BYTES) return { diff: raw.slice(0, MAX_DIFF_BYTES), binary: false, tooLarge: true }; + return { diff: raw, binary: false, tooLarge: false }; +} + /** true si le HEAD courant n'est pas encore poussé (amend autorisé). Sans upstream → true. */ export async function isUnpushed(worktreePath: string): Promise { try { diff --git a/packages/server/src/core/pty-manager.ts b/packages/server/src/core/pty-manager.ts index 0584597..3b38461 100644 --- a/packages/server/src/core/pty-manager.ts +++ b/packages/server/src/core/pty-manager.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; +import { execFile } from 'node:child_process'; import { existsSync, statSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; @@ -372,17 +373,24 @@ export class PtyManager extends EventEmitter { const s = this.live.get(id); if (!s || s.exited) return false; try { - process.kill(s.proc.pid, 'SIGTERM'); + // Windows n'a pas de signaux : node-pty traduit `kill()` en fermeture de la pseudo-console, ce + // qui laisse échapper les petits-enfants (un `npm run dev` lancé dans le shell). Le SIGKILL + // différé est donc remplacé par un `taskkill /T` qui tue l'ARBRE complet. + if (process.platform === 'win32') s.proc.kill(); + else process.kill(s.proc.pid, 'SIGTERM'); } catch { return false; } s.killTimer ??= setTimeout(() => { - if (!s.exited) { - try { + if (s.exited) return; + try { + if (process.platform === 'win32') { + execFile('taskkill.exe', ['/PID', String(s.proc.pid), '/T', '/F'], () => {}); + } else { process.kill(s.proc.pid, 'SIGKILL'); - } catch { - /* déjà mort */ } + } catch { + /* déjà mort */ } }, KILL_GRACE_MS); return true; diff --git a/packages/server/src/core/session-match.ts b/packages/server/src/core/session-match.ts new file mode 100644 index 0000000..ceb97d8 --- /dev/null +++ b/packages/server/src/core/session-match.ts @@ -0,0 +1,33 @@ +// Adaptateur serveur de la corrélation session ↔ worktree : la RÈGLE vit dans `@arboretum/shared` +// (`path-match.ts`, partagée avec le front et l'extension) ; ici on se contente de normaliser les +// chemins avec `resolve()` avant de la lui passer, puisque le serveur manipule des chemins venant de +// git, de la base et de requêtes (fins de slash, `..`, chemins relatifs au cwd du process). +import { resolve } from 'node:path'; +import { + containsPath as sharedContains, + findWorktreeForCwd as sharedFind, + sessionBelongsToWorktree as sharedBelongs, +} from '@arboretum/shared'; + +export function containsPath(parent: string, child: string): boolean { + return sharedContains(resolve(parent), resolve(child)); +} + +export function sessionBelongsToWorktree( + session: { cwd: string; addedDirs?: string[] }, + worktreePath: string, + others: string[] = [], +): boolean { + return sharedBelongs( + { cwd: resolve(session.cwd), ...(session.addedDirs ? { addedDirs: session.addedDirs.map((d) => resolve(d)) } : {}) }, + resolve(worktreePath), + others.map((p) => resolve(p)), + ); +} + +export function findWorktreeForCwd(cwd: string, worktrees: T[]): T | null { + // On résout une copie pour la comparaison, puis on renvoie l'objet d'origine (le chemin brut est ce + // que le reste du code attend, notamment les clés du watcher FS). + const normalized = worktrees.map((w) => ({ w, path: resolve(w.path) })); + return sharedFind(resolve(cwd), normalized)?.w ?? null; +} diff --git a/packages/server/src/core/worktree-manager.ts b/packages/server/src/core/worktree-manager.ts index f3aa48f..51179d3 100644 --- a/packages/server/src/core/worktree-manager.ts +++ b/packages/server/src/core/worktree-manager.ts @@ -28,6 +28,8 @@ import { amendCommit, cleanFiles, commitAll, + commitDiff, + commitLog, commitStaged, defaultBranch, fetchRemote, @@ -38,6 +40,7 @@ import { isSafeRelativePath, isUnpushed, isValidBranchName, + isValidCommitish, listBranches, listChanges, listWorktrees, @@ -53,7 +56,8 @@ import { type ParsedWorktree, } from './git.js'; import type { FsWatcherService } from './fs-watcher.js'; -import type { FileChange, FileDiffResponse } from '@arboretum/shared'; +import { findWorktreeForCwd, sessionBelongsToWorktree } from './session-match.js'; +import type { CommitDiffResponse, FileChange, FileDiffResponse, WorktreeLogResponse } from '@arboretum/shared'; const FACTS_TTL_MS = 2500; const HOOK_TIMEOUT_MS = 5 * 60_000; @@ -153,6 +157,8 @@ export class WorktreeManager extends EventEmitter { private readonly locks = new Map>(); /** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */ private scanInFlight: Promise | null = null; + /** Worktree épinglé au watcher FS pour chaque session vivante (clé = id de session). */ + private readonly pinnedSessions = new Map(); constructor( private readonly db: Db, @@ -170,6 +176,46 @@ export class WorktreeManager extends EventEmitter { if (row) void this.emitWorktree(row, path).catch(() => {}); this.emit('worktree_changes', { repoId, path }); }); + // Une session vivante rend son worktree « actif » : on épingle son watcher FS pour que les + // compteurs git restent temps réel même si aucun client ne regarde ce worktree. C'est le cas + // nominal du travail en CLI : l'agent écrit dans un worktree de feature pendant qu'on regarde + // ailleurs. Sans cette épingle, le point « modifié » de l'arbre restait figé sur le dernier + // listing REST. + this.ptyManager.on('session_update', (s) => { + void this.syncSessionPin(s).catch(() => {}); + }); + } + + /** Épingle (session vivante) ou libère (session terminée) le watcher FS du worktree d'une session. */ + private async syncSessionPin(s: SessionSummary): Promise { + if (!this.fsWatcher) return; + const pinned = this.pinnedSessions.get(s.id); + if (!s.live) { + if (!pinned) return; + this.pinnedSessions.delete(s.id); + this.fsWatcher.unpinSession(pinned.repoId, pinned.path); + return; + } + if (pinned) return; // déjà épinglé : `session_update` bat au rythme de l'activité + const target = await this.resolveWorktreeForCwd(s.cwd); + if (!target) return; // session hors de tout repo enregistré + this.pinnedSessions.set(s.id, target); + this.fsWatcher.pinSession(target.repoId, target.path); + } + + /** + * Worktree connu (tous repos non masqués) contenant ce cwd, le plus spécifique. Un worktree lié vit + * souvent HORS de l'arborescence de son repo : on ne peut donc pas écarter un repo sur son seul + * chemin, il faut ses worktrees réels (servis par le cache court partagé avec les listings). + */ + private async resolveWorktreeForCwd(cwd: string): Promise<{ repoId: string; path: string } | null> { + const rows = this.db.prepare('SELECT id, path FROM repos WHERE hidden = 0').all() as unknown as Array<{ id: string; path: string }>; + const candidates: Array<{ repoId: string; path: string }> = []; + for (const row of rows) { + const facts = await this.repoFacts(row).catch(() => []); + for (const f of facts) candidates.push({ repoId: row.id, path: f.w.path }); + } + return findWorktreeForCwd(cwd, candidates); } // ---- repos ---- @@ -336,20 +382,28 @@ export class WorktreeManager extends EventEmitter { // ---- worktrees ---- /** - * Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. + * Sessions (managées + découvertes) rattachées à ce worktree : cwd dans le worktree (y compris un + * sous-répertoire de « Démarrer le projet ») ou worktree relié en `--add-dir` par une session de + * groupe · voir `sessionBelongsToWorktree`. `siblings` = les autres worktrees du repo, indispensables + * pour qu'un worktree imbriqué ne voie pas ses sessions attribuées aussi au checkout principal. * Les sessions explicitement masquées (`hidden`) sont exclues, cohérent avec `/api/v1/sessions` * (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé * au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de * suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre. */ - private sessionsForCwd(path: string, opts?: { includeHidden?: boolean }): SessionSummary[] { - const rp = resolve(path); + private sessionsForCwd(path: string, opts?: { includeHidden?: boolean; siblings?: string[] }): SessionSummary[] { return mergeSessions(this.ptyManager.list(), this.discovery.list()) - .filter((s) => resolve(s.cwd) === rp) + .filter((s) => sessionBelongsToWorktree(s, path, opts?.siblings ?? [])) .filter((s) => opts?.includeHidden || !s.hidden); } - private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary { + private toSummary( + repoId: string, + repoPath: string, + w: ParsedWorktree, + status: WorktreeGitStatus, + siblings: string[] = [], + ): WorktreeSummary { return { repoId, path: w.path, @@ -360,11 +414,11 @@ export class WorktreeManager extends EventEmitter { prunable: w.prunable, isMain: resolve(w.path) === resolve(repoPath), git: status, - sessions: this.sessionsForCwd(w.path), + sessions: this.sessionsForCwd(w.path, { siblings }), }; } - private async repoFacts(row: RepoRow, noCache = false): Promise> { + private async repoFacts(row: { id: string; path: string }, noCache = false): Promise> { const cached = this.factsCache.get(row.id); if (!noCache && cached && Date.now() - cached.at < FACTS_TTL_MS) return cached.facts; const parsed = (await listWorktrees(row.path)).filter((w) => !w.bare); @@ -377,7 +431,8 @@ export class WorktreeManager extends EventEmitter { const row = this.getRepoRow(repoId); if (!row) return []; const facts = await this.repoFacts(row, noCache); - return facts.map(({ w, status }) => this.toSummary(row.id, row.path, w, status)); + const paths = facts.map(({ w }) => w.path); + return facts.map(({ w, status }) => this.toSummary(row.id, row.path, w, status, paths)); } async listAllWorktrees(): Promise { @@ -411,9 +466,19 @@ export class WorktreeManager extends EventEmitter { } private async emitWorktree(row: RepoRow, path: string): Promise { - const w = await this.findWorktree(row, path); + // On liste tous les worktrees du repo (et pas seulement celui visé) pour désambiguïser la + // corrélation des sessions entre worktrees imbriqués (cf. sessionsForCwd). + const all = (await listWorktrees(row.path)).filter((w) => !w.bare); + const rp = resolve(path); + const w = all.find((x) => resolve(x.path) === rp); if (!w) return null; - const summary = this.toSummary(row.id, row.path, w, await worktreeStatus(w.path)); + const summary = this.toSummary( + row.id, + row.path, + w, + await worktreeStatus(w.path), + all.map((x) => x.path), + ); this.emit('worktree_update', { repoId: row.id, worktree: summary }); return summary; } @@ -520,6 +585,25 @@ export class WorktreeManager extends EventEmitter { return listChanges(w.path); } + /** Historique de la branche du worktree (lecture, hors lock) : « ce qui a déjà été acté ». */ + async getWorktreeLog(repoId: string, path: string, opts: { limit?: number; skip?: number }): Promise { + const { w } = await this.requireWorktree(repoId, path); + const { commits, unpushedCount, hasUpstream } = await commitLog(w.path, opts); + return { repoId, path: w.path, commits, unpushedCount, hasUpstream }; + } + + /** Diff unifié complet d'un commit (lecture, hors lock). Le hash est validé par la couche git. */ + async getCommitDiff(repoId: string, path: string, hash: string): Promise { + const { w } = await this.requireWorktree(repoId, path); + if (!isValidCommitish(hash)) throw httpError(400, 'BAD_COMMIT', 'Invalid commit hash'); + try { + const d = await commitDiff(w.path, hash); + return { path: w.path, commit: hash, binary: d.binary, tooLarge: d.tooLarge, diff: d.diff }; + } catch (err) { + throw httpError(404, 'NOT_FOUND', (err as Error).message); + } + } + /** Diff unifié d'un fichier (détecte untracked → `git diff --no-index`). Lecture, hors lock. */ async getFileDiff(repoId: string, path: string, file: string, staged: boolean): Promise { const { w } = await this.requireWorktree(repoId, path); @@ -813,8 +897,10 @@ export class WorktreeManager extends EventEmitter { const w = await this.findWorktree(row, path); if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo'); if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree'); - // garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite. - if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) { + // garde-fou : une session vivante tourne dans ce worktree (ou dans un de ses sous-répertoires, ou + // le relie en `--add-dir`) → exiger une confirmation explicite. + const siblings = (await listWorktrees(row.path)).map((x) => x.path); + if (!force && this.sessionsForCwd(w.path, { includeHidden: true, siblings }).some((s) => s.live)) { throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree: pass force to delete anyway'); } return this.withLock(repoId, async () => { diff --git a/packages/server/src/routes/git.ts b/packages/server/src/routes/git.ts index 2ea04d4..105aa14 100644 --- a/packages/server/src/routes/git.ts +++ b/packages/server/src/routes/git.ts @@ -5,7 +5,9 @@ import type { FastifyInstance } from 'fastify'; import type { WorktreeChangesResponse, + CommitDiffResponse, FileDiffResponse, + WorktreeLogResponse, WorktreeFilesRequest, DiscardFilesRequest, FetchWorktreeRequest, @@ -33,12 +35,48 @@ export function registerGitRoutes(app: FastifyInstance, wt: WorktreeManager, db: } }); - // Diff unifié d'un fichier (staged ou non ; untracked détecté côté manager). + // Historique de la branche du worktree (« ce qui a déjà été acté », + ce qui n'est pas poussé). + app.get('/api/v1/repos/:id/worktrees/log', async (req, reply) => { + const { id } = req.params as { id: string }; + const q = req.query as { path?: string; limit?: string; skip?: string }; + if (typeof q.path !== 'string' || q.path === '') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); + } + // Bornes appliquées côté couche git (limite dure) : ici on se contente de convertir. + const limit = q.limit !== undefined ? Number(q.limit) : undefined; + const skip = q.skip !== undefined ? Number(q.skip) : undefined; + if ((limit !== undefined && !Number.isFinite(limit)) || (skip !== undefined && !Number.isFinite(skip))) { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'limit and skip must be numbers' } }); + } + try { + const res = await wt.getWorktreeLog(id, q.path, { + ...(limit !== undefined ? { limit } : {}), + ...(skip !== undefined ? { skip } : {}), + }); + return reply.send(res satisfies WorktreeLogResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + // Diff unifié : d'un fichier (`file`), ou d'un commit entier (`commit`). Les deux formes renvoient un + // diff unifié, donc le même parseur et la même vue côté client. app.get('/api/v1/repos/:id/worktrees/diff', async (req, reply) => { const { id } = req.params as { id: string }; - const q = req.query as { path?: string; file?: string; staged?: string }; - if (typeof q.path !== 'string' || q.path === '' || typeof q.file !== 'string' || q.file === '') { - return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and file are required' } }); + const q = req.query as { path?: string; file?: string; staged?: string; commit?: string }; + if (typeof q.path !== 'string' || q.path === '') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); + } + if (typeof q.commit === 'string' && q.commit !== '') { + try { + const res = await wt.getCommitDiff(id, q.path, q.commit); + return reply.send(res satisfies CommitDiffResponse); + } catch (err) { + return sendManagerError(reply, err); + } + } + if (typeof q.file !== 'string' || q.file === '') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'file or commit is required' } }); } const staged = q.staged === '1' || q.staged === 'true'; try { diff --git a/packages/server/test/cli-install.test.ts b/packages/server/test/cli-install.test.ts index 9b714fd..a75f600 100644 --- a/packages/server/test/cli-install.test.ts +++ b/packages/server/test/cli-install.test.ts @@ -12,6 +12,7 @@ import { xmlEscape, systemdUnitPath, launchAgentPlistPath, + windowsCreateArgs, } from '../src/cli/install.js'; describe('cli install · detectPlatform', () => { @@ -20,9 +21,10 @@ describe('cli install · detectPlatform', () => { expect(detectPlatform('darwin')).toBe('darwin'); }); - it('rejette les autres plateformes avec un message clair', () => { - expect(() => detectPlatform('win32')).toThrow(/Linux \(systemd\) and macOS \(launchd\)/); + it('rejette les plateformes sans superviseur connu, avec un message clair', () => { + // win32 est désormais SUPPORTÉ (Planificateur de tâches) : cf. la suite dédiée plus bas. expect(() => detectPlatform('freebsd')).toThrow(/freebsd/); + expect(() => detectPlatform('aix')).toThrow(/Task Scheduler/); }); }); @@ -192,3 +194,30 @@ describe('cli install · chemins', () => { ); }); }); + +describe('P14 · Windows (Planificateur de tâches)', () => { + it('detectPlatform accepte win32', () => { + expect(detectPlatform('win32')).toBe('win32'); + expect(() => detectPlatform('freebsd')).toThrow(/Task Scheduler/); + }); + + it('windowsCreateArgs : tâche à l’ouverture de session, sans élévation, idempotente', () => { + const args = windowsCreateArgs({ + taskName: 'Arboretum', + exec: 'C:\\Program Files\\nodejs\\node.exe', + scriptArgs: ['C:\\app\\dist\\index.js', '--port', '7317'], + }); + expect(args).toEqual([ + '/Create', + '/TN', + 'Arboretum', + '/TR', + '"C:\\Program Files\\nodejs\\node.exe" C:\\app\\dist\\index.js --port 7317', + '/SC', + 'ONLOGON', + '/RL', + 'LIMITED', + '/F', + ]); + }); +}); diff --git a/packages/server/test/git.test.ts b/packages/server/test/git.test.ts index 281d192..e9b4aa0 100644 --- a/packages/server/test/git.test.ts +++ b/packages/server/test/git.test.ts @@ -32,6 +32,10 @@ import { amendCommit, lastCommit, isUnpushed, + commitLog, + commitDiff, + isValidCommitish, + parseLogZ, } from '../src/core/git.js'; import { appendFileSync } from 'node:fs'; @@ -318,3 +322,79 @@ describe('addWorktree : résolution auto (créer / réutiliser)', () => { await expect(addWorktree(repo, { path: wt, branch: 'dup', mode: 'create' })).rejects.toBeDefined(); }); }); + +describe('P14 · historique (commitLog / commitDiff)', () => { + it('parseLogZ : découpe par paquets de champs et tolère un sujet multi-lignes', () => { + const stdout = ['h1', 's1', 'auteur', '2026-01-01T00:00:00Z', 'sujet\navec saut', 'h2', 's2', 'a2', 'd2', 'sujet 2'].join('\0'); + const recs = parseLogZ(stdout, 5); + expect(recs).toHaveLength(2); + expect(recs[0]?.[4]).toBe('sujet\navec saut'); + expect(recs[1]?.[0]).toBe('h2'); + }); + + it('parseLogZ : ignore un enregistrement final vide (NUL de fin)', () => { + expect(parseLogZ(['h', 's', 'a', 'd', 'sub', ''].join('\0'), 5)).toHaveLength(1); + }); + + it('isValidCommitish : hex 4-64 uniquement (refuse une option déguisée)', () => { + expect(isValidCommitish('abc1234')).toBe(true); + expect(isValidCommitish('ABCDEF12')).toBe(true); + expect(isValidCommitish('abc')).toBe(false); + expect(isValidCommitish('--upload-pack=x')).toBe(false); + expect(isValidCommitish('HEAD')).toBe(false); + expect(isValidCommitish('main..HEAD')).toBe(false); + }); + + it('commitLog : ordre récent → ancien, champs remplis, sans upstream tout est local', async () => { + const repo = makeTmpRepo(); + appendFileSync(join(repo, 'README.md'), 'second\n'); + await commitAll(repo, 'deuxième commit'); + + const { commits, hasUpstream, unpushedCount } = await commitLog(repo); + expect(commits).toHaveLength(2); + expect(commits[0]?.subject).toBe('deuxième commit'); + expect(commits[1]?.subject).toBe('init'); + expect(commits[0]?.hash).toMatch(/^[0-9a-f]{40}$/); + expect(commits[0]?.shortHash.length).toBeGreaterThanOrEqual(7); + expect(commits[0]?.author).toBe('Test'); + expect(Number.isNaN(Date.parse(commits[0]?.date ?? ''))).toBe(false); + // branche locale sans remote : aucun commit n'est publié + expect(hasUpstream).toBe(false); + expect(unpushedCount).toBe(0); + }); + + it('commitLog : limit et skip bornent la fenêtre', async () => { + const repo = makeTmpRepo(); + for (const n of [1, 2, 3]) { + appendFileSync(join(repo, 'README.md'), `line ${n}\n`); + await commitAll(repo, `commit ${n}`); + } + expect((await commitLog(repo, { limit: 2 })).commits.map((c) => c.subject)).toEqual(['commit 3', 'commit 2']); + expect((await commitLog(repo, { limit: 1, skip: 2 })).commits.map((c) => c.subject)).toEqual(['commit 1']); + }); + + it('commitLog : dépôt sans aucun commit → liste vide, pas d’exception', async () => { + const dir = mkdtempSync(join(tmpdir(), 'arb-git-empty-')); + dirs.push(dir); + execFileSync('git', ['init', '-b', 'main'], { cwd: dir, stdio: 'pipe' }); + const res = await commitLog(dir); + expect(res.commits).toEqual([]); + expect(res.hasUpstream).toBe(false); + }); + + it('commitDiff : diff unifié du commit demandé, hash invalide et inconnu rejetés', async () => { + const repo = makeTmpRepo(); + writeFileSync(join(repo, 'nouveau.txt'), 'contenu\n'); + await commitAll(repo, 'ajout fichier'); + const [head] = (await commitLog(repo)).commits; + + const d = await commitDiff(repo, head?.hash ?? ''); + expect(d.binary).toBe(false); + expect(d.tooLarge).toBe(false); + expect(d.diff).toContain('nouveau.txt'); + expect(d.diff).toContain('+contenu'); + + await expect(commitDiff(repo, 'HEAD')).rejects.toThrow(/Invalid commit hash/); + await expect(commitDiff(repo, 'deadbeef')).rejects.toThrow(/Unknown commit/); + }); +}); diff --git a/packages/server/test/session-match.test.ts b/packages/server/test/session-match.test.ts new file mode 100644 index 0000000..7f2abda --- /dev/null +++ b/packages/server/test/session-match.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { containsPath, findWorktreeForCwd, sessionBelongsToWorktree } from '../src/core/session-match.js'; + +describe('containsPath', () => { + it('vrai pour le répertoire lui-même', () => { + expect(containsPath('/p/repo', '/p/repo')).toBe(true); + }); + + it('vrai pour un descendant', () => { + expect(containsPath('/p/repo', '/p/repo/packages/api')).toBe(true); + }); + + it('compare par segment, pas par préfixe de chaîne', () => { + // /p/repo ne contient PAS /p/repo-wt-feature (piège du startsWith nu) + expect(containsPath('/p/repo', '/p/repo-wt-feature')).toBe(false); + }); + + it('faux pour un ancêtre', () => { + expect(containsPath('/p/repo/api', '/p/repo')).toBe(false); + }); + + it('normalise les chemins non résolus', () => { + expect(containsPath('/p/repo', '/p/repo/./api/../api')).toBe(true); + }); +}); + +describe('sessionBelongsToWorktree', () => { + it('rattache une session lancée à la racine du worktree', () => { + expect(sessionBelongsToWorktree({ cwd: '/p/repo' }, '/p/repo')).toBe(true); + }); + + it('rattache une session lancée dans un sous-répertoire (« Démarrer le projet »)', () => { + // LaunchCommand.cwd autorise un sous-dossier : le terminal doit rester visible sous son worktree. + expect(sessionBelongsToWorktree({ cwd: '/p/repo/packages/api' }, '/p/repo')).toBe(true); + }); + + it('ne rattache pas une session d’un worktree frère', () => { + expect(sessionBelongsToWorktree({ cwd: '/p/repo-wt-feat' }, '/p/repo')).toBe(false); + }); + + it('donne un worktree imbriqué au plus spécifique, pas au principal', () => { + const session = { cwd: '/p/repo/.worktrees/feat/src' }; + const all = ['/p/repo', '/p/repo/.worktrees/feat']; + expect(sessionBelongsToWorktree(session, '/p/repo/.worktrees/feat', all)).toBe(true); + expect(sessionBelongsToWorktree(session, '/p/repo', all)).toBe(false); + }); + + it('rattache une session de groupe via ses répertoires reliés', () => { + // cwd = parent commun (hors des repos), les worktrees couverts vivent dans addedDirs. + const session = { cwd: '/p', addedDirs: ['/p/api', '/p/web'] }; + expect(sessionBelongsToWorktree(session, '/p/api')).toBe(true); + expect(sessionBelongsToWorktree(session, '/p/web')).toBe(true); + expect(sessionBelongsToWorktree(session, '/p/docs')).toBe(false); + }); + + it('ignore un addedDirs absent', () => { + expect(sessionBelongsToWorktree({ cwd: '/p/api' }, '/p/api', [])).toBe(true); + }); +}); + +describe('findWorktreeForCwd', () => { + const worktrees = [ + { repoId: 'r1', path: '/p/api' }, + { repoId: 'r1', path: '/p/api-wt-feat' }, + { repoId: 'r2', path: '/p/api/vendor/web' }, + ]; + + it('choisit le worktree le plus spécifique', () => { + expect(findWorktreeForCwd('/p/api/vendor/web/src', worktrees)?.repoId).toBe('r2'); + }); + + it('choisit le worktree frère exact et non le préfixe de chaîne', () => { + expect(findWorktreeForCwd('/p/api-wt-feat/src', worktrees)?.path).toBe('/p/api-wt-feat'); + }); + + it('renvoie null hors de tout worktree connu', () => { + expect(findWorktreeForCwd('/tmp/ailleurs', worktrees)).toBeNull(); + }); +}); diff --git a/packages/server/test/windows-support.test.ts b/packages/server/test/windows-support.test.ts new file mode 100644 index 0000000..777eb9b --- /dev/null +++ b/packages/server/test/windows-support.test.ts @@ -0,0 +1,94 @@ +// Support Windows du daemon : helpers purs, testés avec la plateforme INJECTÉE (ils doivent donc être +// vérifiables depuis Linux). Chacun corrige un point qui rendait le daemon inutilisable sur Windows. +import { describe, expect, it } from 'vitest'; +import { buildSpawnSpec, resolveInteractiveShell, resolvePlainShell, whichCommand } from '../src/core/claude-launcher.js'; +import { askpassScript } from '../src/core/git-auth.js'; +import { defaultDataRoot } from '../src/config.js'; + +describe('recherche du binaire claude dans le PATH', () => { + it('utilise where.exe sur Windows, which ailleurs', () => { + expect(whichCommand('win32')).toEqual({ file: 'where.exe', args: ['claude'] }); + expect(whichCommand('linux')).toEqual({ file: 'which', args: ['claude'] }); + expect(whichCommand('darwin').file).toBe('which'); + }); +}); + +describe('shell de lancement', () => { + it('POSIX : $SHELL connu en login interactif, sinon bash', () => { + expect(resolveInteractiveShell('linux', { SHELL: '/usr/bin/zsh' })).toEqual({ file: '/usr/bin/zsh', args: ['-l', '-i'] }); + expect(resolveInteractiveShell('linux', { SHELL: '/bin/dash' })).toEqual({ file: 'bash', args: ['-l', '-i'] }); + expect(resolveInteractiveShell('linux', {})).toEqual({ file: 'bash', args: ['-l', '-i'] }); + }); + + it('Windows : PowerShell qui reste attaché après la commande auto-tapée', () => { + const shell = resolveInteractiveShell('win32', {}); + expect(shell.file).toBe('powershell.exe'); + expect(shell.args).toContain('-NoExit'); + }); + + it('Windows : le shell est surchargeable par ARBORETUM_SHELL', () => { + expect(resolveInteractiveShell('win32', { ARBORETUM_SHELL: 'pwsh.exe' }).file).toBe('pwsh.exe'); + }); + + it('terminal simple : bash --norc sur POSIX, PowerShell sur Windows', () => { + expect(resolvePlainShell('linux')).toEqual({ file: 'bash', args: ['--norc'] }); + expect(resolvePlainShell('win32').file).toBe('powershell.exe'); + }); +}); + +describe('buildSpawnSpec · plateforme injectée', () => { + it('command=bash sur Windows lance PowerShell (le contrat d’API reste claude|bash)', () => { + const spec = buildSpawnSpec({ command: 'bash', platform: 'win32' }); + expect(spec.file).toBe('powershell.exe'); + expect(spec.env.TERM).toBe('xterm-256color'); + }); + + it('command=bash avec login sur Windows reste attaché', () => { + expect(buildSpawnSpec({ command: 'bash', login: true, platform: 'win32' }).args).toContain('-NoExit'); + }); + + it('command=bash sur Linux inchangé', () => { + expect(buildSpawnSpec({ command: 'bash', platform: 'linux' })).toMatchObject({ file: 'bash', args: ['--norc'] }); + }); +}); + +describe('script askpass git', () => { + it('Windows : .cmd (un .sh à shebang n’y est pas exécutable)', () => { + const s = askpassScript('win32'); + expect(s.name).toBe('askpass.cmd'); + expect(s.content).toContain('@echo off'); + expect(s.content).toContain('ARB_GIT_USER'); + expect(s.content).toContain('ARB_GIT_PASS'); + }); + + it('POSIX : .sh avec shebang', () => { + const s = askpassScript('linux'); + expect(s.name).toBe('askpass.sh'); + expect(s.content.startsWith('#!/bin/sh')).toBe(true); + expect(s.mode).toBe(0o700); + }); + + it('les deux variantes distinguent Username du mot de passe', () => { + for (const p of ['win32', 'linux'] as const) { + const c = askpassScript(p).content; + expect(c).toMatch(/Username/i); + } + }); +}); + +describe('racine des données', () => { + it('XDG_DATA_HOME est prioritaire partout (l’app de bureau s’en sert pour isoler)', () => { + expect(defaultDataRoot('win32', { XDG_DATA_HOME: '/iso' }, '/home/u')).toBe('/iso'); + expect(defaultDataRoot('linux', { XDG_DATA_HOME: '/iso' }, '/home/u')).toBe('/iso'); + }); + + it('Windows : %APPDATA%, avec repli sur AppData/Roaming', () => { + expect(defaultDataRoot('win32', { APPDATA: 'C:\\Users\\u\\AppData\\Roaming' }, 'C:\\Users\\u')).toBe('C:\\Users\\u\\AppData\\Roaming'); + expect(defaultDataRoot('win32', {}, '/home/u')).toBe('/home/u/AppData/Roaming'); + }); + + it('POSIX : ~/.local/share', () => { + expect(defaultDataRoot('linux', {}, '/home/u')).toBe('/home/u/.local/share'); + expect(defaultDataRoot('darwin', {}, '/Users/u')).toBe('/Users/u/.local/share'); + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 8d42ff1..57c62ce 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -237,6 +237,37 @@ export interface FileDiffResponse { /** texte du diff unifié git (vide si binaire ou tooLarge). */ diff: string; } +/** Un commit de l'historique d'un worktree (GET .../worktrees/log). */ +export interface CommitEntry { + /** hash complet (clé stable, utilisée pour demander le diff du commit). */ + hash: string; + /** hash court tel que git l'abrège (affichage). */ + shortHash: string; + author: string; + /** date d'auteur ISO 8601 (%aI). */ + date: string; + subject: string; +} +/** + * GET /api/v1/repos/:id/worktrees/log?path=&limit=&skip= : historique de la branche du worktree. + * `unpushedCount` compte les commits de tête pas encore poussés ; `hasUpstream: false` signifie qu'aucun + * commit n'est publié (branche purement locale) et que TOUS sont donc à considérer comme non poussés. + */ +export interface WorktreeLogResponse { + repoId: string; + path: string; + commits: CommitEntry[]; + unpushedCount: number; + hasUpstream: boolean; +} +/** GET /api/v1/repos/:id/worktrees/diff?path=&commit= : diff unifié complet d'un commit. */ +export interface CommitDiffResponse { + path: string; + commit: string; + binary: boolean; + tooLarge: boolean; + diff: string; +} /** GET /api/v1/repos/:id/files/content?wt=&path= : contenu d'un fichier (pour l'éditeur Monaco). */ export interface FileContentResponse { /** chemin relatif au worktree (POSIX). */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 00798b5..812af34 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './protocol.js'; export * from './api.js'; export * from './wt-key.js'; +export * from './path-match.js'; diff --git a/packages/shared/src/path-match.ts b/packages/shared/src/path-match.ts new file mode 100644 index 0000000..746a014 --- /dev/null +++ b/packages/shared/src/path-match.ts @@ -0,0 +1,71 @@ +// Corrélation session ↔ worktree par contenance de chemin. Source unique partagée par le daemon, le +// front web et l'extension VS Code : la règle doit être identique partout, sinon un terminal apparaît +// sous un worktree côté serveur et sous un autre côté UI. +// +// Historiquement la corrélation était une égalité stricte `session.cwd === worktree.path`. Deux cas +// réels y échappent : +// 1. « Démarrer le projet » autorise un sous-répertoire par commande (`LaunchCommand.cwd`, borné par +// `resolveLaunchSubdir`) : le terminal tourne DANS le worktree, mais pas à sa racine ; +// 2. une session de groupe (P6) couvre plusieurs repos via `--add-dir` : son cwd est le parent commun +// et les worktrees couverts n'apparaissent que dans `addedDirs`. +// Dans les deux cas la session appartient bel et bien au worktree. +// +// Aucune dépendance à `node:path` (le front tourne dans un navigateur) : la comparaison se fait par +// segments, en tolérant les deux séparateurs pour rester correcte sur Windows. La comparaison reste +// SENSIBLE à la casse : les chemins comparés viennent tous de la même source (git et la base), et +// insensibiliser casserait deux répertoires ne différant que par la casse sous Linux. + +/** Segments non vides d'un chemin, séparateurs POSIX et Windows confondus. */ +function segments(p: string): string[] { + return p.split(/[\\/]+/).filter((s) => s.length > 0 && s !== '.'); +} + +/** true si `child` est `parent` lui-même ou un descendant. */ +export function containsPath(parent: string, child: string): boolean { + const p = segments(parent); + const c = segments(child); + if (c.length < p.length) return false; + return p.every((seg, i) => c[i] === seg); +} + +/** Répertoires qu'une session occupe : son cwd, plus les répertoires reliés d'une session de groupe. */ +export function sessionDirs(session: { cwd: string; addedDirs?: string[] }): string[] { + return [session.cwd, ...(session.addedDirs ?? [])]; +} + +/** + * Attribue une session au worktree `worktreePath`. `others` = les autres worktrees connus (au moins + * ceux du même repo) : sans eux, une session lancée dans un worktree imbriqué (`repo/.worktrees/x`) + * serait aussi listée sous le checkout principal `repo`. Le worktree le plus spécifique gagne. + */ +export function sessionBelongsToWorktree( + session: { cwd: string; addedDirs?: string[] }, + worktreePath: string, + others: string[] = [], +): boolean { + const depth = segments(worktreePath).length; + return sessionDirs(session).some((dir) => { + if (!containsPath(worktreePath, dir)) return false; + // un autre worktree plus profond contient aussi ce répertoire → il est le propriétaire légitime. + return !others.some((o) => segments(o).length > depth && containsPath(o, dir)); + }); +} + +/** + * Worktree auquel rattacher un répertoire, parmi une liste hétérogène (tous repos confondus) : le plus + * spécifique qui le contient. Sert à épingler le watcher FS du worktree d'une session vivante et à + * étiqueter un terminal. + */ +export function findWorktreeForCwd(cwd: string, worktrees: T[]): T | null { + let best: T | null = null; + let bestDepth = -1; + for (const w of worktrees) { + if (!containsPath(w.path, cwd)) continue; + const depth = segments(w.path).length; + if (depth > bestDepth) { + best = w; + bestDepth = depth; + } + } + return best; +} diff --git a/packages/shared/test/path-match.test.ts b/packages/shared/test/path-match.test.ts new file mode 100644 index 0000000..d685136 --- /dev/null +++ b/packages/shared/test/path-match.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { containsPath, findWorktreeForCwd, sessionBelongsToWorktree } from '../src/path-match.js'; + +describe('containsPath', () => { + it('vrai pour le répertoire lui-même et ses descendants', () => { + expect(containsPath('/p/repo', '/p/repo')).toBe(true); + expect(containsPath('/p/repo', '/p/repo/packages/api')).toBe(true); + }); + + it('compare par segment, pas par préfixe de chaîne', () => { + // piège du startsWith nu : /p/repo n'est pas le parent de /p/repo-wt-feature + expect(containsPath('/p/repo', '/p/repo-wt-feature')).toBe(false); + }); + + it('faux pour un ancêtre', () => { + expect(containsPath('/p/repo/api', '/p/repo')).toBe(false); + }); + + it('tolère les deux séparateurs (chemins Windows)', () => { + expect(containsPath('C:\\dev\\repo', 'C:\\dev\\repo\\packages\\api')).toBe(true); + expect(containsPath('C:\\dev\\repo', 'C:/dev/repo/packages')).toBe(true); + expect(containsPath('C:\\dev\\repo', 'C:\\dev\\repo2')).toBe(false); + }); + + it('reste sensible à la casse', () => { + expect(containsPath('/p/Repo', '/p/repo/api')).toBe(false); + }); + + it('ignore les séparateurs redondants et un slash final', () => { + expect(containsPath('/p/repo/', '/p//repo/api')).toBe(true); + }); +}); + +describe('sessionBelongsToWorktree', () => { + it('rattache une session lancée dans un sous-répertoire', () => { + expect(sessionBelongsToWorktree({ cwd: '/p/repo/packages/api' }, '/p/repo')).toBe(true); + }); + + it('donne un worktree imbriqué au plus spécifique, pas au principal', () => { + const session = { cwd: '/p/repo/.worktrees/feat/src' }; + const all = ['/p/repo', '/p/repo/.worktrees/feat']; + expect(sessionBelongsToWorktree(session, '/p/repo/.worktrees/feat', all)).toBe(true); + expect(sessionBelongsToWorktree(session, '/p/repo', all)).toBe(false); + }); + + it('rattache une session de groupe via ses répertoires reliés', () => { + const session = { cwd: '/p', addedDirs: ['/p/api', '/p/web'] }; + expect(sessionBelongsToWorktree(session, '/p/api')).toBe(true); + expect(sessionBelongsToWorktree(session, '/p/docs')).toBe(false); + }); + + it('ne rattache pas une session d’un worktree frère', () => { + expect(sessionBelongsToWorktree({ cwd: '/p/repo-wt-feat' }, '/p/repo')).toBe(false); + }); +}); + +describe('findWorktreeForCwd', () => { + const worktrees = [ + { repoId: 'r1', path: '/p/api' }, + { repoId: 'r1', path: '/p/api-wt-feat' }, + { repoId: 'r2', path: '/p/api/vendor/web' }, + ]; + + it('choisit le worktree le plus spécifique', () => { + expect(findWorktreeForCwd('/p/api/vendor/web/src', worktrees)?.repoId).toBe('r2'); + }); + + it('choisit le worktree frère exact', () => { + expect(findWorktreeForCwd('/p/api-wt-feat/src', worktrees)?.path).toBe('/p/api-wt-feat'); + }); + + it('renvoie null hors de tout worktree connu', () => { + expect(findWorktreeForCwd('/tmp/ailleurs', worktrees)).toBeNull(); + }); +}); diff --git a/packages/site/index.html b/packages/site/index.html index 266e4d9..ec31627 100644 --- a/packages/site/index.html +++ b/packages/site/index.html @@ -5,12 +5,12 @@ - + + diff --git a/packages/site/src/components/AppFooter.vue b/packages/site/src/components/AppFooter.vue index c46fc89..dbb6355 100644 --- a/packages/site/src/components/AppFooter.vue +++ b/packages/site/src/components/AppFooter.vue @@ -1,7 +1,7 @@ diff --git a/packages/web/src/components/CommandPalette.vue b/packages/web/src/components/CommandPalette.vue index 816781e..2d3fd42 100644 --- a/packages/web/src/components/CommandPalette.vue +++ b/packages/web/src/components/CommandPalette.vue @@ -102,7 +102,7 @@ const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p; const items = computed(() => { const out: PaletteItem[] = []; - for (const r of worktrees.repos) { + for (const r of worktrees.visibleRepos) { out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), run: () => revealRepo(r.id) }); out.push({ id: `launch-${r.id}`, diff --git a/packages/web/src/components/ToastContainer.vue b/packages/web/src/components/ToastContainer.vue index de62100..aa67a24 100644 --- a/packages/web/src/components/ToastContainer.vue +++ b/packages/web/src/components/ToastContainer.vue @@ -1,26 +1,31 @@ diff --git a/packages/web/src/components/ide/PanelSplitter.vue b/packages/web/src/components/ide/PanelSplitter.vue index b6bd4c1..554118a 100644 --- a/packages/web/src/components/ide/PanelSplitter.vue +++ b/packages/web/src/components/ide/PanelSplitter.vue @@ -1,20 +1,34 @@ diff --git a/packages/web/src/components/ide/PrimarySidebar.vue b/packages/web/src/components/ide/PrimarySidebar.vue index 3b088ae..f9d2a15 100644 --- a/packages/web/src/components/ide/PrimarySidebar.vue +++ b/packages/web/src/components/ide/PrimarySidebar.vue @@ -3,6 +3,10 @@ class="flex min-h-0 shrink-0 flex-col border-r border-border bg-surface-1" :style="{ width: `${ide.leftWidth}px` }" > + + @@ -12,6 +16,7 @@ diff --git a/packages/web/src/components/ide/modals/GroupCreateModal.vue b/packages/web/src/components/ide/modals/GroupCreateModal.vue index 31169a8..78dd584 100644 --- a/packages/web/src/components/ide/modals/GroupCreateModal.vue +++ b/packages/web/src/components/ide/modals/GroupCreateModal.vue @@ -19,10 +19,10 @@
{{ t('groups.reposLabel') }} -

{{ t('groups.noReposRegistered') }}

+

{{ t('groups.noReposRegistered') }}