Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c191b1296 | ||
|
|
17e95754b1 | ||
|
|
dc8c7c9534 | ||
|
|
bdec8d6ad0 | ||
|
|
8aea0ae32d | ||
|
|
9390b62249 | ||
|
|
c6deded0c6 | ||
|
|
ce224fd470 | ||
|
|
114fbc8ba0 | ||
|
|
bde5358ea8 | ||
|
|
9624270d9b | ||
|
|
c8bf6534e0 | ||
|
|
c8d30c7b0d | ||
|
|
f96b36c548 | ||
|
|
e4dd64b535 | ||
|
|
63f2697745 | ||
|
|
a7e04278fd |
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Attache des fichiers à une release Gitea, de façon idempotente (re-run friendly).
|
||||||
|
#
|
||||||
|
# Usage : attach-release-assets.sh <tag> <release-name> <fichier...>
|
||||||
|
# Env : RELEASE_TOKEN (token Gitea avec write:repository), GITHUB_SERVER_URL, GITHUB_REPOSITORY.
|
||||||
|
#
|
||||||
|
# Partagé par tous les jobs de release desktop et par le VSIX : la logique était dupliquée, et toute
|
||||||
|
# correction devait être faite trois fois.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
tag="${1:?tag manquant}"
|
||||||
|
release_name="${2:?nom de release manquant}"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
# Token ABSENT : cas légitime (fork, run sans secret) → on sort proprement.
|
||||||
|
# Token PRÉSENT mais refusé par l'API : anomalie, on doit ÉCHOUER. Sinon le job reste vert alors
|
||||||
|
# qu'aucun asset n'est attaché et qu'aucune release n'est créée, ce qui s'est produit avec un token
|
||||||
|
# expiré : trois workflows « réussis » et zéro fichier publié.
|
||||||
|
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}"
|
||||||
|
body=$(mktemp)
|
||||||
|
trap 'rm -f "$body"' EXIT
|
||||||
|
|
||||||
|
# Lecture d'un champ JSON TOLÉRANTE : une réponse vide ou non-JSON (401, 403, 404) doit donner une
|
||||||
|
# chaîne vide, pas une pile d'appels Node. Sans ça, deux `SyntaxError: Unexpected end of JSON input`
|
||||||
|
# s'affichaient avant le vrai message d'erreur et noyaient le diagnostic.
|
||||||
|
json_field() {
|
||||||
|
node -e "let s='';process.stdin.on('data',(d)=>{s+=d}).on('end',()=>{try{const o=JSON.parse(s);const v=o?.[process.argv[1]];process.stdout.write(v==null?'':String(v))}catch{process.stdout.write('')}})" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `curl` silencieux qui écrit le corps dans $body et renvoie le code HTTP sur stdout.
|
||||||
|
http_call() {
|
||||||
|
curl -sS -o "$body" -w '%{http_code}' "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- résolution de la release (existante, sinon création) -------------------------------------
|
||||||
|
code=$(http_call -H "$auth" "${api}/releases/tags/${tag}")
|
||||||
|
release_id=$(json_field id < "$body")
|
||||||
|
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
# 401/403 sur une simple lecture : inutile de tenter la création, le token est en cause.
|
||||||
|
case "$code" in
|
||||||
|
401)
|
||||||
|
echo "::error::le token de release est refusé (HTTP 401) : il est invalide, révoqué ou expiré."
|
||||||
|
echo "::error::régénérer un token Gitea et mettre à jour le secret NPM_TOKEN du dépôt."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
403)
|
||||||
|
echo "::error::le token de release manque de droits (HTTP 403) sur ${GITHUB_REPOSITORY}."
|
||||||
|
echo "::error::portées attendues : write:repository (releases et assets) et write:package (publication npm)."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
create_code=$(http_call -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"tag_name\":\"${tag}\",\"name\":\"${release_name}\"}" "${api}/releases")
|
||||||
|
release_id=$(json_field id < "$body")
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
echo "::error::impossible de créer la release ${tag} (HTTP ${create_code})."
|
||||||
|
echo "::error::réponse de l'API : $(head -c 300 "$body")"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "release ${tag} créée (id ${release_id})."
|
||||||
|
else
|
||||||
|
echo "release ${tag} trouvée (id ${release_id})."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- attache des fichiers ----------------------------------------------------------------------
|
||||||
|
failed=0
|
||||||
|
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.
|
||||||
|
http_call -H "$auth" "${api}/releases/${release_id}/assets" > /dev/null
|
||||||
|
existing=$(node -e "let s='';process.stdin.on('data',(d)=>{s+=d}).on('end',()=>{try{const a=JSON.parse(s);const m=Array.isArray(a)?a.find((x)=>x.name===process.argv[1]):null;process.stdout.write(m?String(m.id):'')}catch{process.stdout.write('')}})" "$name" < "$body")
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
echo "remplacement de $name (asset $existing)"
|
||||||
|
http_call -X DELETE -H "$auth" "${api}/releases/${release_id}/assets/${existing}" > /dev/null
|
||||||
|
fi
|
||||||
|
upload_code=$(http_call -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${release_id}/assets?name=${name}")
|
||||||
|
if [ "$upload_code" -ge 200 ] && [ "$upload_code" -lt 300 ]; then
|
||||||
|
echo "attaché : $name"
|
||||||
|
else
|
||||||
|
echo "::error::échec de l'upload de ${name} (HTTP ${upload_code}) : $(head -c 200 "$body")"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$failed" != "0" ]; then
|
||||||
|
echo "::error::au moins un asset n'a pas pu être attaché à ${tag}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Assets attachés à la release ${tag}."
|
||||||
@@ -29,6 +29,17 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run build:site
|
- run: npm run build:site
|
||||||
- run: npx vitest run
|
- 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. Une seule version de Node suffit, et
|
||||||
|
# ELECTRON_SKIP_BINARY_DOWNLOAD évite de télécharger ~100 Mo de binaire Electron dont un
|
||||||
|
# typecheck n'a aucun besoin (c'est ce qui rendait le job très long).
|
||||||
|
- name: Typecheck desktop shell
|
||||||
|
if: matrix.node == '22'
|
||||||
|
env:
|
||||||
|
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||||
|
run: |
|
||||||
|
npm --prefix packages/desktop ci
|
||||||
|
npm run typecheck:desktop
|
||||||
|
|
||||||
pack-smoke:
|
pack-smoke:
|
||||||
name: Pack & boot smoke (Node 22)
|
name: Pack & boot smoke (Node 22)
|
||||||
@@ -57,9 +68,11 @@ jobs:
|
|||||||
rm -rf /tmp/inspect && mkdir -p /tmp/inspect && tar -xzf "$tgz" -C /tmp/inspect
|
rm -rf /tmp/inspect && mkdir -p /tmp/inspect && tar -xzf "$tgz" -C /tmp/inspect
|
||||||
test -f /tmp/inspect/package/dist/_shared/index.js \
|
test -f /tmp/inspect/package/dist/_shared/index.js \
|
||||||
|| { echo "ERREUR: dist/_shared/index.js absent de $tgz : inline-shared n'a pas tourné ?"; exit 1; }
|
|| { echo "ERREUR: dist/_shared/index.js absent de $tgz : inline-shared n'a pas tourné ?"; exit 1; }
|
||||||
if grep -rq '@arboretum/shared' /tmp/inspect/package/dist; then
|
# On cherche les vraies formes d'IMPORT, pas la simple chaîne : un commentaire de code qui
|
||||||
|
# mentionne le paquet (pour dire où vit la règle partagée) est légitime et ne casse rien.
|
||||||
|
if grep -rqE "(from|require\(|import\()[[:space:]]*['\"]@arboretum/shared" /tmp/inspect/package/dist; then
|
||||||
echo "ERREUR: import bare '@arboretum/shared' encore présent dans le JS publié"
|
echo "ERREUR: import bare '@arboretum/shared' encore présent dans le JS publié"
|
||||||
grep -rn '@arboretum/shared' /tmp/inspect/package/dist; exit 1
|
grep -rnE "(from|require\(|import\()[[:space:]]*['\"]@arboretum/shared" /tmp/inspect/package/dist; exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: paquet autonome : shared inliné dans dist/_shared, aucun import externe"
|
echo "OK: paquet autonome : shared inliné dans dist/_shared, aucun import externe"
|
||||||
- name: Install tarball in an empty project
|
- name: Install tarball in an empty project
|
||||||
@@ -86,15 +99,16 @@ jobs:
|
|||||||
lint-dashes:
|
lint-dashes:
|
||||||
# Interdit tout tiret cadratin (U+2014) ou demi-cadratin (U+2013) dans les fichiers suivis.
|
# 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.
|
# 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
|
name: No em/en dashes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Fail on U+2014 / U+2013 (outside allow-list)
|
- name: Fail on U+2014 / U+2013 (outside allow-list)
|
||||||
run: |
|
run: |
|
||||||
if git grep -nP '[\x{2014}\x{2013}]' -- . \
|
if git grep -nPI '[\x{2014}\x{2013}]' -- . \
|
||||||
':(exclude)brand/arboretum-logo-source.png' \
|
|
||||||
':(exclude)packages/server/test/fixtures/dialogs/*.raw'; then
|
':(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)."
|
echo "::error::Tiret cadratin/demi-cadratin trouve. Utiliser point median, deux-points, virgule ou tiret simple (plages)."
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
# Packaging de l'app de bureau Electron, déclenché UNIQUEMENT par un tag desktop-vX.Y.Z (séparé de
|
# Packaging de l'app de bureau Electron, déclenché par un tag desktop-vX.Y.Z (séparé de la release du
|
||||||
# la release du daemon qui écoute v*, et du VSIX qui écoute vscode-v*). Linux (AppImage + deb) est
|
# daemon qui écoute v*, et du VSIX qui écoute vscode-v*).
|
||||||
# automatisé ici ; Windows et macOS se buildent sur ces OS (voir packages/desktop/README.md) et
|
#
|
||||||
# leurs artefacts sont attachés manuellement à la release.
|
# Linux (AppImage + deb) : toujours automatisé.
|
||||||
|
# Windows (NSIS + portable) : job RETIRÉ tant qu'aucun runner `windows-latest` n'est enregistré sur le
|
||||||
|
# forge. Un job conditionné par une variable de dépôt ne suffisait pas : la release entière tombait
|
||||||
|
# en erreur. Le repli est un build manuel attaché à la release. Pour le rétablir : enregistrer un
|
||||||
|
# runner (docs/CI_RUNNERS.md) puis restaurer le job depuis l'historique git (tag desktop-v0.2.3).
|
||||||
|
# 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
|
name: Desktop Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['desktop-v*']
|
tags: ['desktop-v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -26,6 +37,7 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
# Garde-fou : le tag (sans "desktop-v") doit correspondre à la version du paquet desktop.
|
# Garde-fou : le tag (sans "desktop-v") doit correspondre à la version du paquet desktop.
|
||||||
- name: Verify tag matches desktop version
|
- name: Verify tag matches desktop version
|
||||||
|
if: github.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
pkg=$(node -p "require('./packages/desktop/package.json').version")
|
pkg=$(node -p "require('./packages/desktop/package.json').version")
|
||||||
tag="${GITHUB_REF_NAME#desktop-v}"
|
tag="${GITHUB_REF_NAME#desktop-v}"
|
||||||
@@ -41,6 +53,11 @@ jobs:
|
|||||||
# Build complet : shell + daemon empaqueté + Node standalone + AppImage/deb (electron-builder).
|
# Build complet : shell + daemon empaqueté + Node standalone + AppImage/deb (electron-builder).
|
||||||
- name: Build installers
|
- name: Build installers
|
||||||
run: cd packages/desktop && npm run dist:linux
|
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.
|
# Artefacts du run : canal fiable, indépendant de l'API release.
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -48,40 +65,75 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
packages/desktop/release/*.AppImage
|
packages/desktop/release/*.AppImage
|
||||||
packages/desktop/release/*.deb
|
packages/desktop/release/*.deb
|
||||||
|
packages/desktop/release/*.blockmap
|
||||||
packages/desktop/release/latest-linux.yml
|
packages/desktop/release/latest-linux.yml
|
||||||
# Best-effort : attache les installeurs (+ latest-linux.yml pour l'auto-update) à la release
|
packages/desktop/release/SHA256SUMS-linux.txt
|
||||||
# Gitea du tag (crée la release si absente). Réutilise NPM_TOKEN (même token Gitea) : ce token
|
# Attache les installeurs (+ latest-linux.yml pour l'auto-update) à la release du tag. Réutilise
|
||||||
# doit porter la portée write:repository en plus de write:package, sinon l'API release renvoie
|
# NPM_TOKEN (même token Gitea) : il doit porter write:repository en plus de write:package, sinon
|
||||||
# un 403 (l'attache est ignorée, les installeurs restent disponibles en artefact du run).
|
# l'API release renvoie 403. Pas de continue-on-error : les artefacts du run sont déjà uploadés à
|
||||||
- name: Attach installers to Gitea release
|
# l'étape précédente, donc un échec ici ne perd rien et doit être VU (avec un token expiré, la
|
||||||
continue-on-error: true
|
# release ressortait verte et vide).
|
||||||
|
- name: Attach installers to the tag release
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
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
|
||||||
|
|
||||||
|
# Le job Windows (NSIS + portable) est RETIRÉ pour le moment : aucun runner Windows n'est
|
||||||
|
# enregistré sur ce Gitea, et `runs-on: windows-latest` fait échouer la release entière au lieu
|
||||||
|
# d'être simplement ignoré. Le job complet reste dans l'historique git (dernier état : tag
|
||||||
|
# desktop-v0.2.3) et la procédure d'enregistrement d'un runner est dans docs/CI_RUNNERS.md : le
|
||||||
|
# rétablir revient à restaurer ce job, puis à réactiver le téléchargement de son artefact et ses
|
||||||
|
# assets dans le canal flottant ci-dessous.
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# 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:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$RELEASE_TOKEN" ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
auth="Authorization: token ${RELEASE_TOKEN}"
|
auth="Authorization: token ${RELEASE_TOKEN}"
|
||||||
|
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
|
||||||
|
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")
|
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)
|
bash .gitea/scripts/attach-release-assets.sh desktop-latest "Arboretum Desktop (latest, ${version})" \
|
||||||
if [ -z "$rid" ]; then
|
dl/*.AppImage dl/*.deb dl/*.blockmap dl/latest-linux.yml dl/SHA256SUMS-*.txt
|
||||||
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 || ''")
|
|
||||||
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}."
|
|
||||||
|
|||||||
@@ -45,31 +45,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: vsix
|
name: vsix
|
||||||
path: packages/vscode/*.vsix
|
path: packages/vscode/*.vsix
|
||||||
# Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente).
|
# Attache le VSIX à la release Gitea du tag (créée si absente), via le script partagé avec la
|
||||||
# Réutilise le secret NPM_TOKEN (même token Gitea que la publication du daemon) : ce token doit
|
# release desktop : la logique était dupliquée, avec le même angle mort. Réutilise le secret
|
||||||
# porter write:repository en plus de write:package, sinon l'API release renvoie un 403. Sans
|
# NPM_TOKEN (même token Gitea que la publication du daemon), qui doit porter write:repository en
|
||||||
# token, l'étape est ignorée sans faire échouer le job (continue-on-error) ; le VSIX reste
|
# plus de write:package. Sans token du tout, le script sort proprement ; avec un token REFUSÉ, il
|
||||||
# disponible en artefact.
|
# échoue, pour que l'anomalie soit visible (le VSIX reste dans les artefacts du run).
|
||||||
- name: Attach VSIX to Gitea release
|
- name: Attach VSIX to Gitea release
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$RELEASE_TOKEN" ]; then
|
|
||||||
echo "::notice::NPM_TOKEN absent : VSIX disponible en artefact uniquement."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
|
||||||
auth="Authorization: token ${RELEASE_TOKEN}"
|
|
||||||
version=$(node -p "require('./packages/vscode/package.json').version")
|
version=$(node -p "require('./packages/vscode/package.json').version")
|
||||||
vsix="packages/vscode/git-arboretum-${version}.vsix"
|
bash .gitea/scripts/attach-release-assets.sh "${GITHUB_REF_NAME}" "Arboretum VSCode ${version}" \
|
||||||
# id de release du tag, sinon création
|
"packages/vscode/git-arboretum-${version}.vsix"
|
||||||
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 VSCode ${version}\"}" \
|
|
||||||
"${api}/releases" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''")
|
|
||||||
fi
|
|
||||||
curl -fsSL -X POST -H "$auth" -F "attachment=@${vsix}" \
|
|
||||||
"${api}/releases/${rid}/assets?name=git-arboretum-${version}.vsix"
|
|
||||||
echo "VSIX attaché à la release ${GITHUB_REF_NAME}."
|
|
||||||
|
|||||||
@@ -34,3 +34,6 @@ CLAUDE.md
|
|||||||
# Spike scratch output
|
# Spike scratch output
|
||||||
spikes/**/tmp/
|
spikes/**/tmp/
|
||||||
spikes/**/captures/
|
spikes/**/captures/
|
||||||
|
|
||||||
|
# captures des scripts de verification visuelle (verify-ui.mjs)
|
||||||
|
.ui-shots/
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (en app de bur
|
|||||||
- **Découverte & reprise de sessions** : les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante. Masquez les anciennes qui encombrent la liste (un clic efface tout l'historique externe ; elles restent reprenables).
|
- **Découverte & reprise de sessions** : les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante. Masquez les anciennes qui encombrent la liste (un clic efface tout l'historique externe ; elles restent reprenables).
|
||||||
- **Terminal web** : terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
- **Terminal web** : terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
||||||
- **IDE multi-projet** : un espace de travail pour tous les projets ouverts à la fois (pas de fenêtre par projet). Un arbre unique (projet, worktrees, sessions Claude), un éditeur Monaco à onglets (plusieurs fichiers de projets différents côte à côte, avec diffs inline par fichier), un dock bas de terminaux de session, et des panneaux Git / Sessions / Groupes. Éditez les fichiers, indexez les changements sélectivement, committez (ou amendez), fetch/pull et push, au même endroit. Un watcher de système de fichiers en temps réel garde la vue à jour au fil des éditions de l'agent. Disponible en app de bureau native et dans le navigateur.
|
- **IDE multi-projet** : un espace de travail pour tous les projets ouverts à la fois (pas de fenêtre par projet). Un arbre unique (projet, worktrees, sessions Claude), un éditeur Monaco à onglets (plusieurs fichiers de projets différents côte à côte, avec diffs inline par fichier), un dock bas de terminaux de session, et des panneaux Git / Sessions / Groupes. Éditez les fichiers, indexez les changements sélectivement, committez (ou amendez), fetch/pull et push, au même endroit. Un watcher de système de fichiers en temps réel garde la vue à jour au fil des éditions de l'agent. Disponible en app de bureau native et dans le navigateur.
|
||||||
|
- **Démarrez un projet en un clic** : beaucoup de projets exigent plusieurs commandes longue durée pour démarrer (serveur de dev, API, base de données). Définissez-les une fois par projet (libellés, commandes shell, sous-dossier optionnel), auto-détectées depuis les scripts `package.json`, un `Procfile` ou `docker-compose`, puis lancez-les toutes d'un coup, un terminal attaché par commande. Chacune tourne dans votre shell de login (donc `npm`, `docker`, nvm/asdf sont dans le `PATH`) et reste vivante après la fin de la commande, pour que les échecs restent à l'écran ; arrêtez tout le lot en une action.
|
||||||
- **Supervision depuis votre téléphone** : PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
- **Supervision depuis votre téléphone** : PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
||||||
- **Groupes de travail** : regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
- **Groupes de travail** : regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
||||||
- **Services git distants** : connectez vos comptes GitHub, GitLab ou Gitea (personal access token ou app password), stockés **chiffrés au repos** (AES-256-GCM) ; parcourez vos dépôts distants et clonez-les en HTTPS avec progression en direct, directement depuis le dashboard.
|
- **Services git distants** : connectez vos comptes GitHub, GitLab ou Gitea (personal access token ou app password), stockés **chiffrés au repos** (AES-256-GCM) ; parcourez vos dépôts distants et clonez-les en HTTPS avec progression en direct, directement depuis le dashboard.
|
||||||
@@ -128,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.
|
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-<version>-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-<version>-x86_64.AppImage` | `chmod +x` puis lancer. Aucune entrée de menu sans un outil d'intégration comme `appimaged`. |
|
||||||
|
| **Windows** | `Arboretum-<version>-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-<version>.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
|
## 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.
|
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.
|
||||||
@@ -163,10 +177,10 @@ Elle est distribuée en **VSIX privé**. Buildez-la et packagez-la depuis le mon
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:vscode
|
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-<version>.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-<version>.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
|
## Accès distant depuis votre téléphone
|
||||||
|
|
||||||
@@ -187,6 +201,47 @@ Ouvrez `https://<machine>.<tailnet>.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.
|
> ⚠️ 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 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 :
|
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 :
|
||||||
@@ -196,7 +251,7 @@ npm i -g @johanleroy/git-arboretum
|
|||||||
arboretum install --allow-origin https://MACHINE.TAILNET.ts.net
|
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
|
```bash
|
||||||
arboretum status # état du service (+ où lire les logs)
|
arboretum status # état du service (+ où lire les logs)
|
||||||
@@ -252,7 +307,9 @@ Les options du daemon sont des flags CLI :
|
|||||||
| `--allow-origin <url>` | aucun | Origine `Origin` autorisée supplémentaire (répétable). Nécessaire pour l'accès Tailscale/HTTPS. |
|
| `--allow-origin <url>` | aucun | Origine `Origin` autorisée supplémentaire (répétable). Nécessaire pour l'accès Tailscale/HTTPS. |
|
||||||
| `--db <path>` | `<data>/arboretum.db` | Chemin de la base SQLite. |
|
| `--db <path>` | `<data>/arboretum.db` | Chemin de la base SQLite. |
|
||||||
| `--vapid-contact <mailto/url>` | `mailto:arboretum@localhost` | Sujet de contact VAPID pour le Web Push. |
|
| `--vapid-contact <mailto/url>` | `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 <chemin>` | `~/.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. |
|
| `--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 :
|
`arboretum install` accepte tous les flags du daemon ci-dessus (propagés tels quels au service), plus :
|
||||||
@@ -264,7 +321,19 @@ Les options du daemon sont des flags CLI :
|
|||||||
| `--dry-run` | Affiche le unit/plist et les commandes sans rien appliquer. |
|
| `--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. |
|
| `--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`. |
|
||||||
|
| `ARBORETUM_DESKTOP_PORT` | app de bureau | Port sur lequel l'app de bureau lance son propre daemon. Défaut `7317`. À définir quand un autre Arboretum occupe ce port en permanence (service installé par `arboretum install`, ou daemon lancé en terminal). |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -290,6 +359,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é.
|
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 <cette origine exacte>` (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
|
## Ce qui le distingue
|
||||||
|
|
||||||
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
@@ -333,8 +415,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-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-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-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.
|
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
|
## Soutenir le projet
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ A single Node.js daemon you run on your dev machine (as a native desktop app, or
|
|||||||
- **Session discovery & resume**: sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session. Hide the old ones that clutter the list (one click clears the whole external history; they stay resumable).
|
- **Session discovery & resume**: sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session. Hide the old ones that clutter the list (one click clears the whole external history; they stay resumable).
|
||||||
- **Web terminal**: full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
- **Web terminal**: full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
||||||
- **Multi-project IDE**: one workspace for every open project at once (no per-project window). A single tree (project, worktrees, Claude sessions), a tabbed Monaco editor (several files from different projects side by side, with inline per-file diffs), a bottom dock of session terminals, and Git / Sessions / Groups panels. Edit files, stage changes selectively, commit (or amend), fetch/pull and push, all in one place. A real-time file-system watcher keeps the view live as the agent edits. Available as a native desktop app and in the browser.
|
- **Multi-project IDE**: one workspace for every open project at once (no per-project window). A single tree (project, worktrees, Claude sessions), a tabbed Monaco editor (several files from different projects side by side, with inline per-file diffs), a bottom dock of session terminals, and Git / Sessions / Groups panels. Edit files, stage changes selectively, commit (or amend), fetch/pull and push, all in one place. A real-time file-system watcher keeps the view live as the agent edits. Available as a native desktop app and in the browser.
|
||||||
|
- **Start a project in one click**: many projects need several long-running commands to boot (dev server, API, database). Define them once per project (labels, shell commands, optional subdir), auto-detected from `package.json` scripts, a `Procfile` or `docker-compose`, then launch them all at once, one attached terminal per command. Each runs in your login shell (so `npm`, `docker`, nvm/asdf are on `PATH`) and stays live after the command exits, so failures stay on screen; stop the whole set in one action.
|
||||||
- **Supervision from your phone**: installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
- **Supervision from your phone**: installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
||||||
- **Work groups**: bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
- **Work groups**: bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
||||||
- **Remote git services**: connect your GitHub, GitLab or Gitea accounts (personal access token or app password), stored **encrypted at rest** (AES-256-GCM); browse your remote repositories and clone them over HTTPS with live progress, straight from the dashboard.
|
- **Remote git services**: connect your GitHub, GitLab or Gitea accounts (personal access token or app password), stored **encrypted at rest** (AES-256-GCM); browse your remote repositories and clone them over HTTPS with live progress, straight from the dashboard.
|
||||||
@@ -128,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.
|
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-<version>-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-<version>-x86_64.AppImage` | `chmod +x` then run. No desktop entry unless you use a tool like `appimaged`. |
|
||||||
|
| **Windows** | `Arboretum-<version>-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-<version>.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
|
## 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.
|
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.
|
||||||
@@ -163,10 +177,10 @@ It is distributed as a **private VSIX**. Build and package it from the monorepo:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:vscode
|
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-<version>.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-<version>.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
|
## Remote access from your phone
|
||||||
|
|
||||||
@@ -187,6 +201,47 @@ Open `https://<machine>.<tailnet>.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.
|
> ⚠️ 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
|
## 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:
|
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:
|
||||||
@@ -196,7 +251,7 @@ npm i -g @johanleroy/git-arboretum
|
|||||||
arboretum install --allow-origin https://MACHINE.TAILNET.ts.net
|
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
|
```bash
|
||||||
arboretum status # service status (+ where to read logs)
|
arboretum status # service status (+ where to read logs)
|
||||||
@@ -252,7 +307,9 @@ Daemon options are CLI flags:
|
|||||||
| `--allow-origin <url>` | none | Additional allowed `Origin` (repeatable). Needed for Tailscale/HTTPS access. |
|
| `--allow-origin <url>` | none | Additional allowed `Origin` (repeatable). Needed for Tailscale/HTTPS access. |
|
||||||
| `--db <path>` | `<data>/arboretum.db` | SQLite database path. |
|
| `--db <path>` | `<data>/arboretum.db` | SQLite database path. |
|
||||||
| `--vapid-contact <mailto/url>` | `mailto:arboretum@localhost` | VAPID contact subject for Web Push. |
|
| `--vapid-contact <mailto/url>` | `mailto:arboretum@localhost` | VAPID contact subject for Web Push. |
|
||||||
| `--print-token` | `false` | Hint about token re-printing (tokens are hashed and cannot be re-shown). |
|
| `--print-token` | `false` | Print the access token on start (bootstraps one if the database has none). |
|
||||||
|
| `--claude-home <path>` | `~/.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. |
|
| `--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:
|
`arboretum install` accepts every daemon flag above (propagated verbatim to the service) plus:
|
||||||
@@ -264,7 +321,19 @@ Daemon options are CLI flags:
|
|||||||
| `--dry-run` | Print the unit/plist and commands without applying anything. |
|
| `--dry-run` | Print the unit/plist and commands without applying anything. |
|
||||||
| `--no-enable` | Write the service file but do not enable/start it. |
|
| `--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`. |
|
||||||
|
| `ARBORETUM_DESKTOP_PORT` | desktop app | Port the desktop app starts its own daemon on. Default `7317`. Set it when another Arboretum permanently holds that port (a service installed with `arboretum install`, or one you run in a terminal). |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -290,6 +359,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.
|
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 <that exact 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
|
## What makes it different
|
||||||
|
|
||||||
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |
|
||||||
@@ -333,8 +415,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-p10.mjs # automatic session archival
|
||||||
node packages/server/scripts/acceptance-p11.mjs # real-time settings sync
|
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-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.
|
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
|
## Support
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ Sorties :
|
|||||||
packages/web/public/icon-512.png icône PWA maskable (arbre, fond #09090b)
|
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/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/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 <source.png>
|
Usage : python3 brand/build-assets.py <source.png>
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -94,6 +102,40 @@ def main():
|
|||||||
fav = square(tree, 0.04)
|
fav = square(tree, 0.04)
|
||||||
fav.save("packages/web/public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
|
fav.save("packages/web/public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
|
||||||
print(" packages/web/public/favicon.ico 16/32/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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# 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 **retiré** du workflow ; manuel (`npm run dist:win` sur Windows) |
|
||||||
|
| macOS | aucun | manuel (`npm run dist:mac` sur un Mac) |
|
||||||
|
|
||||||
|
Le job Windows a d'abord été gardé dans le workflow, conditionné par
|
||||||
|
`if: vars.ENABLE_WINDOWS_BUILD == 'true'`. Cela **n'a pas suffi** : sans runner labellisé
|
||||||
|
`windows-latest`, la release entière tombait en erreur au lieu de sauter le job. Il est donc
|
||||||
|
**retiré** de `.gitea/workflows/desktop-release.yml`. Son dernier état complet est dans l'historique
|
||||||
|
git (tag `desktop-v0.2.3`) : après avoir enregistré le runner ci-dessous, restaurer ce job, puis
|
||||||
|
remettre dans `latest-channel` le `download-artifact` de `desktop-windows` ainsi que
|
||||||
|
`dl/*.exe dl/latest.yml` dans la liste d'assets du canal flottant.
|
||||||
|
|
||||||
|
## 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 <JETON_DENREGISTREMENT> `
|
||||||
|
--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.
|
||||||
@@ -7933,7 +7933,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.2.0",
|
"version": "3.7.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
@@ -7967,7 +7967,7 @@
|
|||||||
},
|
},
|
||||||
"packages/site": {
|
"packages/site": {
|
||||||
"name": "@arboretum/site",
|
"name": "@arboretum/site",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
"vue-i18n": "^11.4.5"
|
"vue-i18n": "^11.4.5"
|
||||||
@@ -8063,7 +8063,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "git-arboretum",
|
"name": "git-arboretum",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
"@arboretum/shared": "0.1.0",
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
"dev:site": "npm run dev -w @arboretum/site",
|
"dev:site": "npm run dev -w @arboretum/site",
|
||||||
"preview:site": "npm run preview -w @arboretum/site",
|
"preview:site": "npm run preview -w @arboretum/site",
|
||||||
"build:vscode": "npm run build -w @arboretum/shared -w git-arboretum",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 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.5
|
||||||
|
|
||||||
|
Ships the daemon 3.7.1, and finishes the job started in 0.2.4: an update installed while the app runs
|
||||||
|
now applies **itself**.
|
||||||
|
|
||||||
|
- **The restart after an update no longer needs you.** 0.2.4 detected that the binary had been
|
||||||
|
replaced and offered a *Restart now* dialog. That still made the user do the work. The app now
|
||||||
|
restarts on its own when it costs nothing, which is the common case, and only asks when there is
|
||||||
|
something to lose: the dialog appears when live sessions would be interrupted (it says how many),
|
||||||
|
or when the daemon cannot be reached to find out. A previous *Later* is final for that version, so
|
||||||
|
nothing ever restarts behind your back.
|
||||||
|
- **The update is now noticed while the window is open.** Detection used to run only when the window
|
||||||
|
was re-shown (tray, Dock, second launch), so an update installed during a working session could go
|
||||||
|
unnoticed indefinitely. A cheap `stat` every 30 s covers it, by polling rather than `fs.watch`,
|
||||||
|
because a package replacing the binary or a whole directory often produces no watch event at all.
|
||||||
|
|
||||||
|
## 0.2.4
|
||||||
|
|
||||||
|
Ships the daemon 3.7.0: terminals no longer go black, they can sit side by side in resizable columns,
|
||||||
|
and the Changes view follows the terminal you are working in. This release also carries the start-up
|
||||||
|
fixes below, which is what makes an update installed over a running app recover on its own.
|
||||||
|
|
||||||
|
- **The app could refuse to start after an update, silently.** Installing a new version replaces the
|
||||||
|
files on disk but leaves the running app alone: its daemon kept port 7317, so the version you just
|
||||||
|
installed hit `EADDRINUSE`, its daemon died before the handshake, and the shell logged the failure to a
|
||||||
|
console nobody sees and quit. Clicking the launcher appeared to do nothing at all. Three fixes:
|
||||||
|
- **Every start-up failure now opens a dialog** with *Retry / Show log / Quit* instead of vanishing,
|
||||||
|
and the daemon's output is kept in `<userData>/logs/daemon.log`. A daemon that dies *after* start-up
|
||||||
|
is reported too, with an offer to restart it, instead of leaving a dead window on screen.
|
||||||
|
- **A busy port is diagnosed, not just fatal** (`src/main/port-guard.ts`). The daemon we spawn is
|
||||||
|
recorded in `<userData>/daemon/daemon.json`, so an *orphaned* daemon (its Electron gone after a
|
||||||
|
crash, a `kill -9` or an upgrade) is reclaimed - SIGTERM then SIGKILL, waiting for the port to be
|
||||||
|
effectively free - while a live sibling instance or third-party server is reported with the action
|
||||||
|
that unblocks it, and never killed.
|
||||||
|
- **An update installed while the app runs is announced** (`src/main/upgrade-watch.ts`). Until now the
|
||||||
|
single-instance lock quietly routed you back to the old version's window; the shell now notices its
|
||||||
|
own binary changed on disk and offers *Restart now*, which stops the daemon before relaunching.
|
||||||
|
- **`ARBORETUM_DESKTOP_PORT`** picks another port, for machines where a service or terminal daemon owns
|
||||||
|
7317 permanently.
|
||||||
|
|
||||||
|
## 0.2.3
|
||||||
|
|
||||||
|
Ships the daemon 3.6.0. Files open again (the editor area could stay blank), and uncommitted work
|
||||||
|
gets a real surface in the centre of the IDE: one block per project, with staging, commit and push.
|
||||||
|
The Electron shell itself is unchanged.
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
|
||||||
|
- **Clipboard bridge.** The renderer cannot use `navigator.clipboard` (Electron rejects it with
|
||||||
|
`NotAllowedError`), so copying a terminal selection did nothing in the app even after 0.2.1. The
|
||||||
|
preload now exposes `arboretumDesktop.clipboard`, relaying to Electron's `clipboard` module over IPC
|
||||||
|
(read and write, writes capped at 1M chars). Ships the daemon 3.5.1, whose SPA uses that bridge first.
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
Ships the daemon 3.5.0, which fixes the black window seen after updating the app.
|
||||||
|
|
||||||
|
- **Black window after an update, fixed.** The window loaded an `index.html` kept from the previous
|
||||||
|
version (revalidated as `304` because the tarball mtime is constant, so the etag did not change) whose
|
||||||
|
`/assets/<hash>` files no longer existed. Nothing painted. If you hit it before updating, the app
|
||||||
|
repairs itself now; clearing `~/.config/Arboretum/Partitions/arboretum/Cache` was the manual fix.
|
||||||
|
- **Copy & paste in session terminals.** `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS);
|
||||||
|
the Edit menu's Copy also works on a terminal selection now. `Ctrl+C` still interrupts.
|
||||||
|
- Browse the files of a group's worktrees straight from the Groups panel.
|
||||||
|
|
||||||
|
The Electron shell itself is unchanged.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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).
|
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),
|
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`.
|
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
|
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.
|
`node:sqlite` works without a flag and the `node-pty` prebuild keeps the `node.` ABI prefix.
|
||||||
@@ -56,24 +57,99 @@ Fully supported. `dist:linux` runs on a Linux host or the Gitea CI runner.
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
Build on a Windows host (recommended): the `node-pty` win32 native binary and the installer
|
**Must be built on a Windows host.** Cross-building from Linux (including via Wine) does not work, and
|
||||||
(`makensis`) are most reliable there. Cross-building from Linux via Wine is a best-effort fallback.
|
the option has been removed from this document to stop people losing time on it:
|
||||||
The app uses ConPTY (Windows 10 1809+). The installer is not code-signed yet, so SmartScreen shows
|
|
||||||
|
- `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".
|
"unknown publisher": choose "More info" then "Run anyway".
|
||||||
|
|
||||||
### macOS (best-effort)
|
### macOS (best-effort)
|
||||||
|
|
||||||
Build on a Mac (`dmg`/`zip` cannot be produced elsewhere). The app is **not** signed or notarized,
|
Build on a Mac (`dmg`/`zip` cannot be produced elsewhere); there is no macOS runner, so it is a manual
|
||||||
so Gatekeeper blocks the first launch: right-click the app then "Open", or run
|
step. The app is **not** signed or notarized, so Gatekeeper blocks the first launch: right-click the app
|
||||||
`xattr -dr com.apple.quarantine /Applications/Arboretum.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.
|
||||||
|
|
||||||
|
## Startup, and what happens when it fails
|
||||||
|
|
||||||
|
The shell owns the daemon: it spawns it on **port 7317** (`ARBORETUM_DESKTOP_PORT` overrides), waits for
|
||||||
|
the handshake on fd 3, seeds the session cookie, then loads the SPA. Since a fixed port is easy to hold
|
||||||
|
hostage, the port is checked *before* spawning (`src/main/port-guard.ts`) and the outcome decides:
|
||||||
|
|
||||||
|
| Who holds the port | What the app does |
|
||||||
|
| --- | --- |
|
||||||
|
| Nobody | Starts normally. |
|
||||||
|
| **Our own daemon, orphaned** (its Electron died: crash, `kill -9`, package upgrade) | Reclaims it: SIGTERM, then SIGKILL, waiting for the port to be *effectively* free, then starts. |
|
||||||
|
| **Another live instance** of the app | Says so, and points at the tray where that window is hiding. Never kills it. |
|
||||||
|
| A third party (`arboretum install` service, `npx @johanleroy/git-arboretum`, unrelated software) | Says so, and suggests stopping it or setting `ARBORETUM_DESKTOP_PORT`. |
|
||||||
|
|
||||||
|
Ownership is recorded in `<userData>/daemon/daemon.json` (`{pid, ownerPid, port}`): a live daemon whose
|
||||||
|
`ownerPid` is gone is an orphan, one whose owner is alive is another instance. Every failure now opens a
|
||||||
|
dialog with **Retry / Show log / Quit** instead of quitting silently, and the daemon's output is kept in
|
||||||
|
`<userData>/logs/daemon.log`. If the daemon dies *after* startup, the app offers to restart it rather
|
||||||
|
than leaving a dead window on screen.
|
||||||
|
|
||||||
|
`<userData>` is `~/.config/Arboretum` (Linux), `~/Library/Application Support/Arboretum` (macOS),
|
||||||
|
`%APPDATA%\Arboretum` (Windows).
|
||||||
|
|
||||||
|
## Installing a new version
|
||||||
|
|
||||||
|
Installers replace the files on disk; they never touch the running process. So after a `dpkg -i` (or an
|
||||||
|
NSIS run) **the open window keeps serving the old version**, and its daemon keeps port 7317 - which used
|
||||||
|
to make the freshly installed version unable to start at all.
|
||||||
|
|
||||||
|
The recommended order is therefore either one of:
|
||||||
|
|
||||||
|
1. Quit Arboretum from the tray, then install, then launch. Clean, nothing to think about.
|
||||||
|
2. Install while it runs, then click the launcher or the tray icon: the shell notices that its own
|
||||||
|
binary changed on disk (`src/main/upgrade-watch.ts`) and offers **Restart now**, which stops the
|
||||||
|
daemon before relaunching, so the new version finds its port free.
|
||||||
|
|
||||||
|
Answering *Later* keeps the old window; the prompt comes back only if yet another version is installed.
|
||||||
|
The check is inert in dev (`app.isPackaged` is false).
|
||||||
|
|
||||||
## Auto-update
|
## Auto-update
|
||||||
|
|
||||||
electron-builder emits `latest*.yml` next to the artifacts; `electron-updater` (wired in a later
|
electron-builder emits `latest*.yml` next to the artifacts and `electron-updater` reads them from a
|
||||||
change) points at the Gitea release assets. Auto-update works for Windows (NSIS) and Linux
|
**floating `desktop-latest` release** on Gitea, which the release workflow recreates on every version
|
||||||
(AppImage); macOS updates are manual while the app is unsigned.
|
(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
|
`scripts/fetch-node.mjs` downloads a pinned Node (SHA256 verified) and **prunes it** to the binary and
|
||||||
platform icon from it.
|
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.
|
||||||
|
|||||||
@@ -21,27 +21,59 @@ extraResources:
|
|||||||
to: node
|
to: node
|
||||||
- from: resources/icon.png
|
- from: resources/icon.png
|
||||||
to: 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
|
# Icônes : générées par `python3 brand/build-assets.py` depuis le logo source.
|
||||||
# (buildResources), pas besoin de .ico/.icns séparés.
|
# - `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:
|
linux:
|
||||||
target: [AppImage, deb]
|
target: [AppImage, deb]
|
||||||
category: Development
|
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}
|
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
|
# 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
|
# 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
|
# 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.
|
# (productName) mais explicite et robuste à un futur changement de productName.
|
||||||
desktop:
|
desktop:
|
||||||
StartupWMClass: Arboretum
|
StartupWMClass: Arboretum
|
||||||
# Note : le .deb installe l'icône et rafraîchit le cache (postinst electron-builder). L'AppImage,
|
GenericName: AI IDE for git worktrees
|
||||||
# lui, n'installe aucun .desktop sans intégration (appimaged) : sur Debian, préférer le .deb.
|
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:
|
deb:
|
||||||
# git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente).
|
# git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente).
|
||||||
depends: [git]
|
depends: [git]
|
||||||
# Mainteneur .deb explicite (electron-builder l'exige ; sinon derive de author.email du package.json).
|
# Mainteneur .deb explicite (electron-builder l'exige ; sinon derive de author.email du package.json).
|
||||||
maintainer: Johan LEROY <contact@johanleroy.fr>
|
maintainer: Johan LEROY <contact@johanleroy.fr>
|
||||||
|
# `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:
|
win:
|
||||||
target:
|
target:
|
||||||
@@ -49,22 +81,38 @@ win:
|
|||||||
arch: [x64]
|
arch: [x64]
|
||||||
- target: portable
|
- target: portable
|
||||||
arch: [x64]
|
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}
|
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||||
|
|
||||||
|
# Le build portable produit lui aussi un .exe : sans nom distinct, il entrerait en collision avec
|
||||||
|
# l'installeur NSIS (les deux cibles héritent de `win.artifactName`) et l'un écraserait l'autre.
|
||||||
|
portable:
|
||||||
|
artifactName: ${productName}-${version}-${arch}-portable.${ext}
|
||||||
|
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
perMachine: false
|
perMachine: false
|
||||||
allowToChangeInstallationDirectory: true
|
allowToChangeInstallationDirectory: true
|
||||||
|
shortcutName: Arboretum
|
||||||
|
uninstallDisplayName: Arboretum ${version}
|
||||||
|
createDesktopShortcut: true
|
||||||
|
license: ../../LICENSE
|
||||||
|
|
||||||
mac:
|
mac:
|
||||||
target: [dmg, zip]
|
target: [dmg, zip]
|
||||||
|
icon: resources/icon.png
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
# macOS best-effort : non signe (documente : clic droit -> Ouvrir, ou xattr -dr com.apple.quarantine)
|
# macOS best-effort : non signe (documente : clic droit -> Ouvrir, ou xattr -dr com.apple.quarantine)
|
||||||
identity: null
|
identity: null
|
||||||
hardenedRuntime: false
|
hardenedRuntime: false
|
||||||
|
|
||||||
# Auto-update (electron-updater, cable en C5) : provider generic pointant sur les assets de release
|
# Auto-update (electron-updater) : provider generic pointant sur un tag FLOTTANT `desktop-latest`,
|
||||||
# Gitea. Genere latest*.yml a cote des artefacts.
|
# 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:
|
publish:
|
||||||
provider: generic
|
provider: generic
|
||||||
url: https://git.lidge.fr/johanleroy/arboretum/releases/download/desktop-latest
|
url: https://git.lidge.fr/johanleroy/arboretum/releases/download/desktop-latest
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
||||||
"homepage": "https://git-arboretum.com",
|
"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",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Johan LEROY",
|
"name": "Johan LEROY",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -2,7 +2,7 @@
|
|||||||
// SHA256. Le daemon tourne SUR ce Node (pas celui d'Electron) pour garantir node:sqlite sans flag
|
// 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).
|
// et l'ABI node-pty attendue (prefixe `node.`). Options : --platform / --arch (défaut : hôte).
|
||||||
import { execFileSync } from 'node:child_process';
|
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 { createHash } from 'node:crypto';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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}`);
|
const archive = join(BUILD, `${name}.${ext}`);
|
||||||
writeFileSync(archive, tarball);
|
writeFileSync(archive, tarball);
|
||||||
if (ext === 'zip') execFileSync('unzip', ['-q', archive, '-d', BUILD], { stdio: 'inherit' });
|
// `tar` de Windows 10+ (bsdtar) lit aussi les .zip : une seule commande pour les trois plateformes,
|
||||||
else execFileSync('tar', ['-xJf', archive, '-C', BUILD], { stdio: 'inherit' });
|
// là où `unzip` n'existe pas sur un Windows standard.
|
||||||
// aplatir node-vX-os-arch/ -> build/node/
|
execFileSync('tar', [ext === 'zip' ? '-xf' : '-xJf', archive, '-C', BUILD], { stdio: 'inherit' });
|
||||||
execFileSync('bash', ['-c', `cp -R "${join(BUILD, name)}/." "${NODE_DIR}/" && rm -rf "${join(BUILD, name)}" "${archive}"`], { 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ const arg = (name) => args.find((a) => a.startsWith(`--${name}=`))?.split('=')[1
|
|||||||
const platform = arg('platform');
|
const platform = arg('platform');
|
||||||
const arch = arg('arch');
|
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) =>
|
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 });
|
rmSync(SERVER_DIR, { recursive: true, force: true });
|
||||||
mkdirSync(SERVER_DIR, { recursive: true });
|
mkdirSync(SERVER_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { clipboard, ipcMain } from 'electron';
|
||||||
|
import { CLIPBOARD_READ, CLIPBOARD_WRITE } from '../shared/ipc';
|
||||||
|
|
||||||
|
// Pont presse-papier pour le renderer.
|
||||||
|
//
|
||||||
|
// Pourquoi il est nécessaire : dans Electron, `navigator.clipboard.writeText` ET `readText`
|
||||||
|
// échouent en `NotAllowedError` (vérifié dans l'app packagée). La SPA ne pouvait donc PAS copier
|
||||||
|
// la sélection d'un terminal, alors que la même page y arrive dans un navigateur. Le module
|
||||||
|
// `clipboard` n'étant pas exposé aux preloads sandboxés, on passe par IPC.
|
||||||
|
//
|
||||||
|
// Portée : l'app charge exclusivement sa propre SPA locale servie par son daemon, et un terminal
|
||||||
|
// web est déjà de l'exécution de code par conception : le presse-papier n'élargit pas la surface.
|
||||||
|
// On borne quand même la taille écrite pour qu'une boucle accidentelle ne remplisse pas la mémoire.
|
||||||
|
const MAX_WRITE_CHARS = 1_000_000;
|
||||||
|
|
||||||
|
export function registerClipboardBridge(): void {
|
||||||
|
ipcMain.handle(CLIPBOARD_READ, () => clipboard.readText());
|
||||||
|
ipcMain.handle(CLIPBOARD_WRITE, (_event, text: unknown) => {
|
||||||
|
if (typeof text !== 'string' || text.length === 0) return false;
|
||||||
|
clipboard.writeText(text.slice(0, MAX_WRITE_CHARS));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,31 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
import { join } from 'node:path';
|
import { createWriteStream, mkdirSync, statSync, truncateSync, type WriteStream } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
import { resolveNodeBin, resolveServerEntry } from './paths';
|
import { resolveNodeBin, resolveServerEntry } from './paths';
|
||||||
import { buildChildEnv } from './env';
|
import { buildChildEnv } from './env';
|
||||||
|
import {
|
||||||
|
classifyPortConflict,
|
||||||
|
clearDaemonRecord,
|
||||||
|
isOurDaemonProcess,
|
||||||
|
isPortFree,
|
||||||
|
processAlive,
|
||||||
|
readDaemonRecord,
|
||||||
|
reclaimOrphanDaemon,
|
||||||
|
writeDaemonRecord,
|
||||||
|
} from './port-guard';
|
||||||
|
import { DaemonStartError } from './start-failure';
|
||||||
|
|
||||||
|
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||||
|
/** Lignes de sortie conservées pour le diagnostic affiché en cas d'échec. */
|
||||||
|
const LOG_TAIL_LINES = 40;
|
||||||
|
/** Au-delà, le journal est tronqué au démarrage (fichier de dépannage, pas d'archive). */
|
||||||
|
const MAX_LOG_BYTES = 2_000_000;
|
||||||
|
|
||||||
export interface DaemonHandle {
|
export interface DaemonHandle {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
/** Dernières lignes de sortie du daemon (diagnostic). */
|
||||||
|
logTail(): string;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,19 +34,31 @@ interface Handshake {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartDaemonOptions {
|
||||||
|
dataDir: string;
|
||||||
|
port: number;
|
||||||
|
/** Empreinte du daemon lancé, pour récupérer un orphelin au démarrage suivant. */
|
||||||
|
pidfile?: string;
|
||||||
|
/** Journal persistant du daemon (dépannage hors terminal). */
|
||||||
|
logFile?: string;
|
||||||
|
onLog?: (line: string) => void;
|
||||||
|
/** Appelé si le daemon s'arrête APRÈS le handshake (mort inattendue). */
|
||||||
|
onExit?: (code: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3
|
* Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3
|
||||||
* (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le
|
* (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le
|
||||||
* serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL.
|
* serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL.
|
||||||
|
*
|
||||||
|
* Le port est vérifié AVANT le spawn : sans ça, un daemon resté seul après un crash ou une mise à
|
||||||
|
* jour rendait l'app définitivement inutilisable (EADDRINUSE, enfant mort, aucun message).
|
||||||
*/
|
*/
|
||||||
export function startDaemon(opts: {
|
export async function startDaemon(opts: StartDaemonOptions): Promise<DaemonHandle> {
|
||||||
dataDir: string;
|
|
||||||
port: number;
|
|
||||||
onLog?: (line: string) => void;
|
|
||||||
onExit?: (code: number | null) => void;
|
|
||||||
}): Promise<DaemonHandle> {
|
|
||||||
const node = resolveNodeBin();
|
const node = resolveNodeBin();
|
||||||
const entry = resolveServerEntry();
|
const entry = resolveServerEntry();
|
||||||
|
await ensurePortAvailable(opts.port, entry, opts.pidfile);
|
||||||
|
|
||||||
const dbPath = join(opts.dataDir, 'arboretum.db');
|
const dbPath = join(opts.dataDir, 'arboretum.db');
|
||||||
const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' });
|
const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' });
|
||||||
|
|
||||||
@@ -35,18 +67,41 @@ export function startDaemon(opts: {
|
|||||||
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
|
const logSink = opts.logFile ? openLogFile(opts.logFile) : null;
|
||||||
child.stderr?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
|
const tail: string[] = [];
|
||||||
|
const collect = (chunk: Buffer): void => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
opts.onLog?.(text);
|
||||||
|
logSink?.write(text);
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
tail.push(line);
|
||||||
|
if (tail.length > LOG_TAIL_LINES) tail.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const logTail = (): string => tail.join('\n');
|
||||||
|
|
||||||
|
child.stdout?.on('data', collect);
|
||||||
|
child.stderr?.on('data', collect);
|
||||||
|
|
||||||
|
if (child.pid !== undefined && opts.pidfile) {
|
||||||
|
writeDaemonRecord(opts.pidfile, { pid: child.pid, ownerPid: process.pid, port: opts.port });
|
||||||
|
}
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const stop = (): Promise<void> =>
|
const stop = (): Promise<void> =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
if (stopped || child.exitCode !== null) return resolve();
|
const done = (): void => {
|
||||||
|
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
|
||||||
|
logSink?.end();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
if (stopped || child.exitCode !== null) return done();
|
||||||
stopped = true;
|
stopped = true;
|
||||||
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
||||||
child.once('exit', () => {
|
child.once('exit', () => {
|
||||||
clearTimeout(killTimer);
|
clearTimeout(killTimer);
|
||||||
resolve();
|
done();
|
||||||
});
|
});
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
});
|
});
|
||||||
@@ -59,16 +114,28 @@ export function startDaemon(opts: {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
void stop();
|
void stop();
|
||||||
reject(new Error('daemon handshake timeout'));
|
reject(new DaemonStartError('handshake', 'daemon handshake timeout', logTail()));
|
||||||
}, 30000);
|
}, HANDSHAKE_TIMEOUT_MS);
|
||||||
|
|
||||||
child.once('exit', (code) => {
|
child.once('exit', (code) => {
|
||||||
|
if (settled) {
|
||||||
|
// Mort après le handshake : l'empreinte ne décrit plus rien de vivant.
|
||||||
|
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
|
||||||
opts.onExit?.(code);
|
opts.onExit?.(code);
|
||||||
if (!settled) {
|
return;
|
||||||
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
reject(new Error(`daemon exited before handshake (code ${code ?? 'null'})`));
|
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
|
||||||
}
|
logSink?.end();
|
||||||
|
// Course perdue entre la vérification du port et le bind du daemon : le motif reste « port pris »,
|
||||||
|
// pas un échec de handshake opaque.
|
||||||
|
const busy = /EADDRINUSE/.test(logTail());
|
||||||
|
reject(
|
||||||
|
busy
|
||||||
|
? new DaemonStartError('port-busy-foreign', `port ${opts.port} is already in use`, logTail())
|
||||||
|
: new DaemonStartError('handshake', `daemon exited before handshake (code ${code ?? 'null'})`, logTail()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
fd3?.on('data', (chunk: Buffer) => {
|
fd3?.on('data', (chunk: Buffer) => {
|
||||||
@@ -80,11 +147,45 @@ export function startDaemon(opts: {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
try {
|
try {
|
||||||
const hs = JSON.parse(buf.slice(0, nl)) as Handshake;
|
const hs = JSON.parse(buf.slice(0, nl)) as Handshake;
|
||||||
resolve({ url: hs.url, token: hs.token, stop });
|
resolve({ url: hs.url, token: hs.token, logTail, stop });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
void stop();
|
void stop();
|
||||||
reject(err instanceof Error ? err : new Error(String(err)));
|
reject(new DaemonStartError('handshake', err instanceof Error ? err.message : String(err), logTail()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libère le port si l'occupant est un daemon à nous devenu orphelin ; sinon échoue avec un motif que
|
||||||
|
* le dialogue sait traduire en action (autre instance dans le tray, service, daemon en terminal).
|
||||||
|
*/
|
||||||
|
async function ensurePortAvailable(port: number, serverEntry: string, pidfile?: string): Promise<void> {
|
||||||
|
if (await isPortFree(port)) return;
|
||||||
|
|
||||||
|
const conflict = classifyPortConflict(pidfile ? readDaemonRecord(pidfile) : null, processAlive, port);
|
||||||
|
// La reprise exige DEUX preuves : l'empreinte désigne un daemon sans pilote, et le pid exécute
|
||||||
|
// effectivement notre serveur (un pid recyclé ne doit jamais être tué à sa place).
|
||||||
|
if (conflict.kind === 'orphan' && isOurDaemonProcess(conflict.pid, serverEntry)) {
|
||||||
|
// Daemon survivant à son Electron (crash, kill -9, paquet mis à jour) : plus personne ne le
|
||||||
|
// pilote et il tient la base ET le port. On le récupère au lieu de condamner l'app.
|
||||||
|
await reclaimOrphanDaemon(conflict.pid, port);
|
||||||
|
if (await isPortFree(port)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DaemonStartError(
|
||||||
|
conflict.kind === 'other-instance' ? 'port-busy-instance' : 'port-busy-foreign',
|
||||||
|
`port ${port} is already in use`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLogFile(path: string): WriteStream | null {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
|
||||||
|
if (size > MAX_LOG_BYTES) truncateSync(path, 0);
|
||||||
|
return createWriteStream(path, { flags: 'a' });
|
||||||
|
} catch {
|
||||||
|
return null; // un journal indisponible ne doit pas empêcher le démarrage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,30 @@ import { homedir } from 'node:os';
|
|||||||
// `claude`. Le réglage `claude_bin_path` (UI) reste le filet de secours.
|
// `claude`. Le réglage `claude_bin_path` (UI) reste le filet de secours.
|
||||||
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
|
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
|
||||||
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
|
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
|
||||||
if (process.platform !== 'win32') {
|
const extras = pathExtras(process.platform, env);
|
||||||
const extras = ['/usr/local/bin', '/opt/homebrew/bin', join(homedir(), '.local', 'bin'), '/usr/bin', '/bin'];
|
if (extras.length > 0) {
|
||||||
const current = env.PATH ? env.PATH.split(delimiter) : [];
|
const current = env.PATH ? env.PATH.split(delimiter) : [];
|
||||||
env.PATH = [...new Set([...extras, ...current])].join(delimiter);
|
env.PATH = [...new Set([...extras, ...current])].join(delimiter);
|
||||||
}
|
}
|
||||||
return env;
|
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'];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions, type Tray } from 'electron';
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
session,
|
||||||
|
shell,
|
||||||
|
type BrowserWindowConstructorOptions,
|
||||||
|
type Tray,
|
||||||
|
} from 'electron';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { startDaemon, type DaemonHandle } from './daemon';
|
import { startDaemon, type DaemonHandle } from './daemon';
|
||||||
import { seedSessionCookie } from './auth';
|
import { seedSessionCookie } from './auth';
|
||||||
import { loadWindowState, saveWindowState } from './window-state';
|
import { loadWindowState, saveWindowState } from './window-state';
|
||||||
import { createTray } from './tray';
|
import { createTray } from './tray';
|
||||||
|
import { installAppMenu } from './app-menu';
|
||||||
|
import { registerClipboardBridge } from './clipboard';
|
||||||
import { initUpdater } from './updater';
|
import { initUpdater } from './updater';
|
||||||
import { resolveIconPath } from './paths';
|
import { resolveIconPath } from './paths';
|
||||||
|
import { describeStartFailure } from './start-failure';
|
||||||
|
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp, type InstallStamp } from './upgrade-watch';
|
||||||
|
|
||||||
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
||||||
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
||||||
@@ -15,41 +28,279 @@ import { resolveIconPath } from './paths';
|
|||||||
app.setName('Arboretum');
|
app.setName('Arboretum');
|
||||||
|
|
||||||
const PARTITION = 'persist:arboretum';
|
const PARTITION = 'persist:arboretum';
|
||||||
const PORT = 7317;
|
const DEFAULT_PORT = 7317;
|
||||||
|
const PORT = resolvePort();
|
||||||
|
|
||||||
let daemon: DaemonHandle | null = null;
|
let daemon: DaemonHandle | null = null;
|
||||||
let win: BrowserWindow | null = null;
|
let win: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
let relaunchAfterQuit = false;
|
||||||
|
let bridgeRegistered = false;
|
||||||
|
/** Le daemon a passé son handshake : sa mort devient un incident à signaler. */
|
||||||
|
let serverReady = false;
|
||||||
|
/** Empreinte du binaire au lancement, comparée plus tard pour repérer une mise à jour installée. */
|
||||||
|
const bootStamp = readInstallStamp(process.execPath);
|
||||||
|
let dismissedStamp: InstallStamp | null = null;
|
||||||
|
let restartPromptOpen = false;
|
||||||
|
/** Surveillance de l'installation : une mise à jour posée à chaud doit se voir sans que l'utilisateur
|
||||||
|
* ait à toucher à quoi que ce soit, fenêtre ouverte comprise. */
|
||||||
|
let installPoll: { stop: () => void } | null = null;
|
||||||
|
/** Intervalle du poll : un `stat` toutes les 30 s est indolore et suffit largement. */
|
||||||
|
const INSTALL_POLL_MS = 30_000;
|
||||||
|
|
||||||
// Instance unique : deux instances = deux daemons/ports en conflit.
|
// Instance unique : deux instances = deux daemons/ports en conflit.
|
||||||
if (!app.requestSingleInstanceLock()) {
|
if (!app.requestSingleInstanceLock()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', showWindow);
|
app.on('second-instance', showWindow);
|
||||||
app.whenReady().then(bootstrap).catch((err: unknown) => {
|
void app.whenReady()
|
||||||
console.error('[arboretum-desktop] bootstrap failed:', err);
|
.then(startWithRetry)
|
||||||
app.quit();
|
.catch((err: unknown) => {
|
||||||
|
console.error('[arboretum-desktop] fatal:', err);
|
||||||
|
app.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
/**
|
||||||
const dataDir = join(app.getPath('userData'), 'daemon');
|
* Port du daemon local. Surcharge par variable d'env pour cohabiter avec un Arboretum déjà installé
|
||||||
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
|
* en service (ou lancé en terminal) qui tient 7317 en permanence.
|
||||||
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
*/
|
||||||
createWindow(daemon.url);
|
function resolvePort(): number {
|
||||||
tray = createTray({ show: showWindow, quit: quitApp });
|
const n = Number(process.env.ARBORETUM_DESKTOP_PORT);
|
||||||
initUpdater();
|
return Number.isInteger(n) && n >= 1024 && n <= 65535 ? n : DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logFilePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'logs', 'daemon.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre l'app, et en cas d'échec propose une action au lieu de disparaître : un « rien ne se passe »
|
||||||
|
* au clic sur l'icône était le pire symptôme possible (port occupé, Node absent, base verrouillée).
|
||||||
|
*/
|
||||||
|
async function startWithRetry(): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await bootstrap();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[arboretum-desktop] bootstrap failed:', err);
|
||||||
|
await stopDaemonQuietly();
|
||||||
|
if ((await promptStartFailure(err)) === 'quit') {
|
||||||
|
isQuitting = true;
|
||||||
|
app.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
await startServer();
|
||||||
|
// Idempotent : un « Retry » après échec ne doit pas réenregistrer le pont IPC ni empiler un tray.
|
||||||
|
if (!bridgeRegistered) {
|
||||||
|
registerClipboardBridge();
|
||||||
|
bridgeRegistered = true;
|
||||||
|
}
|
||||||
|
if (!win) createWindow(daemonUrl());
|
||||||
|
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
|
||||||
|
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
|
||||||
|
initUpdater();
|
||||||
|
// Détection CONTINUE : sans elle, une mise à jour installée pendant que la fenêtre est ouverte
|
||||||
|
// n'était remarquée qu'au prochain passage par le tray ou le Dock, donc parfois jamais.
|
||||||
|
if (app.isPackaged && !installPoll) {
|
||||||
|
installPoll = pollInstall({
|
||||||
|
path: process.execPath,
|
||||||
|
intervalMs: INSTALL_POLL_MS,
|
||||||
|
boot: bootStamp,
|
||||||
|
onChanged: () => void handleUpgradeInstalled(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
|
||||||
|
async function startServer(): Promise<void> {
|
||||||
|
const dataDir = join(app.getPath('userData'), 'daemon');
|
||||||
|
daemon = await startDaemon({
|
||||||
|
dataDir,
|
||||||
|
port: PORT,
|
||||||
|
pidfile: join(dataDir, 'daemon.json'),
|
||||||
|
logFile: logFilePath(),
|
||||||
|
onLog: (l) => process.stdout.write(l),
|
||||||
|
onExit: handleDaemonExit,
|
||||||
|
});
|
||||||
|
serverReady = true;
|
||||||
|
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function daemonUrl(): string {
|
||||||
|
return daemon?.url ?? `http://127.0.0.1:${PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDaemonQuietly(): Promise<void> {
|
||||||
|
serverReady = false;
|
||||||
|
const handle = daemon;
|
||||||
|
daemon = null;
|
||||||
|
await handle?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dialogue d'échec : motif traduit en action, avec accès au journal du daemon. */
|
||||||
|
async function promptStartFailure(err: unknown): Promise<'retry' | 'quit'> {
|
||||||
|
const { message, detail } = describeStartFailure(err, PORT);
|
||||||
|
const log = logFilePath();
|
||||||
|
for (;;) {
|
||||||
|
const buttons = existsSync(log) ? ['Retry', 'Show log', 'Quit'] : ['Retry', 'Quit'];
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Arboretum',
|
||||||
|
message,
|
||||||
|
detail,
|
||||||
|
buttons,
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: buttons.length - 1,
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
if (buttons[response] === 'Show log') {
|
||||||
|
void shell.openPath(log);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return buttons[response] === 'Retry' ? 'retry' : 'quit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mort inattendue du daemon : la fenêtre resterait affichée sur une SPA qui ne répond plus. On le dit
|
||||||
|
* et on propose de le relancer (le token change, donc cookie re-semé et fenêtre rechargée).
|
||||||
|
*/
|
||||||
|
function handleDaemonExit(code: number | null): void {
|
||||||
|
if (!serverReady || isQuitting || shuttingDown) return;
|
||||||
|
serverReady = false;
|
||||||
|
const tail = daemon?.logTail() ?? '';
|
||||||
|
daemon = null;
|
||||||
|
void promptServerStopped(code, tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptServerStopped(code: number | null, tail: string): Promise<void> {
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Arboretum',
|
||||||
|
message: 'The Arboretum server stopped',
|
||||||
|
detail: [`The local server exited (code ${code ?? 'null'}).`, tail && `Server output:\n${tail}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n'),
|
||||||
|
buttons: ['Restart server', 'Quit'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
if (response !== 0) {
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await startServer();
|
||||||
|
await win?.loadURL(`${daemonUrl()}/`);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
await stopDaemonQuietly();
|
||||||
|
if ((await promptStartFailure(err)) === 'quit') {
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
function showWindow(): void {
|
||||||
|
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
|
||||||
|
// l'app, donc le bon moment pour traiter une mise à jour installée entre-temps.
|
||||||
|
void handleUpgradeInstalled();
|
||||||
if (!win) return;
|
if (!win) return;
|
||||||
if (win.isMinimized()) win.restore();
|
if (win.isMinimized()) win.restore();
|
||||||
win.show();
|
win.show();
|
||||||
win.focus();
|
win.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre de sessions VIVANTES hébergées par le daemon : ce sont les seules choses qu'un redémarrage
|
||||||
|
* détruit. `null` quand on n'a pas pu savoir (daemon injoignable) : l'appelant demandera alors.
|
||||||
|
* Pas d'en-tête Origin sur ce fetch, donc le check Origin strict du serveur ne s'y applique pas.
|
||||||
|
*/
|
||||||
|
async function countLiveSessions(): Promise<number | null> {
|
||||||
|
const handle = daemon;
|
||||||
|
if (!handle || !serverReady) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${handle.url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${handle.token}` } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json()) as { sessions?: Array<{ live?: boolean }> };
|
||||||
|
return (body.sessions ?? []).filter((s) => s.live).length;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
|
||||||
|
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
|
||||||
|
*
|
||||||
|
* Objectif : zéro manipulation. Quand un redémarrage ne coûte RIEN (aucune session vivante), on
|
||||||
|
* redémarre tout seul. On ne demande que s'il y a du travail en cours à interrompre, ou si le daemon
|
||||||
|
* ne répond pas. Un « Later » précédent est définitif pour cette version.
|
||||||
|
*/
|
||||||
|
async function handleUpgradeInstalled(): Promise<void> {
|
||||||
|
if (restartPromptOpen || isQuitting || shuttingDown || !app.isPackaged) return;
|
||||||
|
const current = readInstallStamp(process.execPath);
|
||||||
|
if (!current || !installChanged(bootStamp, current)) return;
|
||||||
|
|
||||||
|
const dismissed = !!dismissedStamp && !installChanged(dismissedStamp, current);
|
||||||
|
const liveSessions = dismissed ? 0 : await countLiveSessions();
|
||||||
|
const action = decideUpgradeAction({ changed: true, liveSessions, dismissed });
|
||||||
|
if (action === 'none') return;
|
||||||
|
|
||||||
|
if (action === 'restart') {
|
||||||
|
// Rien à perdre : on applique la mise à jour sans rien demander. C'est le cas courant.
|
||||||
|
installPoll?.stop();
|
||||||
|
installPoll = null;
|
||||||
|
relaunchAfterQuit = true;
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restartPromptOpen = true;
|
||||||
|
try {
|
||||||
|
const running = liveSessions ?? 0;
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Arboretum',
|
||||||
|
message: 'A new version of Arboretum has been installed',
|
||||||
|
detail:
|
||||||
|
`This window still runs version ${app.getVersion()}, started before the update. ` +
|
||||||
|
(running > 0
|
||||||
|
? `Restarting stops ${running} running session${running > 1 ? 's' : ''}. They can be resumed afterwards.`
|
||||||
|
: 'Restart to load the installed version.'),
|
||||||
|
buttons: ['Restart now', 'Later'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
if (response === 0) {
|
||||||
|
relaunchAfterQuit = true;
|
||||||
|
quitApp();
|
||||||
|
} else {
|
||||||
|
dismissedStamp = current;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
restartPromptOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function quitApp(): void {
|
function quitApp(): void {
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -130,8 +381,12 @@ async function shutdown(): Promise<void> {
|
|||||||
await daemon?.stop();
|
await daemon?.stop();
|
||||||
} finally {
|
} finally {
|
||||||
daemon = null;
|
daemon = null;
|
||||||
|
serverReady = false;
|
||||||
tray?.destroy();
|
tray?.destroy();
|
||||||
tray = null;
|
tray = null;
|
||||||
|
// Relance demandée après une mise à jour : l'enregistrer une fois le daemon arrêté, sinon le
|
||||||
|
// nouveau process retrouverait le port occupé par l'ancien.
|
||||||
|
if (relaunchAfterQuit) app.relaunch();
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
// Résolution des chemins runtime : dev (depuis le repo) vs packagé (extraResources).
|
// Résolution des chemins runtime : dev (depuis le repo) vs packagé (extraResources).
|
||||||
// __dirname pointe sur dist/ (bundle esbuild) une fois construit.
|
// __dirname pointe sur dist/ (bundle esbuild) une fois construit.
|
||||||
@@ -23,6 +24,19 @@ export function resolveIconPath(): string {
|
|||||||
: join(__dirname, '..', 'resources', 'icon.png');
|
: 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é). */
|
/** Binaire Node qui exécute le daemon (>= 22.16 : node:sqlite + ABI node-pty maîtrisé). */
|
||||||
export function resolveNodeBin(): string {
|
export function resolveNodeBin(): string {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
// Le daemon écoute sur un port FIXE (7317) : c'est ce qui rend l'URL locale mémorisable, mais aussi
|
||||||
|
// ce qui rend le démarrage fragile dès qu'un autre process le tient. Ce module répond à la seule
|
||||||
|
// question qui compte alors : qui l'occupe, et avons-nous le droit de le reprendre ?
|
||||||
|
|
||||||
|
/** Empreinte du daemon lancé par cette app : de quoi reconnaître un orphelin au démarrage suivant. */
|
||||||
|
export interface DaemonRecord {
|
||||||
|
/** pid du process Node du daemon. */
|
||||||
|
pid: number;
|
||||||
|
/** pid du process Electron qui l'a lancé : s'il est mort, le daemon n'a plus de pilote. */
|
||||||
|
ownerPid: number;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortConflict =
|
||||||
|
/** Notre daemon, dont l'Electron parent est mort : récupérable. */
|
||||||
|
| { kind: 'orphan'; pid: number }
|
||||||
|
/** Une autre instance vivante de l'app (fenêtre probablement dans le tray). */
|
||||||
|
| { kind: 'other-instance'; pid: number }
|
||||||
|
/** Un tiers : service `arboretum install`, `npx @johanleroy/git-arboretum`, autre logiciel. */
|
||||||
|
| { kind: 'foreign' };
|
||||||
|
|
||||||
|
const RECLAIM_GRACE_MS = 3_000;
|
||||||
|
const RECLAIM_POLL_MS = 100;
|
||||||
|
|
||||||
|
/** Le port est-il libre ? Bind réel sur l'interface exacte du daemon (aucune heuristique). */
|
||||||
|
export function isPortFree(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const probe = createServer();
|
||||||
|
probe.once('error', () => resolve(false));
|
||||||
|
probe.once('listening', () => probe.close(() => resolve(true)));
|
||||||
|
probe.listen({ port, host, exclusive: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vivacité d'un pid. `EPERM` = process existant mais hors de notre portée, donc vivant. */
|
||||||
|
export function processAlive(pid: number): boolean {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDaemonRecord(file: string): DaemonRecord | null {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial<DaemonRecord>;
|
||||||
|
const { pid, ownerPid, port } = raw;
|
||||||
|
if (!Number.isInteger(pid) || !Number.isInteger(ownerPid) || !Number.isInteger(port)) return null;
|
||||||
|
return { pid: pid as number, ownerPid: ownerPid as number, port: port as number };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'empreinte est un confort de diagnostic : son écriture ne doit jamais faire échouer un démarrage.
|
||||||
|
export function writeDaemonRecord(file: string, rec: DaemonRecord): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, JSON.stringify(rec), 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDaemonRecord(file: string): void {
|
||||||
|
try {
|
||||||
|
rmSync(file, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qui tient le port ? Fonction pure (vivacité injectée) : l'empreinte du dernier daemon lancé est le
|
||||||
|
* seul élément qui distingue notre propre orphelin d'une autre instance ou d'un logiciel tiers.
|
||||||
|
* À n'appeler que sur un port déjà constaté occupé.
|
||||||
|
*/
|
||||||
|
export function classifyPortConflict(
|
||||||
|
record: DaemonRecord | null,
|
||||||
|
alive: (pid: number) => boolean,
|
||||||
|
port: number,
|
||||||
|
): PortConflict {
|
||||||
|
if (!record || record.port !== port || !alive(record.pid)) return { kind: 'foreign' };
|
||||||
|
return alive(record.ownerPid) ? { kind: 'other-instance', pid: record.pid } : { kind: 'orphan', pid: record.pid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ligne de commande d'un pid, ou `null` si on ne peut pas la lire. Sert de preuve d'identité avant de
|
||||||
|
* tuer quoi que ce soit ; l'absence de preuve vaut refus.
|
||||||
|
*/
|
||||||
|
export function processCommandLine(pid: number): string | null {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||||
|
try {
|
||||||
|
const res =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? spawnSync(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`],
|
||||||
|
{ encoding: 'utf8', timeout: 5_000, windowsHide: true },
|
||||||
|
)
|
||||||
|
: // -ww : sortie NON tronquée. Les chemins en jeu (node bundlé + entrée du serveur dans les
|
||||||
|
// ressources de l'app) dépassent largement la largeur d'écran par défaut de ps.
|
||||||
|
spawnSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 5_000 });
|
||||||
|
const out = (res.stdout ?? '').trim();
|
||||||
|
return out.length > 0 ? out : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le pid exécute-t-il BIEN notre daemon ? Un pidfile périmé peut désigner un pid recyclé entre-temps
|
||||||
|
* par n'importe quel programme de l'utilisateur : sans cette vérification, la reprise du port se
|
||||||
|
* changerait en « tuer un process innocent ». Pas de preuve lisible = pas de reprise.
|
||||||
|
*/
|
||||||
|
export function isOurDaemonProcess(pid: number, serverEntry: string): boolean {
|
||||||
|
const cmd = processCommandLine(pid);
|
||||||
|
return cmd !== null && cmd.includes(serverEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Termine un daemon orphelin et attend la libération EFFECTIVE du port (SIGTERM, puis SIGKILL) :
|
||||||
|
* le pid disparu ne suffit pas, seul un bind réussi prouve que la voie est libre.
|
||||||
|
*/
|
||||||
|
export async function reclaimOrphanDaemon(pid: number, port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
return isPortFree(port, host);
|
||||||
|
}
|
||||||
|
if (await waitForPortFree(port, RECLAIM_GRACE_MS, host)) return true;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {
|
||||||
|
/* déjà parti */
|
||||||
|
}
|
||||||
|
return waitForPortFree(port, RECLAIM_GRACE_MS, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForPortFree(port: number, timeoutMs: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
if (await isPortFree(port, host)) return true;
|
||||||
|
if (Date.now() >= deadline) return false;
|
||||||
|
await sleep(RECLAIM_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Un démarrage raté doit se VOIR. Avant, l'échec du bootstrap se résumait à un console.error suivi
|
||||||
|
// d'un app.quit() : depuis le lanceur du bureau, l'utilisateur cliquait et « rien ne se passait ».
|
||||||
|
|
||||||
|
export type DaemonStartFailureKind = 'port-busy-instance' | 'port-busy-foreign' | 'handshake';
|
||||||
|
|
||||||
|
/** Échec de démarrage du daemon, porteur d'un motif exploitable par le dialogue utilisateur. */
|
||||||
|
export class DaemonStartError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly kind: DaemonStartFailureKind,
|
||||||
|
message: string,
|
||||||
|
readonly logTail = '',
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DaemonStartError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartFailureText {
|
||||||
|
message: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texte du dialogue d'échec (en anglais : convention des messages utilisateur). Chaque motif porte
|
||||||
|
* l'action concrète qui débloque, jamais la seule trace technique.
|
||||||
|
*/
|
||||||
|
export function describeStartFailure(err: unknown, port: number): StartFailureText {
|
||||||
|
const kind = err instanceof DaemonStartError ? err.kind : 'handshake';
|
||||||
|
const tail = err instanceof DaemonStartError ? err.logTail : '';
|
||||||
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
if (kind === 'port-busy-instance') {
|
||||||
|
return {
|
||||||
|
message: 'Arboretum is already running',
|
||||||
|
detail:
|
||||||
|
`Another Arboretum instance is using port ${port}. Its window is probably hidden: ` +
|
||||||
|
'click the Arboretum icon in the system tray to bring it back, or quit it from there and retry. ' +
|
||||||
|
'If you just installed an update, quitting and retrying loads the new version.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'port-busy-foreign') {
|
||||||
|
return {
|
||||||
|
message: `Port ${port} is already in use`,
|
||||||
|
detail: join([
|
||||||
|
`Another program is listening on 127.0.0.1:${port}, typically an Arboretum daemon started ` +
|
||||||
|
'from a terminal (npx @johanleroy/git-arboretum) or installed as a service (arboretum install).',
|
||||||
|
`Stop it and retry, or set ARBORETUM_DESKTOP_PORT to a free port before launching the app.`,
|
||||||
|
tail && `Server output:\n${tail}`,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Arboretum could not start its local server',
|
||||||
|
detail: join([raw, tail && `Server output:\n${tail}`]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function join(parts: (string | false)[]): string {
|
||||||
|
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join('\n\n');
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Menu, Tray, nativeImage } from 'electron';
|
import { Menu, Tray, nativeImage } from 'electron';
|
||||||
import { isAutoStartEnabled, setAutoStart } from './autostart';
|
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. */
|
/** 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 {
|
export function createTray(opts: { show: () => void; quit: () => void }): Tray {
|
||||||
const image = nativeImage.createFromPath(resolveIconPath());
|
// macOS exige une image TEMPLATE (monochrome + alpha) dans la barre de menus : elle s'inverse
|
||||||
const tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image.resize({ width: 18, height: 18 }));
|
// 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');
|
tray.setToolTip('Arboretum');
|
||||||
|
|
||||||
const buildMenu = (): void => {
|
const buildMenu = (): void => {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { statSync } from 'node:fs';
|
||||||
|
|
||||||
|
// Une mise à jour installée pendant que l'app tourne (dpkg -i, installeur nsis, .app remplacée)
|
||||||
|
// remplace le binaire sur disque sans toucher au process en cours. Le lock d'instance unique renvoie
|
||||||
|
// alors les lancements suivants vers la fenêtre de l'ANCIENNE version, silencieusement : l'utilisateur
|
||||||
|
// croit utiliser la nouvelle. Comparer une empreinte du binaire suffit à le détecter.
|
||||||
|
|
||||||
|
/** Empreinte du binaire installé. Un remplacement de fichier change l'inode (et souvent mtime/taille). */
|
||||||
|
export interface InstallStamp {
|
||||||
|
ino: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readInstallStamp(path: string): InstallStamp | null {
|
||||||
|
try {
|
||||||
|
const st = statSync(path);
|
||||||
|
return { ino: Number(st.ino), mtimeMs: Math.floor(st.mtimeMs), size: st.size };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'installation a-t-elle changé sous nos pieds ? Une empreinte illisible ne conclut rien. */
|
||||||
|
export function installChanged(boot: InstallStamp | null, current: InstallStamp | null): boolean {
|
||||||
|
if (!boot || !current) return false;
|
||||||
|
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce qu'il faut faire d'une mise à jour installée à chaud.
|
||||||
|
*
|
||||||
|
* Le but est de ne PAS faire porter la manipulation à l'utilisateur : quand redémarrer ne coûte rien,
|
||||||
|
* on redémarre. La seule chose qu'un redémarrage détruit, ce sont les sessions vivantes hébergées par
|
||||||
|
* le daemon (des agents en train de travailler) : là seulement, on demande.
|
||||||
|
*
|
||||||
|
* `liveSessions === null` = on n'a pas pu le savoir (daemon injoignable) : on demande, par prudence.
|
||||||
|
* Un refus précédent (« Later ») est respecté, y compris si les sessions se terminent ensuite : on ne
|
||||||
|
* redémarre jamais dans le dos de quelqu'un qui a dit non.
|
||||||
|
*/
|
||||||
|
export function decideUpgradeAction(input: {
|
||||||
|
changed: boolean;
|
||||||
|
liveSessions: number | null;
|
||||||
|
dismissed: boolean;
|
||||||
|
}): 'none' | 'restart' | 'ask' {
|
||||||
|
if (!input.changed || input.dismissed) return 'none';
|
||||||
|
return input.liveSessions === 0 ? 'restart' : 'ask';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surveillance de l'installation par POLL, et non par `fs.watch` : un paquet remplace le binaire
|
||||||
|
* (nouvel inode) ou tout un répertoire, et selon le gestionnaire de paquets et le système de
|
||||||
|
* fichiers, `fs.watch` sur le fichier ne voit alors plus rien. Un `stat` périodique est trivial en
|
||||||
|
* coût et se comporte pareil partout. Le premier changement suffit : on arrête de surveiller.
|
||||||
|
*/
|
||||||
|
export function pollInstall(opts: {
|
||||||
|
path: string;
|
||||||
|
intervalMs: number;
|
||||||
|
boot: InstallStamp | null;
|
||||||
|
onChanged: (current: InstallStamp) => void;
|
||||||
|
}): { stop: () => void } {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const current = readInstallStamp(opts.path);
|
||||||
|
if (!current || !installChanged(opts.boot, current)) return;
|
||||||
|
clearInterval(timer);
|
||||||
|
opts.onChanged(current);
|
||||||
|
}, opts.intervalMs);
|
||||||
|
// Ne retient pas la boucle d'événements : ce timer ne doit jamais empêcher l'app de quitter.
|
||||||
|
timer.unref?.();
|
||||||
|
return { stop: () => clearInterval(timer) };
|
||||||
|
}
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import { contextBridge } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
import { CLIPBOARD_READ, CLIPBOARD_WRITE } from '../shared/ipc';
|
||||||
|
|
||||||
// Preload minimal (sandbox activé) : expose seulement un marqueur permettant à la SPA de détecter
|
// Preload minimal (sandbox activé) : un marqueur permettant à la SPA de détecter qu'elle tourne dans
|
||||||
// qu'elle tourne dans l'app de bureau. Aucun accès Node/fs exposé au renderer.
|
// l'app de bureau, plus un pont presse-papier. Aucun accès Node/fs exposé au renderer.
|
||||||
|
//
|
||||||
|
// Le pont existe parce que `navigator.clipboard` est refusé (NotAllowedError) dans le renderer
|
||||||
|
// Electron : sans lui, impossible de copier la sélection d'un terminal depuis l'app. Le module
|
||||||
|
// `clipboard` n'étant pas disponible dans un preload sandboxé, on relaie par IPC vers le main.
|
||||||
contextBridge.exposeInMainWorld('arboretumDesktop', {
|
contextBridge.exposeInMainWorld('arboretumDesktop', {
|
||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
|
clipboard: {
|
||||||
|
readText: (): Promise<string> => ipcRenderer.invoke(CLIPBOARD_READ) as Promise<string>,
|
||||||
|
writeText: (text: string): Promise<boolean> => ipcRenderer.invoke(CLIPBOARD_WRITE, text) as Promise<boolean>,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Noms des canaux IPC, partagés entre le process principal et le preload. Isolés ici pour que le
|
||||||
|
// bundle du preload n'ait pas à importer un module du main (qui tire `ipcMain` avec lui).
|
||||||
|
export const CLIPBOARD_READ = 'arboretum:clipboard-read';
|
||||||
|
export const CLIPBOARD_WRITE = 'arboretum:clipboard-write';
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
classifyPortConflict,
|
||||||
|
clearDaemonRecord,
|
||||||
|
isOurDaemonProcess,
|
||||||
|
isPortFree,
|
||||||
|
processAlive,
|
||||||
|
readDaemonRecord,
|
||||||
|
reclaimOrphanDaemon,
|
||||||
|
waitForPortFree,
|
||||||
|
writeDaemonRecord,
|
||||||
|
type DaemonRecord,
|
||||||
|
} from '../src/main/port-guard';
|
||||||
|
|
||||||
|
const rec = (over: Partial<DaemonRecord> = {}): DaemonRecord => ({ pid: 111, ownerPid: 222, port: 7317, ...over });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réserve un port libre HORS de la plage éphémère du noyau (32768+). Un `listen(0)` rendu puis
|
||||||
|
* réutilisé peut être réattribué entre-temps à un autre worker vitest : le test échouait alors une
|
||||||
|
* fois sur N. Ici le port est choisi dans une plage que personne n'obtient par tirage.
|
||||||
|
*/
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const port = 7400 + Math.floor(Math.random() * 600);
|
||||||
|
if (await isPortFree(port)) return port;
|
||||||
|
}
|
||||||
|
throw new Error('aucun port libre dans 7400-7999');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attend que le squatteur ait RÉELLEMENT bind (un spawn met quelques dizaines de ms à écouter). */
|
||||||
|
async function waitUntilBusy(port: number, timeoutMs = 5_000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!(await isPortFree(port))) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('classifyPortConflict', () => {
|
||||||
|
it('sans empreinte, le port est tenu par un tiers', () => {
|
||||||
|
expect(classifyPortConflict(null, () => true, 7317)).toEqual({ kind: 'foreign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empreinte sur un AUTRE port : sans rapport avec le conflit courant', () => {
|
||||||
|
expect(classifyPortConflict(rec({ port: 7400 }), () => true, 7317)).toEqual({ kind: 'foreign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daemon de l’empreinte mort : le port est tenu par autre chose', () => {
|
||||||
|
expect(classifyPortConflict(rec(), () => false, 7317)).toEqual({ kind: 'foreign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daemon vivant + Electron parent vivant : autre instance de l’app', () => {
|
||||||
|
expect(classifyPortConflict(rec(), () => true, 7317)).toEqual({ kind: 'other-instance', pid: 111 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daemon vivant mais Electron parent mort : orphelin récupérable', () => {
|
||||||
|
const alive = (pid: number): boolean => pid === 111;
|
||||||
|
expect(classifyPortConflict(rec(), alive, 7317)).toEqual({ kind: 'orphan', pid: 111 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empreinte du daemon', () => {
|
||||||
|
it('écrit, relit et efface', () => {
|
||||||
|
const file = join(mkdtempSync(join(tmpdir(), 'arb-pidfile-')), 'daemon.json');
|
||||||
|
writeDaemonRecord(file, rec());
|
||||||
|
expect(readDaemonRecord(file)).toEqual(rec());
|
||||||
|
clearDaemonRecord(file);
|
||||||
|
expect(readDaemonRecord(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un contenu illisible ou incomplet plutôt que de deviner', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-pidfile-'));
|
||||||
|
const bad = join(dir, 'bad.json');
|
||||||
|
writeFileSync(bad, 'pas du json', 'utf8');
|
||||||
|
expect(readDaemonRecord(bad)).toBeNull();
|
||||||
|
const partial = join(dir, 'partial.json');
|
||||||
|
writeFileSync(partial, JSON.stringify({ pid: 12 }), 'utf8');
|
||||||
|
expect(readDaemonRecord(partial)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n’échoue pas si le chemin est inécrivable (diagnostic best-effort)', () => {
|
||||||
|
// Un fichier régulier en guise de répertoire parent : mkdir/write échouent (ENOTDIR) et
|
||||||
|
// l'écriture de l'empreinte doit rester silencieuse, jamais bloquer un démarrage.
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-pidfile-'));
|
||||||
|
const blocker = join(dir, 'pas-un-dossier');
|
||||||
|
writeFileSync(blocker, 'x', 'utf8');
|
||||||
|
expect(() => writeDaemonRecord(join(blocker, 'daemon.json'), rec())).not.toThrow();
|
||||||
|
expect(readDaemonRecord(join(blocker, 'daemon.json'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processAlive', () => {
|
||||||
|
it('reconnaît le process courant et refuse les pid invalides', () => {
|
||||||
|
expect(processAlive(process.pid)).toBe(true);
|
||||||
|
expect(processAlive(0)).toBe(false);
|
||||||
|
expect(processAlive(-1)).toBe(false);
|
||||||
|
expect(processAlive(Number.NaN)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPortFree', () => {
|
||||||
|
it('distingue un port libre d’un port occupé', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
const srv = createServer();
|
||||||
|
await new Promise<void>((resolve) => srv.listen(port, '127.0.0.1', resolve));
|
||||||
|
expect(await isPortFree(port)).toBe(false);
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waitForPortFree rend la main sur expiration sans boucler indéfiniment', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const srv = createServer();
|
||||||
|
await new Promise<void>((resolve) => srv.listen(port, '127.0.0.1', resolve));
|
||||||
|
expect(await waitForPortFree(port, 250)).toBe(false);
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOurDaemonProcess', () => {
|
||||||
|
it('reconnaît un process dont la commande porte l’entrée du serveur', async () => {
|
||||||
|
const marker = join(mkdtempSync(join(tmpdir(), 'arb-entry-')), 'server-entry-marker.js');
|
||||||
|
writeFileSync(marker, 'setInterval(()=>{},1000)', 'utf8');
|
||||||
|
const child = spawn(process.execPath, [marker]);
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
expect(isOurDaemonProcess(child.pid as number, marker)).toBe(true);
|
||||||
|
// Un pid recyclé par un programme quelconque ne doit PAS passer pour notre daemon.
|
||||||
|
expect(isOurDaemonProcess(child.pid as number, '/opt/ailleurs/dist/index.js')).toBe(false);
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse quand la commande est illisible (pid absent, pid invalide)', () => {
|
||||||
|
expect(isOurDaemonProcess(2_147_483_600, '/quelconque')).toBe(false);
|
||||||
|
expect(isOurDaemonProcess(0, '/quelconque')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reclaimOrphanDaemon', () => {
|
||||||
|
it('termine le squatteur et attend la libération EFFECTIVE du port', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
'-e',
|
||||||
|
`require('net').createServer().listen(${port},'127.0.0.1');setInterval(()=>{},1000)`,
|
||||||
|
]);
|
||||||
|
expect(await waitUntilBusy(port)).toBe(true); // l'enfant a bien pris le port
|
||||||
|
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
}, 12_000);
|
||||||
|
|
||||||
|
it('un pid déjà mort ne bloque rien', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
||||||
|
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
|
||||||
|
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SIGKILL en dernier recours quand SIGTERM est ignoré', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
'-e',
|
||||||
|
`process.on('SIGTERM',()=>{});require('net').createServer().listen(${port},'127.0.0.1');setInterval(()=>{},1000)`,
|
||||||
|
]);
|
||||||
|
expect(await waitUntilBusy(port)).toBe(true);
|
||||||
|
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
}, 12_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DaemonStartError, describeStartFailure } from '../src/main/start-failure';
|
||||||
|
|
||||||
|
describe('describeStartFailure', () => {
|
||||||
|
it('port tenu par une autre instance : renvoie vers le tray, pas vers une trace technique', () => {
|
||||||
|
const err = new DaemonStartError('port-busy-instance', 'port 7317 is already in use');
|
||||||
|
const { message, detail } = describeStartFailure(err, 7317);
|
||||||
|
expect(message).toBe('Arboretum is already running');
|
||||||
|
expect(detail).toContain('7317');
|
||||||
|
expect(detail).toContain('system tray');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('port tenu par un tiers : nomme les suspects et l’échappatoire (variable d’env)', () => {
|
||||||
|
const err = new DaemonStartError('port-busy-foreign', 'port 7317 is already in use', 'EADDRINUSE');
|
||||||
|
const { message, detail } = describeStartFailure(err, 7317);
|
||||||
|
expect(message).toBe('Port 7317 is already in use');
|
||||||
|
expect(detail).toContain('arboretum install');
|
||||||
|
expect(detail).toContain('ARBORETUM_DESKTOP_PORT');
|
||||||
|
expect(detail).toContain('EADDRINUSE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échec de handshake : conserve le message et la queue de journal', () => {
|
||||||
|
const err = new DaemonStartError('handshake', 'daemon exited before handshake (code 1)', 'boom\nbadaboum');
|
||||||
|
const { message, detail } = describeStartFailure(err, 7317);
|
||||||
|
expect(message).toBe('Arboretum could not start its local server');
|
||||||
|
expect(detail).toContain('daemon exited before handshake (code 1)');
|
||||||
|
expect(detail).toContain('badaboum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erreur quelconque (hors DaemonStartError) reste affichable', () => {
|
||||||
|
expect(describeStartFailure(new Error('ENOENT node'), 7317).detail).toContain('ENOENT node');
|
||||||
|
expect(describeStartFailure('cassé', 7317).detail).toContain('cassé');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans queue de journal, aucun bloc « Server output » vide', () => {
|
||||||
|
const detail = describeStartFailure(new DaemonStartError('handshake', 'nope'), 7317).detail;
|
||||||
|
expect(detail).not.toContain('Server output');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch';
|
||||||
|
|
||||||
|
describe('upgrade-watch', () => {
|
||||||
|
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
|
||||||
|
const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin');
|
||||||
|
writeFileSync(file, 'v1', 'utf8');
|
||||||
|
const stamp = readInstallStamp(file);
|
||||||
|
expect(stamp?.size).toBe(2);
|
||||||
|
expect(readInstallStamp(join(file, 'nulle-part'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('détecte le remplacement du binaire (mtime/taille)', () => {
|
||||||
|
const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin');
|
||||||
|
writeFileSync(file, 'v1', 'utf8');
|
||||||
|
const boot = readInstallStamp(file);
|
||||||
|
expect(installChanged(boot, readInstallStamp(file))).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync(file, 'version deux', 'utf8');
|
||||||
|
utimesSync(file, new Date(), new Date(Date.now() + 5_000)); // dpkg pose un mtime plus récent
|
||||||
|
expect(installChanged(boot, readInstallStamp(file))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('une empreinte illisible ne conclut jamais à une mise à jour', () => {
|
||||||
|
const stamp = { ino: 1, mtimeMs: 2, size: 3 };
|
||||||
|
expect(installChanged(null, stamp)).toBe(false);
|
||||||
|
expect(installChanged(stamp, null)).toBe(false);
|
||||||
|
expect(installChanged(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une
|
||||||
|
// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander.
|
||||||
|
describe('decideUpgradeAction', () => {
|
||||||
|
it('rien à faire si l’installation n’a pas changé', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none');
|
||||||
|
expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aucune session vivante : redémarrage automatique, sans dialogue', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('des sessions tournent : on demande avant de les interrompre', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask');
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('état inconnu (daemon injoignable) : on demande, par prudence', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none');
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pollInstall', () => {
|
||||||
|
it('signale le premier changement, puis s’arrête de lui-même', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-poll-'));
|
||||||
|
const file = join(dir, 'app');
|
||||||
|
try {
|
||||||
|
writeFileSync(file, 'v1');
|
||||||
|
const boot = readInstallStamp(file);
|
||||||
|
const seen: number[] = [];
|
||||||
|
const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) });
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
expect(seen).toHaveLength(0); // rien n'a bougé
|
||||||
|
|
||||||
|
writeFileSync(file, 'v2-plus-long');
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
|
// un second changement ne doit PAS rappeler : le poll s'arrête au premier
|
||||||
|
writeFileSync(file, 'v3-encore-plus-long');
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
handle.stop();
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un chemin illisible ne déclenche rien', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const handle = pollInstall({
|
||||||
|
path: '/definitely/not/here',
|
||||||
|
intervalMs: 10,
|
||||||
|
boot: { ino: 1, mtimeMs: 1, size: 1 },
|
||||||
|
onChanged: () => seen.push('x'),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
handle.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,169 @@
|
|||||||
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
||||||
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 3.7.1
|
||||||
|
|
||||||
|
- **Sessions launched from Arboretum lost their transcript.** When the daemon itself was started from a
|
||||||
|
Claude Code session (an agent launching the desktop app, or `arboretum` started from a Claude
|
||||||
|
terminal), it inherited that session's runtime markers and passed them on to every session it
|
||||||
|
spawned. `CLAUDE_CODE_CHILD_SESSION=1` makes the CLI believe it is a sub-session, so it turns
|
||||||
|
transcript saving off: no history, no `--resume`, `claudeSessionId` stays null, and with it the fine
|
||||||
|
busy/waiting/idle state. The visible symptom was a warning in the terminal: *Transcript saving is
|
||||||
|
off, inherited CLAUDE_CODE_CHILD_SESSION marker*. The PTY environment is now stripped of those
|
||||||
|
markers (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`,
|
||||||
|
`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_EXECPATH`, `CLAUDE_PID`, `CLAUDE_EFFORT`) for `claude` **and**
|
||||||
|
for shells, since a `claude` typed by hand in a terminal would inherit them too. Legitimate user
|
||||||
|
configuration (`CLAUDE_CONFIG_DIR`, `ANTHROPIC_*`, proxies) is untouched.
|
||||||
|
|
||||||
|
## 3.7.0
|
||||||
|
|
||||||
|
Terminals stop going black, and they now sit side by side. Files and commits follow the terminal you
|
||||||
|
are actually looking at. Fully additive, no protocol version bump.
|
||||||
|
|
||||||
|
- **A terminal could stay completely black while its session was alive and running.** The attach
|
||||||
|
replay is a binary frame, but a client only learns its channel number from the `attached` message.
|
||||||
|
The server sent the replay *first*, so every client dropped it on an unknown channel: nothing was
|
||||||
|
painted, and a resting TUI (Claude at its prompt) never emits anything on its own. `attached` is now
|
||||||
|
sent before the replay, which fixes the web app, the desktop app and the VS Code extension at once.
|
||||||
|
A resize on attach used to hide the bug by triggering a repaint through SIGWINCH, which is why it
|
||||||
|
looked intermittent.
|
||||||
|
- **Screen-less attachments.** `attach` accepts an optional `screen` flag (default `true`). With
|
||||||
|
`screen: false`, a client that only wants to answer a dialog no longer takes control of the session,
|
||||||
|
no longer forces its own dimensions onto the PTY (which used to freeze the real terminal's geometry)
|
||||||
|
and no longer receives the output stream just to throw it away.
|
||||||
|
- **The reason a session died is now visible in the terminal**: a last line, `[arboretum] claude
|
||||||
|
exited with code 1`, is written to the stream before clients are detached. A process that died at
|
||||||
|
spawn time used to leave nothing but an empty screen.
|
||||||
|
- **A stale Claude CLI path is re-resolved.** The resolved binary was cached for the lifetime of the
|
||||||
|
daemon; after an nvm or asdf switch it pointed at a file that no longer existed, and the PTY died
|
||||||
|
without a single byte of output.
|
||||||
|
- **Terminal columns.** The dock holds up to three resizable columns, each with its own tabs. Alt+click
|
||||||
|
a session (tree, panels, attention list) or use the tab button to open it beside the current one.
|
||||||
|
Keystrokes always go to the focused column. The dock's height ceiling now follows the viewport
|
||||||
|
instead of a fixed 640 px, and opening the dock gives it a usable height.
|
||||||
|
- **Changes are scoped to the focused terminal.** The Changes view shows the worktree of the terminal
|
||||||
|
you are working in, or every repo of its group for a group session, with a one-click "show every
|
||||||
|
project" toggle. The Git index in the sidebar stays global on purpose: it is the way out of a narrow
|
||||||
|
scope. The activity-bar badge stays global too: it exists to surface work you are *not* looking at.
|
||||||
|
|
||||||
|
## 3.6.0
|
||||||
|
|
||||||
|
Files open again, and uncommitted work gets a real surface: one block per project, in the centre of
|
||||||
|
the IDE. Fully additive, no protocol version bump.
|
||||||
|
|
||||||
|
- **Opening a file showed an empty page.** The container of the single Monaco instance lived inside a
|
||||||
|
`v-if`, so mounting the IDE with no open tab (first use, or after closing everything) bailed out
|
||||||
|
silently and the editor was never created: the first file you opened rendered nothing, with no
|
||||||
|
spinner and no error. The container is now always mounted and the editor is created on demand.
|
||||||
|
Closing the last tab no longer destroys it either.
|
||||||
|
- **The editor now says when it cannot load.** A missing editor chunk (a stale `index.html` after an
|
||||||
|
update, a network drop) used to be memoized as a permanent failure, silently, for the whole
|
||||||
|
session. It reports the failure, offers Retry and Reload page, and shows a loading state while the
|
||||||
|
chunk and the file are fetched. A file that disappears under the editor is reported instead of
|
||||||
|
being swallowed.
|
||||||
|
- **No more console errors when opening TypeScript.** Only the base worker was provided, so the
|
||||||
|
TypeScript language service kept calling a worker that did not implement its methods, throwing on
|
||||||
|
every single file open. The language workers are shipped now (loaded on demand). Semantic
|
||||||
|
diagnostics stay off on purpose: with no tsconfig and no node_modules, they would invent errors.
|
||||||
|
- **Changes: your uncommitted work, per project, in the centre.** Two buttons on the right of the tab
|
||||||
|
bar switch the centre between Files and Changes. Changes shows one block per worktree across every
|
||||||
|
open project, each with its staged and unstaged files, its diff unfolded in place, its commit,
|
||||||
|
push, fetch and pull, and its own history. The Git panel in the sidebar becomes the index: what
|
||||||
|
each project is worth at a glance, one click to open the matching block. The activity bar badge
|
||||||
|
finally counts the worktrees that need attention.
|
||||||
|
- **Push and commit buttons tell the truth.** Push was enabled with nothing to push and disabled when
|
||||||
|
merely behind; it now follows what git will actually do. Amending just a message is allowed, as the
|
||||||
|
server already did. A rebase is offered as soon as the branch is behind, not only after a
|
||||||
|
fast-forward fails.
|
||||||
|
- **Commit, fetch and push refresh what they change.** They only broadcast a worktree update, so the
|
||||||
|
"n unpushed" counter stayed stale right after a push, and files just committed were still listed.
|
||||||
|
- On mobile, tapping a file in the explorer now brings the editor to the front, and the Git tab opens
|
||||||
|
the Changes view (the sidebar index is a desktop affordance).
|
||||||
|
|
||||||
|
## 3.5.1
|
||||||
|
|
||||||
|
Completes the terminal copy & paste of 3.5.0, which only worked in a browser.
|
||||||
|
|
||||||
|
- **Copy & paste inside the desktop app.** In the Electron renderer, `navigator.clipboard` rejects with
|
||||||
|
`NotAllowedError` for reads AND writes, so 3.5.0's copy silently did nothing there, exactly where the
|
||||||
|
problem had been reported. Clipboard access now goes through a cascade: the desktop app's own bridge
|
||||||
|
first (IPC to Electron's `clipboard` module, exposed by the preload), then `navigator.clipboard`, then
|
||||||
|
`document.execCommand('copy')` for writes, which also covers plain-HTTP access over a LAN where the
|
||||||
|
Clipboard API is unavailable.
|
||||||
|
|
||||||
|
## 3.5.0
|
||||||
|
|
||||||
|
Fixes a black screen after every update, gives the web terminal a working copy & paste, and lets you
|
||||||
|
browse a group's files without leaving the Groups panel. Fully additive, no protocol version bump.
|
||||||
|
|
||||||
|
- **No more black screen after an update.** The embedded SPA is served by `@fastify/static`, whose weak
|
||||||
|
etag derives from size + mtime, and `npm pack` pins the mtime of every file in the tarball to a
|
||||||
|
constant (1985-10-26). Two different `index.html` of equal size therefore shared an etag: clients got a
|
||||||
|
`304 Not Modified` and kept an index referencing `/assets/<hash>` files that no longer existed. The
|
||||||
|
fallback route then answered those module requests with `index.html` as `text/html`, the browser
|
||||||
|
refused the script, and nothing painted. `index.html` and every other unhashed file are now served
|
||||||
|
`no-store` with conditional validation disabled, so a client holding a stale copy repairs itself;
|
||||||
|
hashed `/assets/` are served `immutable` for a year.
|
||||||
|
- **Copy & paste in the terminal.** xterm's selection is not a DOM selection, so the native Copy (the
|
||||||
|
Electron Edit menu, the browser context menu) had nothing to copy and terminal output could not be
|
||||||
|
retrieved at all. `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS, plus
|
||||||
|
`Ctrl+Insert` / `Shift+Insert`) now copy the selection and paste the clipboard, and the DOM `copy`
|
||||||
|
event is intercepted so the native Copy works too. `Ctrl+C` is deliberately untouched: it stays SIGINT.
|
||||||
|
- **Theme applied before the first paint again.** The anti-FOUC script was inline in `index.html`, which
|
||||||
|
the daemon's own CSP (`script-src 'self'`) refused to execute; it moved to `/theme-boot.js`.
|
||||||
|
- **Browse files from the Groups panel.** A group's worktrees expand into their file tree, the same
|
||||||
|
component and the same expansion state as the Explorer, and those worktrees are now watched for
|
||||||
|
real-time changes too.
|
||||||
|
- **Sources are text again.** Three files embedded a literal NUL byte in a string separator, which made
|
||||||
|
git and grep treat them as binary: their diffs were unreviewable and the `lint-dashes` CI guard
|
||||||
|
(`git grep -I`) silently skipped them. Escaped as `\0`, same runtime value.
|
||||||
|
|
||||||
|
## 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=<hash>` 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.
|
||||||
|
|
||||||
|
- **Launch commands per repo.** A repo now carries reusable start commands (label, shell command, optional subdirectory), persisted as JSON and edited from the dashboard. They can be auto-detected from `package.json` scripts, a `Procfile` or a `docker-compose` file.
|
||||||
|
- **One terminal per command.** `POST /api/v1/repos/:id/launch` resolves the target worktree server-side (the client never passes a raw path) and opens one managed terminal per enabled command, all sharing a launch run id so you can stop the whole set in one action. Each command runs in your interactive login shell (so `npm`, `docker`, nvm/asdf are on `PATH`) and the shell stays live after the command exits, keeping failures on screen.
|
||||||
|
- **Surfaces.** Start a project from a repo or worktree menu, the sessions panel or the command palette. The VS Code extension exposes it too (see its changelog).
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
Visual overhaul: the web UI adopts the "Emerald" design system and gains a full theme system. No protocol or API change (fully additive, backward compatible).
|
Visual overhaul: the web UI adopts the "Emerald" design system and gains a full theme system. No protocol or API change (fully additive, backward compatible).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.2.0",
|
"version": "3.7.1",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Johan LEROY <contact@johanleroy.fr>",
|
"author": "Johan LEROY <contact@johanleroy.fr>",
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P13 (sans navigateur, sans quota Claude) : « Démarrer le projet » (lancement
|
||||||
|
// multi-terminaux). Vrai daemon + vrai repo git tmp + vrai client WS. Couvre : exposition/persistance
|
||||||
|
// de launch_commands (+ broadcast repo_update), auto-détection (package.json/Procfile/compose),
|
||||||
|
// lancement d'un terminal par commande activée (même launchRunId, command bash, titre = label),
|
||||||
|
// auto-type réellement exécuté (marqueur dans le ring) dans un shell INTERACTIF (survit à la commande),
|
||||||
|
// bornage anti-traversal du cwd + sous-dossier valide, sélection par commandIds, filtrage des désactivées,
|
||||||
|
// et « tout arrêter ».
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 = 7553;
|
||||||
|
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-p13-'));
|
||||||
|
const repo = join(tmp, 'demo-repo');
|
||||||
|
mkdirSync(repo, { recursive: true });
|
||||||
|
mkdirSync(join(repo, 'sub'), { 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');
|
||||||
|
// Fichiers pour l'auto-détection.
|
||||||
|
writeFileSync(join(repo, 'package.json'), JSON.stringify({ scripts: { dev: 'echo dev', build: 'echo build', test: 'echo test' } }));
|
||||||
|
writeFileSync(join(repo, 'Procfile'), 'web: echo procweb\n');
|
||||||
|
writeFileSync(join(repo, 'docker-compose.yml'), 'services: {}\n');
|
||||||
|
writeFileSync(join(repo, 'README.md'), '# demo\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));
|
||||||
|
|
||||||
|
// Client WS multiplexé (contrôle JSON + sortie binaire → ring décodé en latin1).
|
||||||
|
function wsClient(cookie) {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
const state = { msgs: [], outputs: new Map() };
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
state.msgs.push(JSON.parse(String(data)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(data);
|
||||||
|
const type = buf.readUInt8(0);
|
||||||
|
const channel = buf.readUInt32LE(1);
|
||||||
|
const payload = buf.subarray(5);
|
||||||
|
if (type === 0x02) state.outputs.set(channel, payload.toString('latin1'));
|
||||||
|
else state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-200000));
|
||||||
|
});
|
||||||
|
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 && cookie.startsWith('arb_session='));
|
||||||
|
|
||||||
|
// Enregistre le repo.
|
||||||
|
const reg = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
||||||
|
const repoId = (await reg.json()).repo?.id;
|
||||||
|
check('register repo', reg.status === 201 && !!repoId);
|
||||||
|
|
||||||
|
// launchCommands vide par défaut.
|
||||||
|
const list0 = await (await j('/api/v1/repos', 'GET', cookie)).json();
|
||||||
|
const r0 = list0.repos.find((r) => r.id === repoId);
|
||||||
|
check('launchCommands défaut = []', Array.isArray(r0?.launchCommands) && r0.launchCommands.length === 0);
|
||||||
|
|
||||||
|
// Auto-détection.
|
||||||
|
const det = await (await j(`/api/v1/repos/${repoId}/launch/detect`, 'GET', cookie)).json();
|
||||||
|
const runs = (det.suggestions ?? []).map((s) => s.run);
|
||||||
|
check(
|
||||||
|
'detect : package.json + Procfile + docker-compose',
|
||||||
|
runs.includes('npm run dev') && runs.includes('npm run build') && runs.some((r) => r.startsWith('echo procweb')) && runs.includes('docker compose up'),
|
||||||
|
`${runs.length} suggestions`,
|
||||||
|
);
|
||||||
|
check('detect : dev activé, build désactivé (heuristique)', (det.suggestions.find((s) => s.run === 'npm run dev')?.enabled === true) && (det.suggestions.find((s) => s.run === 'npm run build')?.enabled === false));
|
||||||
|
|
||||||
|
// Abonnement worktrees pour vérifier le broadcast repo_update.
|
||||||
|
const c1 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||||
|
c1.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c1.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c1.send({ type: 'sub', topics: ['worktrees', 'sessions'] });
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const nonce = String(token).slice(-6);
|
||||||
|
const commands = [
|
||||||
|
{ id: 'c-web', label: 'web', run: `echo ARB_LAUNCH_OK_${nonce}; echo ARB_FLAGS_$-; sleep 30`, enabled: true },
|
||||||
|
{ id: 'c-api', label: 'api', run: 'sleep 30', enabled: true },
|
||||||
|
{ id: 'c-build', label: 'build', run: 'echo SHOULD_NOT_RUN', enabled: false },
|
||||||
|
{ id: 'c-sub', label: 'sub', run: 'pwd; sleep 30', cwd: 'sub', enabled: false },
|
||||||
|
{ id: 'c-esc', label: 'esc', run: 'pwd', cwd: '../../etc', enabled: false },
|
||||||
|
];
|
||||||
|
const patch = await j(`/api/v1/repos/${repoId}`, 'PATCH', cookie, { launchCommands: commands });
|
||||||
|
const patched = (await patch.json()).repo;
|
||||||
|
check('PATCH launchCommands persiste', patch.status === 200 && patched.launchCommands.length === 5);
|
||||||
|
const evt = await c1.waitMsg((m) => m.type === 'repo_update' && m.repo?.id === repoId && (m.repo.launchCommands?.length ?? 0) === 5);
|
||||||
|
check('broadcast repo_update porte launchCommands', !!evt);
|
||||||
|
|
||||||
|
// Lancement par défaut (commandes activées : web, api).
|
||||||
|
const launch = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, {});
|
||||||
|
const lr = await launch.json();
|
||||||
|
check('POST /launch → N sessions (activées only)', launch.status === 201 && lr.sessions?.length === 2, `${lr.sessions?.length} sessions`);
|
||||||
|
const runIds = new Set(lr.sessions.map((s) => s.launchRunId));
|
||||||
|
check('même launchRunId sur tous les terminaux', runIds.size === 1 && [...runIds][0], [...runIds][0]);
|
||||||
|
check('command = bash + titre = label', lr.sessions.every((s) => s.command === 'bash') && lr.sessions.map((s) => s.title).sort().join(',') === 'api,web');
|
||||||
|
const launchRunId = [...runIds][0];
|
||||||
|
|
||||||
|
// Attache au terminal « web » → l'auto-type a été exécuté (marqueur) dans un shell INTERACTIF.
|
||||||
|
const webSid = lr.sessions.find((s) => s.title === 'web').id;
|
||||||
|
c1.send({ type: 'attach', sessionId: webSid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === webSid);
|
||||||
|
await sleep(1200);
|
||||||
|
const out = c1.state.outputs.get(att.channel) ?? '';
|
||||||
|
check('auto-type exécuté (marqueur dans le ring)', out.includes(`ARB_LAUNCH_OK_${nonce}`), `${out.length} o`);
|
||||||
|
check('shell interactif (flags $- contiennent i)', /ARB_FLAGS_[a-zA-Z]*i/.test(out));
|
||||||
|
|
||||||
|
// Le shell survit à la commande (sleep encore vivant).
|
||||||
|
const sess1 = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||||
|
const apiSid = lr.sessions.find((s) => s.title === 'api').id;
|
||||||
|
check('shell survivant (session live)', sess1.sessions.find((s) => s.id === apiSid)?.live === true);
|
||||||
|
|
||||||
|
// Sélection par commandIds (web seul).
|
||||||
|
const one = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-web'] })).json();
|
||||||
|
check('commandIds : sous-ensemble', one.sessions?.length === 1 && one.sessions[0].title === 'web');
|
||||||
|
|
||||||
|
// Sous-dossier valide : pwd sous le worktree.
|
||||||
|
const subRes = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-sub'] })).json();
|
||||||
|
const subSid = subRes.sessions?.[0]?.id;
|
||||||
|
c1.send({ type: 'attach', sessionId: subSid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const attSub = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === subSid);
|
||||||
|
await sleep(800);
|
||||||
|
check('cwd sous-dossier borné (pwd dans /sub)', (c1.state.outputs.get(attSub.channel) ?? '').includes('/sub'));
|
||||||
|
|
||||||
|
// Traversal rejeté : cwd ../../etc.
|
||||||
|
const esc = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-esc'] });
|
||||||
|
check('cwd traversal rejeté (4xx)', esc.status >= 400 && esc.status < 500, `status ${esc.status}`);
|
||||||
|
|
||||||
|
// « Tout arrêter » : kill de tous les terminaux du launchRunId initial.
|
||||||
|
const before = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId);
|
||||||
|
for (const s of before) await j(`/api/v1/sessions/${s.id}`, 'DELETE', cookie);
|
||||||
|
// Un shell interactif ignore SIGTERM (standard) → mort garantie au SIGKILL après le délai de grâce (~5 s).
|
||||||
|
await sleep(6500);
|
||||||
|
const after = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId);
|
||||||
|
check('tout arrêter → terminaux non vivants', before.length === 2 && after.length === 2 && after.every((s) => !s.live));
|
||||||
|
|
||||||
|
c1.ws.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err && err.stack ? err.stack : err));
|
||||||
|
} finally {
|
||||||
|
srv.kill('SIGTERM');
|
||||||
|
await sleep(1500);
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(failed.length === 0 ? '\nACCEPTANCE P13: ALL GREEN' : `\nACCEPTANCE P13: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P17 : « le terminal reste tout noir alors que la session tourne ».
|
||||||
|
//
|
||||||
|
// Cause racine reproduite ici : le replay d'attache est une frame BINAIRE, et le client n'apprend le
|
||||||
|
// numéro de canal qu'avec le message `attached`. Émis AVANT, le replay tombait sur un canal inconnu et
|
||||||
|
// était jeté en silence : rien à l'écran jusqu'au prochain octet spontané du PTY, c'est-à-dire jamais
|
||||||
|
// pour un TUI au repos. Ce script vérifie l'ordre réel des trames sur un VRAI WebSocket, et couvre au
|
||||||
|
// passage les attaches sans écran et l'épilogue de sortie.
|
||||||
|
//
|
||||||
|
// Aucun quota Claude consommé : commande `bash`.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const PORT = 7549;
|
||||||
|
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-p17-'));
|
||||||
|
// Daemon lancé avec un environnement POLLUÉ, exactement comme lorsqu'il est démarré depuis une
|
||||||
|
// session Claude Code (cas vécu : l'app de bureau lancée par un agent). Ces marqueurs ne doivent
|
||||||
|
// JAMAIS atteindre les sessions qu'il lance, sinon le CLI se croit sous-session et coupe la
|
||||||
|
// sauvegarde de son transcript (plus d'historique, plus de --resume).
|
||||||
|
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ARBORETUM_LOG: 'warn',
|
||||||
|
CLAUDECODE: '1',
|
||||||
|
CLAUDE_CODE_CHILD_SESSION: '1',
|
||||||
|
CLAUDE_CODE_SESSION_ID: 'parent-session-id',
|
||||||
|
CLAUDE_PID: '424242',
|
||||||
|
ARB_MARQUEUR_LEGITIME: 'conserve-moi',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let srvOut = '';
|
||||||
|
srv.stdout.on('data', (d) => (srvOut += d));
|
||||||
|
srv.stderr.on('data', (d) => (srvOut += d));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client WS qui conserve la CHRONOLOGIE des trames (`frames`), texte et binaire mêlés : c'est le seul
|
||||||
|
* moyen de tester un ordre. Les frames binaires sont décodées en {type, channel, payload}.
|
||||||
|
*/
|
||||||
|
function wsClient(cookie) {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
const frames = [];
|
||||||
|
const msgs = [];
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
const msg = JSON.parse(String(data));
|
||||||
|
msgs.push(msg);
|
||||||
|
frames.push({ kind: 'text', msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(data);
|
||||||
|
frames.push({ kind: 'binary', type: buf.readUInt8(0), channel: buf.readUInt32LE(1), payload: buf.subarray(5) });
|
||||||
|
});
|
||||||
|
const waitMsg = async (pred, timeout = 8000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) {
|
||||||
|
const m = msgs.find(pred);
|
||||||
|
if (m) return m;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Sortie telle que le VRAI client la peindrait : il n'enregistre un canal qu'en recevant `attached`
|
||||||
|
* et jette toute frame binaire arrivée avant. On imite ce comportement, sinon ce script verrait un
|
||||||
|
* écran que le navigateur, lui, n'affiche pas.
|
||||||
|
*/
|
||||||
|
const outputOf = (channel) => {
|
||||||
|
const known = frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached' && f.msg.channel === channel);
|
||||||
|
if (known < 0) return '';
|
||||||
|
return frames
|
||||||
|
.slice(known)
|
||||||
|
.filter((f) => f.kind === 'binary' && f.channel === channel)
|
||||||
|
.map((f) => f.payload.toString('latin1'))
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
return { ws, frames, msgs, waitMsg, outputOf, send: (m) => ws.send(JSON.stringify(m)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500);
|
||||||
|
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||||
|
check('boot + token bootstrap', !!token);
|
||||||
|
|
||||||
|
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 && cookie.startsWith('arb_session='));
|
||||||
|
|
||||||
|
const api = (path, init = {}) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie, ...(init.headers ?? {}) } });
|
||||||
|
|
||||||
|
const created = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
|
||||||
|
const sid = (await created.json()).session.id;
|
||||||
|
check('spawn bash', created.status === 201 && !!sid);
|
||||||
|
|
||||||
|
// --- 1. Première attache : de la sortie existe déjà dans le ring ---
|
||||||
|
const c1 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||||
|
c1.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c1.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
|
||||||
|
c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att1 = await c1.waitMsg((m) => m.type === 'attached');
|
||||||
|
check('attach interactif + controlling', att1?.controlling === true);
|
||||||
|
await sleep(300);
|
||||||
|
c1.send({ type: 'stdin', channel: att1.channel, data: 'echo MARQUEUR-ECRAN-1\r' });
|
||||||
|
await sleep(800);
|
||||||
|
check('stdin → output', c1.outputOf(att1.channel).includes('MARQUEUR-ECRAN-1'));
|
||||||
|
|
||||||
|
// --- 2. Ré-attache (nouvelle connexion, PTY silencieux) : l'écran DOIT revenir ---
|
||||||
|
// C'est le scénario vécu : l'app est rechargée, Claude est à son prompt et n'émet plus rien.
|
||||||
|
const c2 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej)));
|
||||||
|
c2.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c2.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att2 = await c2.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
const idxAttached = c2.frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached');
|
||||||
|
const idxResync = c2.frames.findIndex((f) => f.kind === 'binary' && f.type === 0x02);
|
||||||
|
check('ORDRE : `attached` précède le replay binaire', idxAttached >= 0 && idxResync > idxAttached, `attached@${idxAttached}, resync@${idxResync}`);
|
||||||
|
check('le replay porte le canal annoncé', c2.frames[idxResync]?.channel === att2.channel);
|
||||||
|
const replay = c2.outputOf(att2.channel);
|
||||||
|
check('l’écran se reconstitue à la ré-attache (fin de l’écran noir)', replay.includes('MARQUEUR-ECRAN-1'), `${replay.length} octets rejoués`);
|
||||||
|
|
||||||
|
// --- 3. Un observateur peint aussi : il doit recevoir son replay ---
|
||||||
|
const c3 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c3.ws.on('open', res), c3.ws.on('error', rej)));
|
||||||
|
c3.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c3.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c3.send({ type: 'attach', sessionId: sid, mode: 'observer', cols: 100, rows: 30 });
|
||||||
|
const att3 = await c3.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(400);
|
||||||
|
check('un observateur reçoit son replay (non-controlling)', att3?.controlling === false && c3.outputOf(att3.channel).includes('MARQUEUR-ECRAN-1'));
|
||||||
|
|
||||||
|
// --- 4. Attache SANS écran : ne vole pas le contrôle, ne reçoit rien ---
|
||||||
|
// Régression : le DialogPrompt attachait en interactif avec des dimensions bidon, prenait le
|
||||||
|
// `controlling` et figeait la géométrie du TUI pour le terminal ouvert ensuite.
|
||||||
|
const c4 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c4.ws.on('open', res), c4.ws.on('error', rej)));
|
||||||
|
c4.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c4.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c4.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 80, rows: 24, screen: false });
|
||||||
|
const att4 = await c4.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(300);
|
||||||
|
check('attache sans écran : jamais controlling', att4?.controlling === false);
|
||||||
|
check('attache sans écran : aucune frame binaire', !c4.frames.some((f) => f.kind === 'binary'));
|
||||||
|
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo APRES-AVEUGLE\r' });
|
||||||
|
await sleep(800);
|
||||||
|
check('attache sans écran : ne reçoit pas la sortie du PTY', !c4.frames.some((f) => f.kind === 'binary'));
|
||||||
|
check('le terminal à écran garde le contrôle et fonctionne', c2.outputOf(att2.channel).includes('APRES-AVEUGLE'));
|
||||||
|
// elle garde en revanche le droit d'écrire (c'est sa seule raison d'être)
|
||||||
|
c4.send({ type: 'stdin', channel: att4.channel, data: 'echo ECRIT-PAR-AVEUGLE\r' });
|
||||||
|
await sleep(800);
|
||||||
|
check('attache sans écran : peut écrire', c2.outputOf(att2.channel).includes('ECRIT-PAR-AVEUGLE'));
|
||||||
|
|
||||||
|
// --- 5. Épilogue de sortie : la raison de la mort est visible DANS le terminal ---
|
||||||
|
const dying = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
|
||||||
|
const dsid = (await dying.json()).session.id;
|
||||||
|
const c5 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c5.ws.on('open', res), c5.ws.on('error', rej)));
|
||||||
|
c5.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c5.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c5.send({ type: 'attach', sessionId: dsid, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
const att5 = await c5.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(300);
|
||||||
|
c5.send({ type: 'stdin', channel: att5.channel, data: 'exit 3\r' });
|
||||||
|
const detached5 = await c5.waitMsg((m) => m.type === 'detached' && m.channel === att5.channel, 8000);
|
||||||
|
const epilogue = c5.outputOf(att5.channel);
|
||||||
|
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
|
||||||
|
check('épilogue reçu AVANT le detached', !!detached5);
|
||||||
|
|
||||||
|
// --- 6. L'environnement du PTY est assaini des marqueurs de la session parente ---
|
||||||
|
// Le nom du marqueur est CONCATÉNÉ dans la commande ('EN' + 'V:') pour que l'écho local du terminal
|
||||||
|
// ne ressemble pas au résultat : sinon on relit sa propre frappe et le test passe toujours.
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "EN""V:[$CLAUDECODE][$CLAUDE_CODE_CHILD_SESSION][$CLAUDE_CODE_SESSION_ID][$CLAUDE_PID]"\r' });
|
||||||
|
await sleep(900);
|
||||||
|
const envLine = /ENV:\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]/.exec(c2.outputOf(att2.channel).replace(/\r?\n/g, ''))?.[0] ?? '';
|
||||||
|
check('les marqueurs de session parente ne sont pas transmis au PTY', envLine === 'ENV:[][][][]', envLine || 'non observé');
|
||||||
|
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "GARDE:[$ARB_MARQUEUR_LEGITIME]"\r' });
|
||||||
|
await sleep(900);
|
||||||
|
check(
|
||||||
|
'le reste de l’environnement est bien transmis',
|
||||||
|
c2.outputOf(att2.channel).includes('GARDE:[conserve-moi]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 7. Le PTY n'a pas été redimensionné par les attaches sans écran ---
|
||||||
|
const listed = await (await api('/api/v1/sessions')).json();
|
||||||
|
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
|
||||||
|
|
||||||
|
for (const c of [c1, c2, c3, c4, c5]) 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.exitCode === null);
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length > 0) console.log(`\n--- sortie du daemon ---\n${srvOut.slice(-2000)}`);
|
||||||
|
console.log(failed.length === 0 ? '\nACCEPTANCE P17: ALL GREEN' : `\nACCEPTANCE P17: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Vérification E2E du copier / coller dans le terminal web (régression : la sélection d'xterm n'est
|
||||||
|
// pas une sélection DOM, le « Copier » natif ne voyait donc rien). Daemon temporaire isolé + session
|
||||||
|
// `bash` (pas `claude` : pas de quota consommé) + Chromium piloté en CDP : on tape un marqueur, on le
|
||||||
|
// sélectionne à la souris, Ctrl+Shift+C, et on relit le presse-papier réel du navigateur. Puis
|
||||||
|
// l'inverse : on remplit le presse-papier, Ctrl+Shift+V, et on vérifie que le PTY l'a reçu.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
const PORT = 7411;
|
||||||
|
const CDP_PORT = 9334;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, ok, detail = '') {
|
||||||
|
console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` : ${detail}` : ''}`);
|
||||||
|
if (!ok) failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChromium() {
|
||||||
|
for (const bin of ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome']) {
|
||||||
|
if (existsSync(bin)) return bin;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client CDP minimal : un socket, corrélation par id. */
|
||||||
|
function cdp(url) {
|
||||||
|
const ws = new WebSocket(url, { perMessageDeflate: false });
|
||||||
|
const pending = new Map();
|
||||||
|
let seq = 0;
|
||||||
|
const ready = new Promise((resolve, reject) => {
|
||||||
|
ws.once('open', resolve);
|
||||||
|
ws.once('error', reject);
|
||||||
|
});
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
const entry = pending.get(msg.id);
|
||||||
|
if (!entry) return;
|
||||||
|
pending.delete(msg.id);
|
||||||
|
msg.error ? entry.reject(new Error(msg.error.message)) : entry.resolve(msg.result);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
close: () => ws.close(),
|
||||||
|
send(method, params = {}, sessionId) {
|
||||||
|
const id = ++seq;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let srv, browser, tmp;
|
||||||
|
try {
|
||||||
|
tmp = mkdtempSync(join(tmpdir(), 'arb-clip-'));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
|
||||||
|
const cookieValue = cookie?.slice('arb_session='.length) ?? '';
|
||||||
|
check('login → cookie de session', !!cookie);
|
||||||
|
|
||||||
|
const sess = await (
|
||||||
|
await fetch(`${ORIGIN}/api/v1/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Origin: ORIGIN, Cookie: cookie, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cwd: tmp, command: 'bash' }),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
const sessionId = sess.session?.id;
|
||||||
|
check('session bash lancée', !!sessionId);
|
||||||
|
|
||||||
|
const chromeBin = findChromium();
|
||||||
|
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
|
||||||
|
if (!chromeBin || !sessionId) throw new Error('prérequis manquants');
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Presse-papier lisible/écrivable sans geste utilisateur (sinon readText() rejette en headless).
|
||||||
|
await client.send('Browser.grantPermissions', {
|
||||||
|
origin: ORIGIN,
|
||||||
|
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { sessionId: sid } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
await client.send('Page.enable', {}, sid);
|
||||||
|
await client.send('Runtime.enable', {}, sid);
|
||||||
|
await client.send('Network.enable', {}, sid);
|
||||||
|
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sid);
|
||||||
|
await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, sid);
|
||||||
|
|
||||||
|
const evaluate = async (expression, awaitPromise = false) =>
|
||||||
|
(await client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }, sid)).result?.value;
|
||||||
|
|
||||||
|
await client.send('Page.navigate', { url: `${ORIGIN}/sessions/${sessionId}` }, sid);
|
||||||
|
// attend que xterm soit monté ET que bash ait rendu son invite
|
||||||
|
let screen = null;
|
||||||
|
for (let i = 0; i < 80 && !screen; i++) {
|
||||||
|
await sleep(250);
|
||||||
|
screen = await evaluate(`(() => { const el = document.querySelector('.xterm-screen'); if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 50 ? JSON.stringify(r) : null; })()`);
|
||||||
|
}
|
||||||
|
check('terminal xterm monté', !!screen);
|
||||||
|
const rect = screen ? JSON.parse(screen) : null;
|
||||||
|
|
||||||
|
// Le renderer WebGL peint dans un canvas : `.xterm-rows` est vide, on ne peut RIEN vérifier via le
|
||||||
|
// DOM. Les preuves passent donc par le système de fichiers (le cwd de la session est `tmp`) et par
|
||||||
|
// le presse-papier réel du navigateur.
|
||||||
|
const focusTerm = () => client.send('Runtime.evaluate', { expression: `document.querySelector('.xterm-helper-textarea')?.focus()` }, sid);
|
||||||
|
const pressEnter = async () => {
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sid);
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sid);
|
||||||
|
};
|
||||||
|
const waitForFile = async (name, tries = 40) => {
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
if (existsSync(join(tmp, name))) return true;
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Frappe dans le PTY (Input.insertText → textarea xterm → stdin) ---
|
||||||
|
await focusTerm();
|
||||||
|
await client.send('Input.insertText', { text: 'touch typed-ok' }, sid);
|
||||||
|
await pressEnter();
|
||||||
|
check('le PTY exécute une commande tapée au clavier', await waitForFile('typed-ok'));
|
||||||
|
|
||||||
|
// Marqueur affiché à l'écran, cible de la copie
|
||||||
|
const MARKER = 'COPIE_MOI_4242';
|
||||||
|
await client.send('Input.insertText', { text: `echo ${MARKER}` }, sid);
|
||||||
|
await pressEnter();
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
// --- Sélection à la souris sur la zone du terminal, puis Ctrl+Shift+C ---
|
||||||
|
if (rect) {
|
||||||
|
const y = rect.y + 8;
|
||||||
|
await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x + 2, y, button: 'left', clickCount: 1, buttons: 1 }, sid);
|
||||||
|
await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + rect.width - 4, y: y + 40, button: 'left', buttons: 1 }, sid);
|
||||||
|
await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + rect.width - 4, y: y + 40, button: 'left', clickCount: 1, buttons: 0 }, sid);
|
||||||
|
}
|
||||||
|
await sleep(300);
|
||||||
|
const selection = await evaluate(`(() => { const s = document.querySelector('.xterm')?.classList; return document.getSelection()?.toString() ?? ''; })()`);
|
||||||
|
// ctrl(2) + shift(8) = 10
|
||||||
|
const keyOpts = { modifiers: 10, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'C', code: 'KeyC' };
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...keyOpts }, sid);
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...keyOpts }, sid);
|
||||||
|
await sleep(500);
|
||||||
|
const copied = (await evaluate('navigator.clipboard.readText()', true)) ?? '';
|
||||||
|
check('Ctrl+Shift+C copie la sélection du terminal', copied.includes(MARKER), JSON.stringify(copied.slice(0, 60)));
|
||||||
|
|
||||||
|
// --- Collage : presse-papier → Ctrl+Shift+V → la commande collée doit atteindre le PTY ---
|
||||||
|
await evaluate(`navigator.clipboard.writeText('touch paste-ok')`, true);
|
||||||
|
await focusTerm();
|
||||||
|
const vOpts = { modifiers: 10, windowsVirtualKeyCode: 86, nativeVirtualKeyCode: 86, key: 'V', code: 'KeyV' };
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...vOpts }, sid);
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...vOpts }, sid);
|
||||||
|
await sleep(400);
|
||||||
|
await pressEnter();
|
||||||
|
check('Ctrl+Shift+V colle le presse-papier dans le terminal', await waitForFile('paste-ok'));
|
||||||
|
|
||||||
|
// --- Ctrl+C ne doit PAS être détourné : il reste SIGINT ---
|
||||||
|
// `sleep 25` bloque le shell ; si le ^C passe, le shell reprend et exécute la commande suivante.
|
||||||
|
await focusTerm();
|
||||||
|
await client.send('Input.insertText', { text: 'sleep 25' }, sid);
|
||||||
|
await pressEnter();
|
||||||
|
await sleep(700);
|
||||||
|
const cOpts = { modifiers: 2, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'c', code: 'KeyC' };
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...cOpts }, sid);
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...cOpts }, sid);
|
||||||
|
await sleep(500);
|
||||||
|
await client.send('Input.insertText', { text: 'touch interrupt-ok' }, sid);
|
||||||
|
await pressEnter();
|
||||||
|
check('Ctrl+C reste transmis au PTY (SIGINT, pas une copie)', await waitForFile('interrupt-ok', 30));
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exécution du scénario', false, err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
browser?.kill('SIGKILL');
|
||||||
|
srv?.kill('SIGKILL');
|
||||||
|
await sleep(300);
|
||||||
|
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures === 0 ? '\nVERIFY CLIPBOARD: ALL GREEN' : `\nVERIFY CLIPBOARD: ${failures} ÉCHEC(S)`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Vérification E2E de la ZONE CENTRALE de l'IDE, par interaction réelle (pas des captures) : daemon
|
||||||
|
// temporaire isolé + Chromium headless piloté en CDP + cookie de session injecté. On clique dans
|
||||||
|
// l'arbre comme un utilisateur, puis on lit le DOM de Monaco.
|
||||||
|
//
|
||||||
|
// Ce que ce script prouve, et qu'aucune capture ne prouvait :
|
||||||
|
// (a) ouvrir un fichier alors qu'AUCUN onglet n'est persisté affiche réellement son contenu. C'est
|
||||||
|
// le cas nominal qui restait vide et muet : le conteneur de Monaco vivait sous un `v-if`, donc
|
||||||
|
// l'éditeur n'était jamais créé et aucun watcher ne retentait ;
|
||||||
|
// (b) fermer le dernier onglet puis réouvrir un fichier réaffiche le contenu (le conteneur ne doit
|
||||||
|
// pas être détruit, sinon l'éditeur pointe sur un noeud détaché) ;
|
||||||
|
// (c) sur mobile, toucher un fichier amène la zone centrale au premier plan (l'arbre et l'éditeur
|
||||||
|
// étant mutuellement exclusifs sous 768 px, un contenu visible EST la preuve de la bascule) ;
|
||||||
|
// (d) chunk de l'éditeur introuvable : un message et des actions de récupération, pas une zone vide ;
|
||||||
|
// (e) la bascule Fichiers / Changements liste bien les fichiers modifiés et permet de committer.
|
||||||
|
//
|
||||||
|
// Usage : node packages/server/scripts/verify-editor.mjs
|
||||||
|
// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs`.
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } 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 = 7412;
|
||||||
|
const CDP_PORT = 9335;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const MARKER = 'ARB_EDITOR_RENDERED_4242';
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
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-editor-'));
|
||||||
|
let srv = null;
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 fichier sonde EN PREMIÈRE LIGNE (Monaco virtualise le rendu) ---
|
||||||
|
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');
|
||||||
|
writeFileSync(join(repo, 'probe.ts'), `export const probe = '${MARKER}';\n`);
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'commit initial');
|
||||||
|
// du travail non commité, pour la vue Changements
|
||||||
|
writeFileSync(join(repo, 'dirty.txt'), 'travail en cours\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 sessionCookie = (login.headers.getSetCookie?.() ?? []).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 repoRes = await (await j('/api/v1/repos', 'POST', { path: repo })).json();
|
||||||
|
const repoId = repoRes.repo?.id;
|
||||||
|
const repoLabel = repoRes.repo?.label;
|
||||||
|
check('dépôt de démonstration enregistré', !!repoId, repoLabel ?? '');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// localStorage est partagé par les cibles d'un même profil : on l'efface pour chaque scénario.
|
||||||
|
// AUCUN onglet persisté : c'est précisément le cas qui restait vide. `arboretum.locale` n'est pas
|
||||||
|
// un persistedRef (chaîne brute, pas de JSON).
|
||||||
|
const seed = (extra = '') =>
|
||||||
|
`localStorage.clear();localStorage.setItem('arb.theme', '"dark"');localStorage.setItem('arboretum.locale', 'en');${extra}`;
|
||||||
|
|
||||||
|
/** Ouvre une cible isolée, authentifiée, avec un état de vue amorcé. */
|
||||||
|
async function openTarget({ width = 1440, height = 900, extraSeed = '', blocked = [] } = {}) {
|
||||||
|
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('Page.enable', {}, sessionId);
|
||||||
|
await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 500 }, sessionId);
|
||||||
|
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
|
||||||
|
if (blocked.length > 0) await client.send('Network.setBlockedURLs', { urls: blocked }, sessionId);
|
||||||
|
await client.send('Page.addScriptToEvaluateOnNewDocument', { source: seed(extraSeed) }, sessionId);
|
||||||
|
const before = client.events.length;
|
||||||
|
await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId);
|
||||||
|
return { targetId, sessionId, before };
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluate = async (expression, sessionId) =>
|
||||||
|
(await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value;
|
||||||
|
|
||||||
|
/** Attend qu'une condition devienne vraie (jamais de sleep fixe sur un rendu asynchrone). */
|
||||||
|
async function waitFor(fn, tries = 60, delay = 250) {
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
if (await fn()) return true;
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clic sur une ligne par son libellé exact : les libellés vivent dans un <span> à l'intérieur du
|
||||||
|
// <button> de la ligne (arbre de projets comme arbre de fichiers).
|
||||||
|
const clickRow = (label, sessionId) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const span = [...document.querySelectorAll('button span')].find((s) => s.textContent.trim() === ${JSON.stringify(label)});
|
||||||
|
const btn = span?.closest('button');
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monaco VIRTUALISE : seules les lignes visibles existent dans le DOM, et les espaces sortent en
|
||||||
|
// U+00A0. La sonde est donc en première ligne, et on normalise avant comparaison.
|
||||||
|
const editorText = async (sessionId) =>
|
||||||
|
(await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const lines = document.querySelector('.monaco-editor .view-lines');
|
||||||
|
return lines ? lines.textContent.replace(/\\u00a0/g, ' ') : '';
|
||||||
|
})()`,
|
||||||
|
sessionId,
|
||||||
|
)) ?? '';
|
||||||
|
|
||||||
|
const editorBox = async (sessionId) =>
|
||||||
|
JSON.parse(
|
||||||
|
(await evaluate(
|
||||||
|
`JSON.stringify((() => {
|
||||||
|
const el = document.querySelector('.monaco-editor');
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { w: Math.round(r.width), h: Math.round(r.height) };
|
||||||
|
})())`,
|
||||||
|
sessionId,
|
||||||
|
)) ?? 'null',
|
||||||
|
);
|
||||||
|
|
||||||
|
const describeError = (e) => {
|
||||||
|
const d = e.params?.exceptionDetails;
|
||||||
|
if (d) {
|
||||||
|
const ex = d.exception ?? {};
|
||||||
|
const where = d.url ? ` @${d.url}:${d.lineNumber ?? '?'}` : '';
|
||||||
|
return `${d.text ?? ''} ${ex.description ?? ex.value ?? ''}${where}`.trim();
|
||||||
|
}
|
||||||
|
return (e.params?.args ?? []).map((a) => a.description ?? a.value ?? a.type).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const consoleErrors = (sessionId, before) =>
|
||||||
|
client.events
|
||||||
|
.slice(before)
|
||||||
|
.filter((e) => e.sessionId === sessionId)
|
||||||
|
.filter((e) => (e.method === 'Runtime.consoleAPICalled' && e.params?.type === 'error') || e.method === 'Runtime.exceptionThrown')
|
||||||
|
.map(describeError)
|
||||||
|
.filter((m) => m && !/favicon|manifest\.webmanifest/i.test(m));
|
||||||
|
|
||||||
|
/** Déroule l'arbre jusqu'au fichier sonde : dépôt, puis branche, puis fichier. */
|
||||||
|
async function openProbeFile(sessionId) {
|
||||||
|
if (!(await waitFor(() => clickRow(repoLabel, sessionId)))) return false;
|
||||||
|
if (!(await waitFor(() => clickRow('main', sessionId)))) return false;
|
||||||
|
return waitFor(() => clickRow('probe.ts', sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (a) desktop, aucun onglet persisté : le cas nominal qui restait vide ---
|
||||||
|
{
|
||||||
|
const { targetId, sessionId, before } = await openTarget();
|
||||||
|
const treeReady = await waitFor(async () => !!(await clickRow(repoLabel, sessionId)));
|
||||||
|
check('a · arbre de projets rendu', treeReady);
|
||||||
|
// L'éditeur ne doit pas exister avant qu'un fichier soit demandé (chargement paresseux préservé).
|
||||||
|
check('a · Monaco non chargé avant ouverture d un fichier', (await editorBox(sessionId)) === null);
|
||||||
|
|
||||||
|
check('a · navigation jusqu au fichier', await waitFor(() => clickRow('main', sessionId)) && (await waitFor(() => clickRow('probe.ts', sessionId))));
|
||||||
|
const shown = await waitFor(async () => (await editorText(sessionId)).includes(MARKER));
|
||||||
|
check('a · le contenu du fichier est réellement rendu', shown, shown ? '' : await editorText(sessionId));
|
||||||
|
const box = await editorBox(sessionId);
|
||||||
|
check('a · l éditeur occupe une surface réelle', !!box && box.w > 200 && box.h > 100, JSON.stringify(box));
|
||||||
|
check('a · la gouttière de numéros est rendue', !!(await evaluate("!!document.querySelector('.margin-view-overlays .line-numbers')", sessionId)));
|
||||||
|
const errs = consoleErrors(sessionId, before);
|
||||||
|
check('a · aucune erreur console', errs.length === 0, errs.slice(0, 3).join(' | '));
|
||||||
|
await client.send('Target.closeTarget', { targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (b) fermer le dernier onglet, puis réouvrir : le conteneur ne doit pas avoir été détruit ---
|
||||||
|
{
|
||||||
|
const { targetId, sessionId, before } = await openTarget();
|
||||||
|
check('b · fichier ouvert une première fois', (await openProbeFile(sessionId)) && (await waitFor(async () => (await editorText(sessionId)).includes(MARKER))));
|
||||||
|
|
||||||
|
const closed = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const tab = [...document.querySelectorAll('[title="probe.ts"]')].find((e) => e.querySelector('button'));
|
||||||
|
const btn = tab?.querySelector('button');
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
check('b · dernier onglet fermé', !!closed);
|
||||||
|
const tabsLeft = await evaluate("JSON.parse(localStorage.getItem('arb.ide.tabs') ?? '[]').length", sessionId);
|
||||||
|
check('b · plus aucun onglet', tabsLeft === 0, `restants: ${tabsLeft}`);
|
||||||
|
// Le point à prouver : le CONTENEUR survit (v-show, pas v-if). Sans modèle, Monaco détruit sa
|
||||||
|
// vue de lui-même (`.monaco-editor` disparaît) et la recrée au prochain setModel : c'est normal.
|
||||||
|
// Ce qui ne doit jamais disparaître, c'est le conteneur, sinon l'instance garde une référence sur
|
||||||
|
// un noeud détaché et la zone reste définitivement vide.
|
||||||
|
const hostAlive = await waitFor(
|
||||||
|
async () => (await evaluate("!!document.querySelector('[data-editor-host]')", sessionId)) === true,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
check('b · le conteneur de l éditeur survit à la fermeture', hostAlive);
|
||||||
|
check(
|
||||||
|
'b · état vide affiché',
|
||||||
|
await waitFor(async () => String(await evaluate('document.body.innerText', sessionId)).includes('No file open'), 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
check('b · fichier réouvert', await waitFor(() => clickRow('probe.ts', sessionId)));
|
||||||
|
check('b · le contenu revient après réouverture', await waitFor(async () => (await editorText(sessionId)).includes(MARKER)));
|
||||||
|
const errs = consoleErrors(sessionId, before);
|
||||||
|
check('b · aucune erreur console', errs.length === 0, errs.slice(0, 3).join(' | '));
|
||||||
|
await client.send('Target.closeTarget', { targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (c) mobile : l'arbre et l'éditeur sont mutuellement exclusifs, donc un contenu visible
|
||||||
|
// prouve que l'ouverture d'un fichier a bien amené la zone centrale au premier plan ---
|
||||||
|
{
|
||||||
|
const { targetId, sessionId } = await openTarget({ width: 390, height: 844 });
|
||||||
|
check('c · fichier ouvert depuis l explorateur mobile', await openProbeFile(sessionId));
|
||||||
|
check('c · le contenu s affiche (le panneau a basculé)', await waitFor(async () => (await editorText(sessionId)).includes(MARKER)));
|
||||||
|
const treeGone = await evaluate(
|
||||||
|
`![...document.querySelectorAll('button span')].some((s) => s.textContent.trim() === 'probe.ts')`,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
check('c · l explorateur a laissé la place à l éditeur', !!treeGone);
|
||||||
|
await client.send('Target.closeTarget', { targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (d) chunk de l'éditeur introuvable : message + actions, puis récupération au rechargement ---
|
||||||
|
// On bloque UNIQUEMENT le module d'import dynamique. Surtout pas *vendor-monaco* : le chunk
|
||||||
|
// d'entrée l'importe statiquement, le bloquer tuerait toute la SPA.
|
||||||
|
{
|
||||||
|
const { targetId, sessionId } = await openTarget({ blocked: ['*monaco-setup*'] });
|
||||||
|
check('d · fichier demandé malgré le chunk bloqué', await openProbeFile(sessionId));
|
||||||
|
const said = await waitFor(async () =>
|
||||||
|
String(await evaluate('document.body.innerText', sessionId)).includes('The code editor could not be loaded'),
|
||||||
|
);
|
||||||
|
check('d · l échec est annoncé au lieu d une zone vide', said);
|
||||||
|
const actions = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const labels = [...document.querySelectorAll('button')].map((b) => b.textContent.trim());
|
||||||
|
return labels.includes('Retry') && labels.includes('Reload page');
|
||||||
|
})()`,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
check('d · des actions de récupération sont proposées', !!actions);
|
||||||
|
|
||||||
|
await client.send('Network.setBlockedURLs', { urls: [] }, sessionId);
|
||||||
|
await client.send('Page.reload', {}, sessionId);
|
||||||
|
check('d · après rechargement, le fichier s ouvre', (await openProbeFile(sessionId)) && (await waitFor(async () => (await editorText(sessionId)).includes(MARKER))));
|
||||||
|
await client.send('Target.closeTarget', { targetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (e) bascule Fichiers / Changements : la liste des fichiers non commités et le commit ---
|
||||||
|
{
|
||||||
|
const { targetId, sessionId, before } = await openTarget({
|
||||||
|
extraSeed:
|
||||||
|
`localStorage.setItem('arb.ide.centerMode', '"changes"');` +
|
||||||
|
`localStorage.setItem('arb.ide.expandedRepos', ${JSON.stringify(JSON.stringify([repoId]))});` +
|
||||||
|
`localStorage.setItem('arb.ide.changesExpanded', ${JSON.stringify(JSON.stringify([`${repoId}\0${repo}`]))});`,
|
||||||
|
});
|
||||||
|
const listed = await waitFor(async () => String(await evaluate('document.body.innerText', sessionId)).includes('dirty.txt'));
|
||||||
|
check('e · la vue Changements liste le fichier non commité', listed);
|
||||||
|
const hasCommit = await evaluate(
|
||||||
|
`[...document.querySelectorAll('button')].some((b) => b.textContent.trim().startsWith('Commit'))`,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
check('e · le panneau de commit est présent dans le bloc', !!hasCommit);
|
||||||
|
// La bascule de mode vit dans la barre d'onglets, donc elle est là même sans aucun onglet ouvert.
|
||||||
|
const switched = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const btn = [...document.querySelectorAll('button[title]')].find((b) => b.getAttribute('title') === 'Files');
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
check('e · la bascule Fichiers est accessible sans onglet ouvert', !!switched);
|
||||||
|
check(
|
||||||
|
'e · retour au mode Fichiers',
|
||||||
|
await waitFor(async () => String(await evaluate('document.body.innerText', sessionId)).includes('No file open')),
|
||||||
|
);
|
||||||
|
const errs = consoleErrors(sessionId, before);
|
||||||
|
check('e · aucune erreur console', errs.length === 0, errs.slice(0, 3).join(' | '));
|
||||||
|
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 EDITOR: ALL GREEN' : `\nVERIFY EDITOR: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Vérification E2E du DOCK TERMINAUX par interaction réelle : daemon temporaire isolé + Chromium
|
||||||
|
// headless piloté en CDP + cookie de session injecté. On clique comme un utilisateur, puis on lit le
|
||||||
|
// DOM et l'écran d'xterm.
|
||||||
|
//
|
||||||
|
// Ce que ce script prouve, et qu'aucune capture ne prouvait :
|
||||||
|
// (a) un terminal attaché AFFICHE la sortie de son PTY (le bug « écran tout noir » venait du replay
|
||||||
|
// émis avant `attached`, donc jeté par le client : ici on lit le texte réellement peint) ;
|
||||||
|
// (b) deux terminaux tiennent côte à côte, chacun dans sa colonne, tous les deux visibles ;
|
||||||
|
// (c) la frappe va au terminal de la colonne ACTIVE, et suit le changement de colonne ;
|
||||||
|
// (d) fermer une colonne rend sa place à l'autre, et le terminal restant continue de fonctionner ;
|
||||||
|
// (e) la vue Changements suit le terminal focalisé (portée), et la bascule « tout voir » la libère.
|
||||||
|
//
|
||||||
|
// Sessions `bash` uniquement : aucun quota Claude consommé.
|
||||||
|
// Usage : node packages/server/scripts/verify-terminals.mjs
|
||||||
|
// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs`.
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } 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 = 7413;
|
||||||
|
const CDP_PORT = 9336;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Répertoire de captures optionnel : `node scripts/verify-terminals.mjs [out]`. */
|
||||||
|
const shotDir = process.argv[2] ?? null;
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-term-'));
|
||||||
|
let srv = null;
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
check('SPA copiée dans packages/server/public', existsSync(join(serverDir, 'public', 'index.html')));
|
||||||
|
|
||||||
|
// Deux dépôts : le second sert à prouver que la portée git suit bien le terminal focalisé.
|
||||||
|
const repos = [];
|
||||||
|
for (const name of ['alpha', 'beta']) {
|
||||||
|
const dir = join(tmp, name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||||
|
git('init', '-b', 'main');
|
||||||
|
git('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
git('config', 'user.name', 'Test');
|
||||||
|
writeFileSync(join(dir, 'README.md'), `# ${name}\n`);
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'commit initial');
|
||||||
|
writeFileSync(join(dir, `wip-${name}.txt`), 'travail en cours\n');
|
||||||
|
repos.push({ name, dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sessionCookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
|
||||||
|
const cookieValue = sessionCookie?.slice('arb_session='.length) ?? '';
|
||||||
|
check('login → cookie de session', !!sessionCookie);
|
||||||
|
|
||||||
|
const j = (path, method, body) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of repos) {
|
||||||
|
const res = await (await j('/api/v1/repos', 'POST', { path: r.dir })).json();
|
||||||
|
r.id = res.repo?.id;
|
||||||
|
}
|
||||||
|
check('deux dépôts enregistrés', repos.every((r) => !!r.id));
|
||||||
|
|
||||||
|
// Une session bash par dépôt : ce sont elles qui peupleront les deux colonnes.
|
||||||
|
for (const r of repos) {
|
||||||
|
const res = await (await j('/api/v1/sessions', 'POST', { cwd: r.dir, command: 'bash' })).json();
|
||||||
|
r.sessionId = res.session?.id;
|
||||||
|
}
|
||||||
|
check('deux sessions bash lancées', repos.every((r) => !!r.sessionId));
|
||||||
|
|
||||||
|
const chromeBin = findChromium();
|
||||||
|
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
|
||||||
|
if (!chromeBin) throw new Error('Chromium introuvable');
|
||||||
|
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',
|
||||||
|
// xterm peint sur un canvas WebGL quand il peut : le texte n'est alors PAS dans le DOM et
|
||||||
|
// aucun test ne peut le lire. On force le renderer DOM ; le chemin vérifié (transport → xterm)
|
||||||
|
// est le même, seule la peinture change.
|
||||||
|
'--disable-webgl',
|
||||||
|
'--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;
|
||||||
|
|
||||||
|
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('Page.enable', {}, sessionId);
|
||||||
|
await client.send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 950, deviceScaleFactor: 1, mobile: false }, sessionId);
|
||||||
|
await client.send('Network.enable', {}, sessionId);
|
||||||
|
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
|
||||||
|
// Aucun état de vue persisté : le dock part vide, comme au premier lancement.
|
||||||
|
// Amorçage AVANT navigation (sinon le store lit un localStorage encore vide) : panneau Sessions à
|
||||||
|
// gauche pour ouvrir les terminaux, et zone centrale en mode Changements pour observer la portée.
|
||||||
|
await client.send(
|
||||||
|
'Page.addScriptToEvaluateOnNewDocument',
|
||||||
|
{
|
||||||
|
source: [
|
||||||
|
"localStorage.clear();",
|
||||||
|
`localStorage.setItem('arb.theme', '"dark"');`,
|
||||||
|
"localStorage.setItem('arboretum.locale', 'en');",
|
||||||
|
`localStorage.setItem('arb.ide.activity', '"sessions"');`,
|
||||||
|
`localStorage.setItem('arb.ide.centerMode', '"changes"');`,
|
||||||
|
"localStorage.setItem('arb.ide.leftVisible', 'true');",
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
const consoleBefore = client.events.length;
|
||||||
|
await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId);
|
||||||
|
|
||||||
|
const evaluate = async (expression) => (await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value;
|
||||||
|
async function waitFor(fn, tries = 60, delay = 250) {
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
if (await fn()) return true;
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Texte réellement PEINT par une instance xterm (index de colonne dans le dock). */
|
||||||
|
const screenText = (n) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const rows = [...document.querySelectorAll('.xterm-rows')];
|
||||||
|
const el = rows[${n}] ?? [...document.querySelectorAll('.xterm-screen')][${n}];
|
||||||
|
return el ? el.innerText.replace(/\\u00a0/g, ' ') : null;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
const columnCount = () => evaluate(`document.querySelectorAll('.xterm-screen').length`);
|
||||||
|
const clickRow = (label) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const span = [...document.querySelectorAll('button span')].find((s) => s.textContent.trim() === ${JSON.stringify(label)});
|
||||||
|
const btn = span?.closest('button');
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
const clickTitled = (title, nth = 0) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const btns = [...document.querySelectorAll('[title=' + JSON.stringify(${JSON.stringify(title)}) + ']')];
|
||||||
|
const btn = btns[${nth}];
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* Clic NATIF au centre d'une colonne : c'est le seul moyen de donner le focus au textarea caché
|
||||||
|
* d'xterm (un PointerEvent synthétique ne déplace pas le focus du navigateur).
|
||||||
|
*/
|
||||||
|
async function clickColumn(n) {
|
||||||
|
const box = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const el = [...document.querySelectorAll('.xterm-screen')][${n}];
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!box) return false;
|
||||||
|
for (const type of ['mousePressed', 'mouseReleased']) {
|
||||||
|
await client.send('Input.dispatchMouseEvent', { type, x: box.x, y: box.y, button: 'left', clickCount: 1 }, sessionId);
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** Frappe réelle : insertText va à l'élément focalisé (le textarea d'xterm). */
|
||||||
|
const type = (text) => client.send('Input.insertText', { text }, sessionId);
|
||||||
|
const pressEnter = async () => {
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sessionId);
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
check('SPA chargée sur /ide', await waitFor(async () => (await evaluate(`!!document.querySelector('[aria-label], nav, main')`)) === true));
|
||||||
|
|
||||||
|
// --- (a) un terminal affiche la sortie de son PTY ---
|
||||||
|
/** Clique la ligne de session du panneau Sessions correspondant à un dépôt. `alt` = ouvrir à côté. */
|
||||||
|
const clickSessionRow = (repoName, alt = false) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const rows = [...document.querySelectorAll('aside button, div button')].filter(
|
||||||
|
(b) => b.textContent.includes(${JSON.stringify(repoName)}) && b.querySelector('span'),
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return false;
|
||||||
|
row.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: ${alt} }));
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listed = await waitFor(async () => (await evaluate(`document.body.innerText.includes('alpha') && document.body.innerText.includes('beta')`)) === true, 40);
|
||||||
|
check('les deux sessions sont listées dans le panneau', listed);
|
||||||
|
|
||||||
|
const openedFirst = await waitFor(async () => {
|
||||||
|
await clickSessionRow('alpha');
|
||||||
|
return (await columnCount()) >= 1;
|
||||||
|
}, 30);
|
||||||
|
check('un terminal s’ouvre dans le dock', openedFirst, `colonnes: ${await columnCount()}`);
|
||||||
|
|
||||||
|
await clickColumn(0);
|
||||||
|
check('le terminal prend le focus clavier', (await evaluate(`document.activeElement?.tagName?.toLowerCase() ?? ''`)) === 'textarea');
|
||||||
|
// Deux preuves complémentaires : le PTY a bien reçu la frappe (fichier créé dans SON cwd), et sa
|
||||||
|
// sortie est réellement peinte à l'écran (c'était précisément ce qui manquait : un écran noir).
|
||||||
|
await type('touch recu-A && echo MARQUEUR-COLONNE-A');
|
||||||
|
await pressEnter();
|
||||||
|
const gotA = await waitFor(() => existsSync(join(repos[0].dir, 'recu-A')), 40);
|
||||||
|
check('(a) la frappe atteint le PTY du terminal', gotA);
|
||||||
|
const paintedA = await waitFor(async () => ((await screenText(0)) ?? '').includes('MARQUEUR-COLONNE-A'), 40);
|
||||||
|
check('(a) le terminal PEINT la sortie de son PTY (fin de l’écran noir)', paintedA, ((await screenText(0)) ?? '').replace(/\s+/g, ' ').slice(-70));
|
||||||
|
|
||||||
|
// --- (b) deux terminaux côte à côte ---
|
||||||
|
const splitDone = await waitFor(async () => {
|
||||||
|
await clickSessionRow('beta', true); // Alt+clic = ouvrir à côté
|
||||||
|
return (await columnCount()) === 2;
|
||||||
|
}, 30);
|
||||||
|
check('(b) deux colonnes de terminaux visibles simultanément', splitDone, `colonnes: ${await columnCount()}`);
|
||||||
|
|
||||||
|
const bothVisible = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const screens = [...document.querySelectorAll('.xterm-screen')];
|
||||||
|
if (screens.length !== 2) return false;
|
||||||
|
return screens.every((s) => { const r = s.getBoundingClientRect(); return r.width > 50 && r.height > 20; });
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
check('(b) les deux colonnes ont une surface réelle', bothVisible === true);
|
||||||
|
|
||||||
|
const sideBySide = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const [a, b] = [...document.querySelectorAll('.xterm-screen')].map((s) => s.getBoundingClientRect());
|
||||||
|
return !!a && !!b && Math.abs(a.top - b.top) < 40 && Math.abs(a.left - b.left) > 100;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
check('(b) elles sont côte à côte (et non empilées)', sideBySide === true);
|
||||||
|
|
||||||
|
// Capture optionnelle (argument 1) : preuve visuelle des deux colonnes, utile en revue.
|
||||||
|
if (shotDir) {
|
||||||
|
mkdirSync(shotDir, { recursive: true });
|
||||||
|
const shot = await client.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||||||
|
const file = join(shotDir, 'terminal-columns.png');
|
||||||
|
writeFileSync(file, Buffer.from(shot.data, 'base64'));
|
||||||
|
check('capture des deux colonnes écrite', existsSync(file), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (c) la frappe va à la colonne active (la seconde vient d'être créée) ---
|
||||||
|
await type('touch recu-B && echo MARQUEUR-COLONNE-B');
|
||||||
|
await pressEnter();
|
||||||
|
const gotB = await waitFor(() => existsSync(join(repos[1].dir, 'recu-B')), 40);
|
||||||
|
check('(c) la frappe va au PTY de la colonne active (nouvelle colonne focalisée)', gotB);
|
||||||
|
check('(c) elle ne fuit pas dans l’autre PTY', !existsSync(join(repos[0].dir, 'recu-B')));
|
||||||
|
const paintedB = await waitFor(async () => ((await screenText(1)) ?? '').includes('MARQUEUR-COLONNE-B'), 40);
|
||||||
|
check('(c) la seconde colonne peint aussi sa sortie', paintedB);
|
||||||
|
|
||||||
|
// Retour sur la première colonne : un clic dedans doit lui rendre la frappe.
|
||||||
|
await clickColumn(0);
|
||||||
|
await type('touch retour-A');
|
||||||
|
await pressEnter();
|
||||||
|
const backToA = await waitFor(() => existsSync(join(repos[0].dir, 'retour-A')), 40);
|
||||||
|
check('(c) le focus suit le clic sur une colonne', backToA);
|
||||||
|
|
||||||
|
// --- (e) la portée git suit le terminal focalisé (zone centrale en mode Changements) ---
|
||||||
|
// La colonne active est celle d'alpha (on vient d'y revenir) : seul son fichier modifié doit être
|
||||||
|
// listé, celui de beta doit disparaître de la vue.
|
||||||
|
const centerText = () => evaluate(`document.querySelector('main')?.innerText ?? ''`);
|
||||||
|
const scopedToAlpha = await waitFor(async () => {
|
||||||
|
const txt = await centerText();
|
||||||
|
return txt.includes('wip-alpha.txt') && !txt.includes('wip-beta.txt');
|
||||||
|
}, 40);
|
||||||
|
check('(e) la vue Changements ne montre que le projet du terminal focalisé', scopedToAlpha, (await centerText()).replace(/\s+/g, ' ').slice(0, 90));
|
||||||
|
|
||||||
|
// Focaliser la colonne de beta doit faire suivre la vue.
|
||||||
|
await clickColumn(1);
|
||||||
|
const followsBeta = await waitFor(async () => {
|
||||||
|
const txt = await centerText();
|
||||||
|
return txt.includes('wip-beta.txt') && !txt.includes('wip-alpha.txt');
|
||||||
|
}, 40);
|
||||||
|
check('(e) changer de colonne fait suivre la vue Changements', followsBeta, (await centerText()).replace(/\s+/g, ' ').slice(0, 90));
|
||||||
|
|
||||||
|
// La bascule « tout voir » doit libérer la portée.
|
||||||
|
await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const btn = [...document.querySelectorAll('button')].find((b) => /Show every project|Follow the focused terminal/.test(b.getAttribute('title') ?? ''));
|
||||||
|
btn?.click();
|
||||||
|
return !!btn;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
// Un bloc replié ne liste pas ses fichiers : la preuve d'élargissement, ce sont les deux dépôts.
|
||||||
|
const seesBoth = await waitFor(async () => {
|
||||||
|
const txt = await centerText();
|
||||||
|
return txt.includes('alpha') && txt.includes('beta');
|
||||||
|
}, 40);
|
||||||
|
check('(e) la bascule « tout voir » libère la portée', seesBoth);
|
||||||
|
|
||||||
|
// --- (d) fermer une colonne ---
|
||||||
|
const closed = await waitFor(async () => {
|
||||||
|
await clickTitled('Close', 0);
|
||||||
|
return (await columnCount()) <= 1;
|
||||||
|
}, 20);
|
||||||
|
check('(d) fermer un terminal libère sa colonne', closed, `colonnes: ${await columnCount()}`);
|
||||||
|
|
||||||
|
const consoleErrors = client.events
|
||||||
|
.slice(consoleBefore)
|
||||||
|
.filter((e) => e.method === 'Log.entryAdded' && e.params?.entry?.level === 'error')
|
||||||
|
.map((e) => e.params.entry.text)
|
||||||
|
// Les 404 de favicon et les avertissements de chunk ne concernent pas le dock.
|
||||||
|
.filter((t) => !/favicon|manifest/i.test(t));
|
||||||
|
check('aucune erreur console', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err));
|
||||||
|
} finally {
|
||||||
|
browser?.kill('SIGKILL');
|
||||||
|
srv?.kill('SIGTERM');
|
||||||
|
await sleep(1200);
|
||||||
|
srv?.kill('SIGKILL');
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(failed.length === 0 ? '\nVERIFY TERMINALS: ALL GREEN' : `\nVERIFY TERMINALS: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/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);
|
||||||
|
// Un fichier indexé et un non indexé dans le checkout principal : la vue Changements doit montrer
|
||||||
|
// ses DEUX sections (« Staged » et « Changes »), sinon la capture ne prouve que la moitié.
|
||||||
|
writeFileSync(join(repo, 'notes.md'), 'brouillon\n');
|
||||||
|
const staged = await j(`/api/v1/repos/${repoId}/worktrees/stage`, 'POST', { path: repo, files: ['src/app.ts'] });
|
||||||
|
check('un fichier indexé pour la démonstration', staged.ok);
|
||||||
|
|
||||||
|
// --- 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});`;
|
||||||
|
// Index Git de la sidebar : compteurs par projet, sans liste de fichiers (celle-ci vit au centre).
|
||||||
|
const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');`;
|
||||||
|
// Vue Changements de la zone centrale : blocs dépliés du checkout principal ET du worktree de
|
||||||
|
// feature, plus l'historique du premier (sa clé de repli est désormais PAR worktree).
|
||||||
|
const changesKeys = JSON.stringify(
|
||||||
|
JSON.stringify([`${repoId}\0${repo}`, ...(wtRes.worktree?.path ? [`${repoId}\0${wtRes.worktree.path}`] : [])]),
|
||||||
|
);
|
||||||
|
const seedChanges =
|
||||||
|
`${seedExplorer}localStorage.setItem('arb.ide.centerMode', '"changes"');` +
|
||||||
|
`localStorage.setItem('arb.ide.changesExpanded', ${changesKeys});` +
|
||||||
|
`localStorage.setItem(${JSON.stringify(`arb.history.open:${repoId}\0${repo}`)}, 'true');`;
|
||||||
|
const seedChangesMobile = `${seedChanges}localStorage.setItem('arb.ide.mobilePanel', '"changes"');`;
|
||||||
|
// Zone d'édition : un onglet persisté, jamais capturé jusqu'ici.
|
||||||
|
const editorTabId = `${repoId}\0${repo}\0src/app.ts`;
|
||||||
|
const seedEditor =
|
||||||
|
`${seedExplorer}localStorage.setItem('arb.ide.tabs', ${JSON.stringify(
|
||||||
|
JSON.stringify([{ id: editorTabId, repoId, wtPath: repo, file: 'src/app.ts', view: 'editor' }]),
|
||||||
|
)});localStorage.setItem('arb.ide.activeTab', ${JSON.stringify(JSON.stringify(editorTabId))});`;
|
||||||
|
// Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de
|
||||||
|
// fichiers des membres du groupe, sinon jamais capturée.
|
||||||
|
const seedGroups =
|
||||||
|
`${seedExplorer}localStorage.setItem('arb.ide.activity', '"groups"');` +
|
||||||
|
`localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` +
|
||||||
|
`localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`;
|
||||||
|
|
||||||
|
// `expect` : une capture non vide ne prouve pas que le bon composant s'est rendu. On exige donc un
|
||||||
|
// fragment de texte propre à la surface visée.
|
||||||
|
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, expect: 'feature/demo' },
|
||||||
|
{ name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' },
|
||||||
|
{ name: 'changes-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' },
|
||||||
|
{ name: 'changes-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' },
|
||||||
|
{ name: 'changes-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedChangesMobile, expect: 'wip.txt' },
|
||||||
|
{ name: 'editor-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedEditor, expect: 'src/app.ts' },
|
||||||
|
{ name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups },
|
||||||
|
{ 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);
|
||||||
|
// `localStorage.clear()` d'abord : les cibles d'un même profil Chromium le PARTAGENT, donc sans
|
||||||
|
// ça une capture héritait de l'état de vue de la précédente (une capture de la zone d'édition
|
||||||
|
// montrait la vue Changements laissée par celle d'avant).
|
||||||
|
await client.send(
|
||||||
|
'Page.addScriptToEvaluateOnNewDocument',
|
||||||
|
{ source: `localStorage.clear();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`);
|
||||||
|
if (shot.expect) check(`${shot.name} : contenu attendu`, rendered.includes(shot.expect), shot.expect);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -64,6 +64,22 @@ const SECURITY_HEADERS: Record<string, string> = {
|
|||||||
].join('; '),
|
].join('; '),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Politique de cache du statique. Piège à connaître : `npm pack` normalise le mtime de TOUS les
|
||||||
|
// fichiers du tarball à une date constante (1985-10-26). L'etag faible de @fastify/static étant
|
||||||
|
// dérivé de taille+mtime, deux versions différentes d'un même fichier non haché produisent le
|
||||||
|
// MÊME etag dès que leur taille coïncide : le client reçoit un 304 et garde indéfiniment
|
||||||
|
// l'ancienne copie. Vécu en production sur index.html à la mise à jour de l'app desktop : l'index
|
||||||
|
// obsolète référençait des `/assets/<hash>.js` disparus, le fallback SPA répondait du text/html
|
||||||
|
// pour ces modules, et la page restait noire.
|
||||||
|
// Conséquence : seuls les fichiers dont le NOM porte un hash de contenu (/assets/) sont
|
||||||
|
// cachables ; tout le reste (index.html, sw.js, theme-boot.js, manifest, icônes) part en
|
||||||
|
// no-store, la revalidation par etag n'étant pas fiable ici.
|
||||||
|
export function cacheControlFor(pathname: string): string {
|
||||||
|
return pathname.startsWith('/assets/')
|
||||||
|
? 'public, max-age=31536000, immutable'
|
||||||
|
: 'no-store';
|
||||||
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
authContext: AuthContext | null;
|
authContext: AuthContext | null;
|
||||||
@@ -183,7 +199,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)
|
if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login)
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && !allowedOrigins.has(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);
|
req.authContext = authenticate(req);
|
||||||
if (req.routeOptions.config.public) return;
|
if (req.routeOptions.config.public) return;
|
||||||
@@ -195,7 +216,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||||
registerSessionRoutes(app, manager, discovery, sessionArchive, db);
|
registerSessionRoutes(app, manager, discovery, sessionArchive, db);
|
||||||
registerProjectRoutes(app, manager, db);
|
registerProjectRoutes(app, manager, db);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db, manager);
|
||||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees, db);
|
registerWorktreeRoutes(app, worktrees, db);
|
||||||
registerGitRoutes(app, worktrees, db);
|
registerGitRoutes(app, worktrees, db);
|
||||||
@@ -215,7 +236,25 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
// SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front)
|
// SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front)
|
||||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||||
if (existsSync(publicDir)) {
|
if (existsSync(publicDir)) {
|
||||||
void app.register(fastifyStatic, { root: publicDir, wildcard: false });
|
void app.register(fastifyStatic, {
|
||||||
|
root: publicDir,
|
||||||
|
wildcard: false,
|
||||||
|
// Validation conditionnelle désactivée : l'etag faible et le Last-Modified dérivent du mtime,
|
||||||
|
// que `npm pack` fige (cf. cacheControlFor). Les laisser actifs ferait répondre 304 aux
|
||||||
|
// clients qui détiennent encore un index.html périmé d'une version antérieure : ils y
|
||||||
|
// resteraient bloqués. Sans etag, ils reçoivent un 200 et se réparent d'eux-mêmes. Le coût
|
||||||
|
// est nul pour /assets (noms hachés, servis immutable) et négligeable ailleurs.
|
||||||
|
etag: false,
|
||||||
|
lastModified: false,
|
||||||
|
// Indispensable : sinon le plugin écrit son propre `cache-control: public, max-age=0`
|
||||||
|
// APRÈS setHeaders et écrase le no-store ci-dessous.
|
||||||
|
cacheControl: false,
|
||||||
|
// `setHeaders` s'applique aussi aux `reply.sendFile` du fallback SPA ci-dessous.
|
||||||
|
setHeaders(res, path) {
|
||||||
|
const rel = path.slice(publicDir.length).replace(/\\/g, '/');
|
||||||
|
res.setHeader('Cache-Control', cacheControlFor(rel));
|
||||||
|
},
|
||||||
|
});
|
||||||
app.setNotFoundHandler((req, reply) => {
|
app.setNotFoundHandler((req, reply) => {
|
||||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { AuthService } from '../auth/service.js';
|
|||||||
const SERVICE_NAME = 'arboretum';
|
const SERVICE_NAME = 'arboretum';
|
||||||
const LAUNCHD_LABEL = 'fr.lidge.arboretum';
|
const LAUNCHD_LABEL = 'fr.lidge.arboretum';
|
||||||
|
|
||||||
export type SupportedPlatform = 'linux' | 'darwin';
|
export type SupportedPlatform = 'linux' | 'darwin' | 'win32';
|
||||||
|
|
||||||
export interface InstallFlags {
|
export interface InstallFlags {
|
||||||
port?: string | undefined;
|
port?: string | undefined;
|
||||||
@@ -29,15 +29,31 @@ export interface InstallFlags {
|
|||||||
|
|
||||||
// ─── Fonctions pures (génération de contenu / chemins) ────────────────────────────────
|
// ─── 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 {
|
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(
|
throw new Error(
|
||||||
`Automatic service installation is supported on Linux (systemd) and macOS (launchd) only.\n` +
|
`Automatic service installation is supported on Linux (systemd), macOS (launchd) and Windows ` +
|
||||||
`On ${platform}, run \`arboretum\` manually or set up your own supervisor.`,
|
`(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 {
|
export function parseInstallArgs(argv: string[]): InstallFlags {
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
args: argv,
|
args: argv,
|
||||||
@@ -207,7 +223,8 @@ export function printUsage(version: string): void {
|
|||||||
Usage:
|
Usage:
|
||||||
arboretum [flags] Start the daemon (default)
|
arboretum [flags] Start the daemon (default)
|
||||||
arboretum serve [flags] Start the daemon (explicit alias)
|
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 uninstall Stop & remove the user service
|
||||||
arboretum status Show the service status
|
arboretum status Show the service status
|
||||||
arboretum help Show this help
|
arboretum help Show this help
|
||||||
@@ -218,6 +235,9 @@ Daemon flags:
|
|||||||
--allow-origin <url> Additional allowed Origin (repeatable)
|
--allow-origin <url> Additional allowed Origin (repeatable)
|
||||||
--db <path> SQLite database path
|
--db <path> SQLite database path
|
||||||
--vapid-contact <mailto|url> VAPID contact subject for Web Push
|
--vapid-contact <mailto|url> VAPID contact subject for Web Push
|
||||||
|
--claude-home <path> 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)
|
--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):
|
Install flags (daemon flags above are propagated to the service):
|
||||||
@@ -301,6 +321,23 @@ export async function runInstall(argv: string[]): Promise<void> {
|
|||||||
return;
|
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)
|
// macOS (launchd)
|
||||||
const logs = launchdLogPaths();
|
const logs = launchdLogPaths();
|
||||||
const programArguments = [exec, ...scriptArgs];
|
const programArguments = [exec, ...scriptArgs];
|
||||||
@@ -352,6 +389,12 @@ export async function runUninstall(argv: string[]): Promise<void> {
|
|||||||
console.log('Arboretum service removed.');
|
console.log('Arboretum service removed.');
|
||||||
return;
|
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 plistPath = launchAgentPlistPath(flags.label);
|
||||||
const uid = process.getuid?.() ?? 0;
|
const uid = process.getuid?.() ?? 0;
|
||||||
run('launchctl', ['bootout', `gui/${uid}/${flags.label}`]); // best-effort
|
run('launchctl', ['bootout', `gui/${uid}/${flags.label}`]); // best-effort
|
||||||
@@ -371,6 +414,10 @@ export async function runStatus(argv: string[]): Promise<void> {
|
|||||||
process.exitCode = code;
|
process.exitCode = code;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
process.exitCode = run('schtasks.exe', ['/Query', '/TN', WINDOWS_TASK_NAME, '/V', '/FO', 'LIST']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const uid = process.getuid?.() ?? 0;
|
const uid = process.getuid?.() ?? 0;
|
||||||
const code = run('launchctl', ['print', `gui/${uid}/${flags.label}`]);
|
const code = run('launchctl', ['print', `gui/${uid}/${flags.label}`]);
|
||||||
console.log(`\nLogs: ${launchdLogPaths().out}`);
|
console.log(`\nLogs: ${launchdLogPaths().out}`);
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ export interface Config {
|
|||||||
autoDiscover: boolean;
|
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 {
|
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
args: argv,
|
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 });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
// La DB contient des secrets (server_secret, clé privée VAPID, hashs de tokens) : le dossier de
|
// 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
|
// données ne doit jamais être lisible par d'autres utilisateurs du système. chmod best-effort
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { accessSync, constants } from 'node:fs';
|
import { accessSync, constants, existsSync } from 'node:fs';
|
||||||
|
|
||||||
export interface SpawnSpec {
|
export interface SpawnSpec {
|
||||||
file: string;
|
file: string;
|
||||||
@@ -13,8 +13,17 @@ export interface SpawnOptions {
|
|||||||
resume?: { claudeSessionId: string; fork?: boolean };
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
||||||
addDirs?: string[];
|
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;
|
claudeBinPath?: string | null;
|
||||||
|
/**
|
||||||
|
* Lancement de projet (« Démarrer le projet ») : au lieu de `bash --norc`, lance le shell de
|
||||||
|
* login interactif de l'utilisateur (`$SHELL -l -i`) pour charger son environnement complet
|
||||||
|
* (PATH nvm/asdf/~/.local/bin). Indispensable quand le daemon tourne en service systemd/launchd
|
||||||
|
* (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). */
|
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
|
||||||
@@ -29,16 +38,32 @@ export interface ClaudeBinDiagnostic {
|
|||||||
|
|
||||||
let cachedClaudeBin: string | null = null;
|
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. */
|
/** 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 {
|
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 {
|
} catch {
|
||||||
return null;
|
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 {
|
try {
|
||||||
accessSync(path, constants.X_OK);
|
accessSync(path, constants.X_OK);
|
||||||
return true;
|
return true;
|
||||||
@@ -50,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
|
* 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 :
|
* (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
|
* recherche dans le PATH (`which` / `where.exe`), mise en cache. Un service systemd/launchd démarre
|
||||||
* ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par
|
* avec un PATH minimal sans ~/.local/bin → la recherche y échoue ; d'où le réglage de chemin explicite
|
||||||
* `arboretum install`).
|
* (et le PATH figé par `arboretum install`).
|
||||||
*/
|
*/
|
||||||
export function resolveClaudeBin(configuredPath?: string | null): string {
|
export function resolveClaudeBin(configuredPath?: string | null): string {
|
||||||
if (configuredPath) {
|
if (configuredPath) {
|
||||||
@@ -61,7 +86,14 @@ export function resolveClaudeBin(configuredPath?: string | null): string {
|
|||||||
}
|
}
|
||||||
return configuredPath;
|
return configuredPath;
|
||||||
}
|
}
|
||||||
if (cachedClaudeBin) return cachedClaudeBin;
|
// Cache REVALIDÉ : le daemon vit des jours. Un changement de version nvm/asdf, une réinstallation
|
||||||
|
// du CLI ou un simple `npm i -g` remplace le chemin, et le cache pointait alors sur un fichier
|
||||||
|
// disparu : node-pty spawnait dans le vide, le PTY mourait sans un octet, et l'utilisateur n'avait
|
||||||
|
// qu'un terminal vide sans explication.
|
||||||
|
if (cachedClaudeBin) {
|
||||||
|
if (isExecutable(cachedClaudeBin)) return cachedClaudeBin;
|
||||||
|
cachedClaudeBin = null;
|
||||||
|
}
|
||||||
const found = findClaudeOnPath();
|
const found = findClaudeOnPath();
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -81,15 +113,84 @@ export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiag
|
|||||||
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shells interactifs connus supportant `-l -i` (login + interactif). */
|
||||||
|
const KNOWN_LOGIN_SHELLS = new Set(['bash', 'zsh', 'fish']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marqueurs d'EXÉCUTION que le CLI claude pose dans l'environnement de ses processus enfants. Si le
|
||||||
|
* daemon a lui-même été lancé depuis une session Claude Code (ce qui arrive : `arboretum` démarré
|
||||||
|
* depuis un terminal Claude, ou l'app de bureau lancée par un agent), il les hérite et les
|
||||||
|
* retransmettait à CHAQUE session qu'il lance. Conséquences observées :
|
||||||
|
* - `CLAUDE_CODE_CHILD_SESSION=1` fait croire au CLI qu'il est une sous-session : il DÉSACTIVE la
|
||||||
|
* sauvegarde du transcript (« Transcript saving is off »), donc plus d'historique, plus de
|
||||||
|
* `--resume`, et `claudeSessionId` reste null (l'état fin busy/waiting/idle tombe avec lui) ;
|
||||||
|
* - `CLAUDE_CODE_SESSION_ID` / `CLAUDE_PID` désignent la session PARENTE, pas celle qu'on lance.
|
||||||
|
* On ne retire QUE ces marqueurs : la configuration légitime de l'utilisateur (`CLAUDE_CONFIG_DIR`,
|
||||||
|
* `ANTHROPIC_*`, proxies...) doit passer telle quelle, sinon on casserait son installation.
|
||||||
|
*/
|
||||||
|
export const INHERITED_CLAUDE_MARKERS = [
|
||||||
|
'CLAUDECODE',
|
||||||
|
'CLAUDE_CODE_CHILD_SESSION',
|
||||||
|
'CLAUDE_CODE_SESSION_ID',
|
||||||
|
'CLAUDE_CODE_ENTRYPOINT',
|
||||||
|
'CLAUDE_CODE_EXECPATH',
|
||||||
|
'CLAUDE_PID',
|
||||||
|
'CLAUDE_EFFORT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environnement assaini pour un PTY : pur et testable. Appliqué aussi au shell (`bash`), car un
|
||||||
|
* `claude` lancé à la main dans ce terminal hériterait des mêmes marqueurs.
|
||||||
|
*/
|
||||||
|
export function sanitizeInheritedEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...source };
|
||||||
|
for (const key of INHERITED_CLAUDE_MARKERS) delete env[key];
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||||
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||||
|
const platform = opts.platform ?? process.platform;
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...sanitizeInheritedEnv(process.env),
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
};
|
};
|
||||||
if (opts.command === 'bash') {
|
if (opts.command === 'bash') {
|
||||||
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[] = [];
|
const args: string[] = [];
|
||||||
if (opts.resume) {
|
if (opts.resume) {
|
||||||
|
|||||||
@@ -7,9 +7,33 @@ import { resolve, sep, join } from 'node:path';
|
|||||||
import chokidar, { type FSWatcher } from 'chokidar';
|
import chokidar, { type FSWatcher } from 'chokidar';
|
||||||
import { resolveGitDir } from './git.js';
|
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;
|
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 {
|
export interface FsWatcherEvents {
|
||||||
/** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */
|
/** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */
|
||||||
worktree_fs_change: [{ repoId: string; path: string }];
|
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
|
* 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
|
* staging) ainsi que les répertoires de `IGNORED_DIRS`. chokidar n'ignore pas le dossier `.git`
|
||||||
* pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers volumineux (objects…).
|
* lui-même afin de pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers
|
||||||
|
* volumineux (objects…).
|
||||||
*/
|
*/
|
||||||
export function isIgnoredPath(p: string): boolean {
|
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}`)) {
|
if (p.includes(`${sep}.git${sep}`)) {
|
||||||
return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`));
|
return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Préparation d'un environnement d'authentification git ÉPHÉMÈRE (P12). HTTPS (pat/app_password) :
|
// 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
|
// les identifiants sont fournis via GIT_ASKPASS (script à permissions restreintes lisant deux variables
|
||||||
// dans l'URL ni dans `.git/config`. GIT_TERMINAL_PROMPT=0 (pas d'invite bloquante). Le script est
|
// d'env), JAMAIS dans l'URL ni dans `.git/config`. GIT_TERMINAL_PROMPT=0 (pas d'invite bloquante). Le
|
||||||
// supprimé en `finally` ; le secret ne transite que par l'env du process enfant (jamais loggé).
|
// 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 { mkdtemp, writeFile, rm, chmod } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
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).
|
// Identité HTTPS par défaut quand l'utilisateur n'a pas fourni de username (token-as-password).
|
||||||
const SERVICE_DEFAULT_USER: Record<GitService, string> = { github: 'x-access-token', gitlab: 'oauth2', gitea: 'oauth2' };
|
const SERVICE_DEFAULT_USER: Record<GitService, string> = { 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<T>(
|
export async function withGitAuth<T>(
|
||||||
service: GitService,
|
service: GitService,
|
||||||
auth: GitAuth,
|
auth: GitAuth,
|
||||||
fn: (env: NodeJS.ProcessEnv) => Promise<T>,
|
fn: (env: NodeJS.ProcessEnv) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'arb-gitauth-'));
|
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];
|
const user = auth.username || SERVICE_DEFAULT_USER[service];
|
||||||
await writeFile(
|
await writeFile(askpass, script.content, { mode: script.mode });
|
||||||
askpass,
|
// chmod best-effort : sans effet sur NTFS (comme ailleurs dans le code, cf. config.ts).
|
||||||
"#!/bin/sh\ncase \"$1\" in\n Username*) printf '%s' \"$ARB_GIT_USER\" ;;\n *) printf '%s' \"$ARB_GIT_PASS\" ;;\nesac\n",
|
await chmod(askpass, script.mode).catch(() => {});
|
||||||
{ mode: 0o700 },
|
|
||||||
);
|
|
||||||
await chmod(askpass, 0o700);
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_ASKPASS: askpass,
|
GIT_ASKPASS: askpass,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
|
// 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 { execFile, spawn } from 'node:child_process';
|
||||||
import { resolve, sep } from 'node:path';
|
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;
|
const GIT_TIMEOUT_MS = 10_000;
|
||||||
// `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large.
|
// `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$/, '') };
|
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=<n champs séparés par NUL>` 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. */
|
/** true si le HEAD courant n'est pas encore poussé (amend autorisé). Sans upstream → true. */
|
||||||
export async function isUnpushed(worktreePath: string): Promise<boolean> {
|
export async function isUnpushed(worktreePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Auto-détection des commandes de démarrage d'un projet (« Démarrer le projet »).
|
||||||
|
// Fonctions PURES et sans effet de bord notable : lecture bornée de quelques fichiers connus dans
|
||||||
|
// UN répertoire (jamais de récursion, jamais d'exécution). Tolérant : tout fichier absent/illisible
|
||||||
|
// est simplement ignoré. Les suggestions sont proposées à l'utilisateur, qui coche/ajuste.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { LaunchCommand } from '@arboretum/shared';
|
||||||
|
|
||||||
|
/** Taille max lue par fichier (garde-fou anti-fichier géant). */
|
||||||
|
const MAX_FILE_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
/** Noms de scripts npm activés par défaut (serveurs de dev longue durée) ; les autres sont proposés décochés. */
|
||||||
|
const DEFAULT_ENABLED_SCRIPT = /(^|:)(dev|start|serve|watch)(:|$)/i;
|
||||||
|
|
||||||
|
function readTextSafe(file: string): string | null {
|
||||||
|
try {
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
// Lecture bornée : on tronque au-delà de MAX_FILE_BYTES (suffisant pour scripts / Procfile).
|
||||||
|
return readFileSync(file, 'utf8').slice(0, MAX_FILE_BYTES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Détecte le gestionnaire de paquets d'après le lockfile présent (défaut : npm). */
|
||||||
|
function detectRunner(dir: string): { cmd: string } {
|
||||||
|
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return { cmd: 'pnpm run' };
|
||||||
|
if (existsSync(join(dir, 'yarn.lock'))) return { cmd: 'yarn' };
|
||||||
|
if (existsSync(join(dir, 'bun.lockb'))) return { cmd: 'bun run' };
|
||||||
|
return { cmd: 'npm run' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mk(label: string, run: string, enabled: boolean): LaunchCommand {
|
||||||
|
return { id: randomUUID(), label, run, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scripts npm depuis package.json → `<runner> <script>`. */
|
||||||
|
function fromPackageJson(dir: string): LaunchCommand[] {
|
||||||
|
const raw = readTextSafe(join(dir, 'package.json'));
|
||||||
|
if (!raw) return [];
|
||||||
|
let scripts: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(raw) as { scripts?: Record<string, unknown> };
|
||||||
|
scripts = pkg.scripts;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!scripts || typeof scripts !== 'object') return [];
|
||||||
|
const runner = detectRunner(dir);
|
||||||
|
return Object.keys(scripts)
|
||||||
|
.filter((name) => typeof scripts![name] === 'string')
|
||||||
|
.map((name) => mk(name, `${runner.cmd} ${name}`, DEFAULT_ENABLED_SCRIPT.test(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Procfile (heroku/foreman) : lignes `name: command`. Toutes activées (ce sont des cibles d'exécution). */
|
||||||
|
function fromProcfile(dir: string): LaunchCommand[] {
|
||||||
|
const raw = readTextSafe(join(dir, 'Procfile'));
|
||||||
|
if (!raw) return [];
|
||||||
|
const out: LaunchCommand[] = [];
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const m = /^([A-Za-z0-9_-]+):\s*(.+)$/.exec(line.trim());
|
||||||
|
const name = m?.[1];
|
||||||
|
const cmd = m?.[2]?.trim();
|
||||||
|
if (name && cmd) out.push(mk(name, cmd, true));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** docker-compose présent → suggestion `docker compose up` (énumération des services : évolution future). */
|
||||||
|
function fromDockerCompose(dir: string): LaunchCommand[] {
|
||||||
|
const names = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||||
|
const present = names.some((n) => existsSync(join(dir, n)));
|
||||||
|
return present ? [mk('docker', 'docker compose up', true)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte des commandes de démarrage candidates dans `dir` (package.json, Procfile, docker-compose).
|
||||||
|
* Ne récurse pas et n'exécute rien. Renvoie [] si rien n'est détecté ou si `dir` est inaccessible.
|
||||||
|
*/
|
||||||
|
export function detectLaunchCommands(dir: string): LaunchCommand[] {
|
||||||
|
return [...fromPackageJson(dir), ...fromProcfile(dir), ...fromDockerCompose(dir)];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import { existsSync, statSync } from 'node:fs';
|
import { existsSync, statSync } from 'node:fs';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
@@ -22,6 +23,8 @@ const NOTIFY_DEBOUNCE_MS = 1500;
|
|||||||
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
||||||
const CLAUDE_ID_POLL_MS = 400;
|
const CLAUDE_ID_POLL_MS = 400;
|
||||||
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||||
|
/** Replay d'une attache sans écran : rien à peindre (alloué une fois, jamais muté). */
|
||||||
|
const EMPTY_REPLAY = Buffer.alloc(0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
|
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
|
||||||
@@ -40,8 +43,29 @@ type HistoricalRow = {
|
|||||||
added_dirs: string | null;
|
added_dirs: string | null;
|
||||||
group_id: string | null;
|
group_id: string | null;
|
||||||
archived_at: string | null;
|
archived_at: string | null;
|
||||||
|
launch_run_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Longueur max d'une commande auto-tapée (garde-fou ; une ligne shell raisonnable). */
|
||||||
|
const MAX_INITIAL_INPUT_LEN = 4096;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assainit une commande de lancement avant de l'écrire dans le PTY : trim, borne de longueur,
|
||||||
|
* retrait de tous les caractères de contrôle (dont retours chariot/ligne : le `\r` de soumission
|
||||||
|
* est ajouté par l'appelant). Empêche l'injection de plusieurs lignes / séquences de contrôle par
|
||||||
|
* le champ de commande ; le modèle de menace reste inchangé (terminal = RCE par conception).
|
||||||
|
*/
|
||||||
|
function sanitizeInitialInput(raw: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (const ch of raw.slice(0, MAX_INITIAL_INPUT_LEN)) {
|
||||||
|
const code = ch.codePointAt(0) ?? 0;
|
||||||
|
// saute les caractères de contrôle C0 (0x00-0x1F) et DEL (0x7F) : ni multi-lignes ni séquences ANSI.
|
||||||
|
if (code < 0x20 || code === 0x7f) continue;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
||||||
function parseAddedDirs(raw: string | null): string[] {
|
function parseAddedDirs(raw: string | null): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -57,6 +81,14 @@ function parseAddedDirs(raw: string | null): string[] {
|
|||||||
export interface ClientBinding {
|
export interface ClientBinding {
|
||||||
channel: number;
|
channel: number;
|
||||||
mode: 'interactive' | 'observer';
|
mode: 'interactive' | 'observer';
|
||||||
|
/**
|
||||||
|
* false = attache SANS écran (le client ne peint rien : il n'est là que pour écrire, cf. le
|
||||||
|
* DialogPrompt qui répond à un dialogue depuis la liste « À traiter »). Un tel binding ne prend
|
||||||
|
* jamais le `controlling` et ne redimensionne donc jamais le PTY : sinon ses dimensions
|
||||||
|
* arbitraires figeaient la géométrie du TUI pour le vrai terminal ouvert ensuite. Il ne reçoit
|
||||||
|
* pas non plus la sortie (inutile) et ne pèse pas dans le flow control.
|
||||||
|
*/
|
||||||
|
screen: boolean;
|
||||||
controlling: boolean;
|
controlling: boolean;
|
||||||
sentBytes: number;
|
sentBytes: number;
|
||||||
ackedBytes: number;
|
ackedBytes: number;
|
||||||
@@ -85,6 +117,8 @@ interface ManagedSession {
|
|||||||
addedDirs: string[];
|
addedDirs: string[];
|
||||||
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
||||||
groupId: string | null;
|
groupId: string | null;
|
||||||
|
/** identifiant partagé par les terminaux d'un même « Démarrer le projet » ; null sinon. */
|
||||||
|
launchRunId: string | null;
|
||||||
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||||
tracker: SessionActivityTracker | null;
|
tracker: SessionActivityTracker | null;
|
||||||
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
||||||
@@ -117,6 +151,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
addDirs?: string[];
|
addDirs?: string[];
|
||||||
/** groupe propriétaire (session de groupe, P6). */
|
/** groupe propriétaire (session de groupe, P6). */
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
/** shell de login interactif (charge le PATH utilisateur) : lancement de projet uniquement. */
|
||||||
|
login?: boolean;
|
||||||
|
/** commande auto-tapée dans le PTY juste après le spawn (« Démarrer le projet »). */
|
||||||
|
initialInput?: string;
|
||||||
|
/** titre initial de la session (libellé de l'onglet ; ex. label de commande de lancement). */
|
||||||
|
title?: string;
|
||||||
|
/** identifiant partagé par tous les terminaux d'un même lancement de projet. */
|
||||||
|
launchRunId?: string;
|
||||||
}): SessionSummary {
|
}): SessionSummary {
|
||||||
const cwd = opts.cwd;
|
const cwd = opts.cwd;
|
||||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||||
@@ -138,6 +180,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
...(claudeBinPath ? { claudeBinPath } : {}),
|
...(claudeBinPath ? { claudeBinPath } : {}),
|
||||||
...(opts.resume ? { resume: opts.resume } : {}),
|
...(opts.resume ? { resume: opts.resume } : {}),
|
||||||
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||||
|
...(opts.login ? { login: true } : {}),
|
||||||
});
|
});
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const proc = pty.spawn(spec.file, spec.args, {
|
const proc = pty.spawn(spec.file, spec.args, {
|
||||||
@@ -151,7 +194,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
id,
|
id,
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
title: null,
|
title: opts.title ?? null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
proc,
|
proc,
|
||||||
ring: new RingBuffer(RING_CAPACITY),
|
ring: new RingBuffer(RING_CAPACITY),
|
||||||
@@ -162,6 +205,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
claudeSessionId: null,
|
claudeSessionId: null,
|
||||||
addedDirs,
|
addedDirs,
|
||||||
groupId: opts.groupId ?? null,
|
groupId: opts.groupId ?? null,
|
||||||
|
launchRunId: opts.launchRunId ?? null,
|
||||||
tracker: null,
|
tracker: null,
|
||||||
prevActivity: null,
|
prevActivity: null,
|
||||||
notifyTimer: null,
|
notifyTimer: null,
|
||||||
@@ -177,19 +221,28 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
}
|
}
|
||||||
this.live.set(id, session);
|
this.live.set(id, session);
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
.prepare('INSERT INTO sessions (id, cwd, command, title, created_at, resumed_from, added_dirs, group_id, launch_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
|
session.title,
|
||||||
session.createdAt,
|
session.createdAt,
|
||||||
opts.resume?.claudeSessionId ?? null,
|
opts.resume?.claudeSessionId ?? null,
|
||||||
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
||||||
session.groupId,
|
session.groupId,
|
||||||
|
session.launchRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||||
|
// Auto-type de la commande de lancement APRÈS onData : la commande et sa sortie entrent dans le
|
||||||
|
// ring et sont rejouées à l'attach. Les octets sont mis en file par le tty tant que le shell
|
||||||
|
// n'a pas commencé à lire → pas de course. Un seul `\r` final (aligné sur answer()).
|
||||||
|
if (opts.initialInput) {
|
||||||
|
const line = sanitizeInitialInput(opts.initialInput);
|
||||||
|
if (line) proc.write(`${line}\r`);
|
||||||
|
}
|
||||||
if (command === 'claude') this.captureClaudeSessionId(session);
|
if (command === 'claude') this.captureClaudeSessionId(session);
|
||||||
|
|
||||||
const summary = this.summarize(session);
|
const summary = this.summarize(session);
|
||||||
@@ -269,7 +322,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||||
const liveIds = new Set(this.live.keys());
|
const liveIds = new Set(this.live.keys());
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at FROM sessions ORDER BY created_at DESC LIMIT 100')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at, launch_run_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||||
.all() as HistoricalRow[];
|
.all() as HistoricalRow[];
|
||||||
const historical: SessionSummary[] = rows
|
const historical: SessionSummary[] = rows
|
||||||
.filter((r) => !liveIds.has(r.id))
|
.filter((r) => !liveIds.has(r.id))
|
||||||
@@ -302,6 +355,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
registryStatus: null,
|
registryStatus: null,
|
||||||
...(addedDirs.length ? { addedDirs } : {}),
|
...(addedDirs.length ? { addedDirs } : {}),
|
||||||
groupId: r.group_id,
|
groupId: r.group_id,
|
||||||
|
launchRunId: r.launch_run_id,
|
||||||
archived: r.archived_at != null,
|
archived: r.archived_at != null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -314,7 +368,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
emitHistoricalUpdate(id: string): void {
|
emitHistoricalUpdate(id: string): void {
|
||||||
if (this.live.has(id)) return;
|
if (this.live.has(id)) return;
|
||||||
const r = this.db
|
const r = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at FROM sessions WHERE id = ?')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at, launch_run_id FROM sessions WHERE id = ?')
|
||||||
.get(id) as HistoricalRow | undefined;
|
.get(id) as HistoricalRow | undefined;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
this.emit('session_update', this.historicalSummary(r));
|
this.emit('session_update', this.historicalSummary(r));
|
||||||
@@ -329,18 +383,25 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const s = this.live.get(id);
|
const s = this.live.get(id);
|
||||||
if (!s || s.exited) return false;
|
if (!s || s.exited) return false;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
s.killTimer ??= setTimeout(() => {
|
s.killTimer ??= setTimeout(() => {
|
||||||
if (!s.exited) {
|
if (s.exited) return;
|
||||||
try {
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
execFile('taskkill.exe', ['/PID', String(s.proc.pid), '/T', '/F'], () => {});
|
||||||
|
} else {
|
||||||
process.kill(s.proc.pid, 'SIGKILL');
|
process.kill(s.proc.pid, 'SIGKILL');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* déjà mort */
|
/* déjà mort */
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, KILL_GRACE_MS);
|
}, KILL_GRACE_MS);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -352,22 +413,32 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
// ---- attach / detach / io ----
|
// ---- attach / detach / io ----
|
||||||
|
|
||||||
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
|
/**
|
||||||
|
* Attache un client. Le payload de replay (reset terminal + queue du ring, l'écran TUI courant se
|
||||||
|
* reconstitue) est RENVOYÉ, pas envoyé : la gateway doit l'émettre APRÈS le message `attached`,
|
||||||
|
* sinon le client reçoit une frame binaire sur un canal qu'il ne connaît pas encore et la jette,
|
||||||
|
* ce qui laissait un terminal vide jusqu'au prochain octet spontané du PTY (jamais, pour un TUI
|
||||||
|
* au repos). Invariant à ne pas casser.
|
||||||
|
*/
|
||||||
|
attach(
|
||||||
|
sessionId: string,
|
||||||
|
binding: ClientBinding,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
): { ok: true; controlling: boolean; replay: Buffer } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
|
||||||
const s = this.live.get(sessionId);
|
const s = this.live.get(sessionId);
|
||||||
if (!s) return { ok: false, code: 'NOT_FOUND' };
|
if (!s) return { ok: false, code: 'NOT_FOUND' };
|
||||||
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
|
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
|
||||||
const hasController = [...s.clients].some((c) => c.controlling);
|
const hasController = [...s.clients].some((c) => c.controlling);
|
||||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
binding.controlling = binding.mode === 'interactive' && binding.screen && !hasController;
|
||||||
s.clients.add(binding);
|
s.clients.add(binding);
|
||||||
if (binding.controlling) {
|
if (binding.controlling) {
|
||||||
s.proc.resize(cols, rows);
|
s.proc.resize(cols, rows);
|
||||||
s.tracker?.resize(cols, rows);
|
s.tracker?.resize(cols, rows);
|
||||||
}
|
}
|
||||||
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
|
||||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
|
||||||
binding.sentBytes = 0;
|
binding.sentBytes = 0;
|
||||||
binding.ackedBytes = 0;
|
binding.ackedBytes = 0;
|
||||||
return { ok: true, controlling: binding.controlling };
|
return { ok: true, controlling: binding.controlling, replay: binding.screen ? s.ring.tail(REPLAY_TAIL_BYTES) : EMPTY_REPLAY };
|
||||||
}
|
}
|
||||||
|
|
||||||
detach(sessionId: string, binding: ClientBinding): void {
|
detach(sessionId: string, binding: ClientBinding): void {
|
||||||
@@ -375,7 +446,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
s.clients.delete(binding);
|
s.clients.delete(binding);
|
||||||
if (binding.controlling) {
|
if (binding.controlling) {
|
||||||
const next = [...s.clients].find((c) => c.mode === 'interactive');
|
// Le relais ne peut aller qu'à un client qui PEINT : un binding sans écran redimensionnerait
|
||||||
|
// le PTY à des dimensions arbitraires (cf. ClientBinding.screen).
|
||||||
|
const next = [...s.clients].find((c) => c.mode === 'interactive' && c.screen);
|
||||||
if (next) {
|
if (next) {
|
||||||
next.controlling = true;
|
next.controlling = true;
|
||||||
next.onControlChanged(true);
|
next.onControlChanged(true);
|
||||||
@@ -484,7 +557,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
s.ring.write(chunk);
|
s.ring.write(chunk);
|
||||||
s.tracker?.feed(chunk);
|
s.tracker?.feed(chunk);
|
||||||
for (const c of s.clients) {
|
for (const c of s.clients) {
|
||||||
if (c.lagging) continue;
|
if (c.lagging || !c.screen) continue;
|
||||||
c.sendOutput(chunk);
|
c.sendOutput(chunk);
|
||||||
c.sentBytes += chunk.length;
|
c.sentBytes += chunk.length;
|
||||||
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
|
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
|
||||||
@@ -494,11 +567,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
|
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
|
||||||
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
|
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY, et les
|
||||||
|
* attaches sans écran ne reçoivent rien : les compter maintiendrait un `outstanding` nul et
|
||||||
|
* empêcherait toute pause, donc on les écarte.
|
||||||
*/
|
*/
|
||||||
private updateFlowControl(s: ManagedSession): void {
|
private updateFlowControl(s: ManagedSession): void {
|
||||||
if (s.exited) return;
|
if (s.exited) return;
|
||||||
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
|
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && c.screen && !c.lagging);
|
||||||
if (interactive.length === 0) {
|
if (interactive.length === 0) {
|
||||||
if (s.paused) {
|
if (s.paused) {
|
||||||
s.proc.resume();
|
s.proc.resume();
|
||||||
@@ -525,6 +600,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
||||||
|
// Épilogue visible DANS le terminal : sans lui, un process mort à l'instant du spawn (binaire
|
||||||
|
// introuvable, auth expirée, commande de lancement qui sort aussitôt) ne laissait qu'un écran
|
||||||
|
// vide et un bandeau « Session terminée », sans jamais dire pourquoi. Diffusé AVANT le
|
||||||
|
// `onDetached` : après, le client a déjà oublié le canal et jetterait la frame.
|
||||||
|
this.handleOutput(s, Buffer.from(`\r\n[arboretum] ${s.command} ${signal ? `terminated by signal ${signal}` : `exited with code ${exitCode ?? 0}`}\r\n`));
|
||||||
for (const c of s.clients) c.onDetached('session_exit');
|
for (const c of s.clients) c.onDetached('session_exit');
|
||||||
s.clients.clear();
|
s.clients.clear();
|
||||||
this.live.delete(s.id);
|
this.live.delete(s.id);
|
||||||
@@ -558,6 +638,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
dialog: act?.dialog ?? null,
|
dialog: act?.dialog ?? null,
|
||||||
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
||||||
groupId: s.groupId,
|
groupId: s.groupId,
|
||||||
|
launchRunId: s.launchRunId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T extends { path: string }>(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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { existsSync, realpathSync } from 'node:fs';
|
|||||||
import type {
|
import type {
|
||||||
DiscoverReposResponse,
|
DiscoverReposResponse,
|
||||||
HookRunResult,
|
HookRunResult,
|
||||||
|
LaunchCommand,
|
||||||
PostCreateHook,
|
PostCreateHook,
|
||||||
RepoSummary,
|
RepoSummary,
|
||||||
SessionSummary,
|
SessionSummary,
|
||||||
@@ -27,6 +28,8 @@ import {
|
|||||||
amendCommit,
|
amendCommit,
|
||||||
cleanFiles,
|
cleanFiles,
|
||||||
commitAll,
|
commitAll,
|
||||||
|
commitDiff,
|
||||||
|
commitLog,
|
||||||
commitStaged,
|
commitStaged,
|
||||||
defaultBranch,
|
defaultBranch,
|
||||||
fetchRemote,
|
fetchRemote,
|
||||||
@@ -37,6 +40,7 @@ import {
|
|||||||
isSafeRelativePath,
|
isSafeRelativePath,
|
||||||
isUnpushed,
|
isUnpushed,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
|
isValidCommitish,
|
||||||
listBranches,
|
listBranches,
|
||||||
listChanges,
|
listChanges,
|
||||||
listWorktrees,
|
listWorktrees,
|
||||||
@@ -52,7 +56,8 @@ import {
|
|||||||
type ParsedWorktree,
|
type ParsedWorktree,
|
||||||
} from './git.js';
|
} from './git.js';
|
||||||
import type { FsWatcherService } from './fs-watcher.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 FACTS_TTL_MS = 2500;
|
||||||
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||||
@@ -71,6 +76,8 @@ interface RepoRow {
|
|||||||
pre_trust: number;
|
pre_trust: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
hidden: number;
|
hidden: number;
|
||||||
|
/** commandes de démarrage du projet (JSON array de LaunchCommand) ; '[]' par défaut. */
|
||||||
|
launch_commands: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeManagerEvents {
|
export interface WorktreeManagerEvents {
|
||||||
@@ -101,6 +108,28 @@ function parseHooks(json: string): PostCreateHook[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse la colonne `launch_commands` (JSON array de LaunchCommand) de façon défensive ; [] si invalide. */
|
||||||
|
function parseLaunchCommands(json: string): LaunchCommand[] {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(json) as unknown;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr
|
||||||
|
.filter(
|
||||||
|
(c): c is LaunchCommand =>
|
||||||
|
!!c && typeof c.id === 'string' && typeof c.label === 'string' && typeof c.run === 'string' && typeof c.enabled === 'boolean',
|
||||||
|
)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
run: c.run,
|
||||||
|
enabled: c.enabled,
|
||||||
|
...(typeof c.cwd === 'string' && c.cwd.trim() !== '' ? { cwd: c.cwd } : {}),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
||||||
return new Promise((resolveP) => {
|
return new Promise((resolveP) => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -128,6 +157,8 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
private readonly locks = new Map<string, Promise<unknown>>();
|
private readonly locks = new Map<string, Promise<unknown>>();
|
||||||
/** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */
|
/** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */
|
||||||
private scanInFlight: Promise<DiscoverReposResponse> | null = null;
|
private scanInFlight: Promise<DiscoverReposResponse> | null = null;
|
||||||
|
/** Worktree épinglé au watcher FS pour chaque session vivante (clé = id de session). */
|
||||||
|
private readonly pinnedSessions = new Map<string, { repoId: string; path: string }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Db,
|
private readonly db: Db,
|
||||||
@@ -145,6 +176,46 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
if (row) void this.emitWorktree(row, path).catch(() => {});
|
if (row) void this.emitWorktree(row, path).catch(() => {});
|
||||||
this.emit('worktree_changes', { repoId, path });
|
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<void> {
|
||||||
|
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 ----
|
// ---- repos ----
|
||||||
@@ -160,6 +231,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
label: row.label,
|
label: row.label,
|
||||||
defaultBranch: row.default_branch,
|
defaultBranch: row.default_branch,
|
||||||
postCreateHooks: parseHooks(row.post_create_hooks),
|
postCreateHooks: parseHooks(row.post_create_hooks),
|
||||||
|
launchCommands: parseLaunchCommands(row.launch_commands),
|
||||||
preTrust: row.pre_trust === 1,
|
preTrust: row.pre_trust === 1,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
valid: await isRepo(row.path),
|
valid: await isRepo(row.path),
|
||||||
@@ -167,12 +239,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Résumé d'un repo par id (null si inconnu). Sert notamment au lancement de projet. */
|
||||||
|
async getRepo(id: string): Promise<RepoSummary | null> {
|
||||||
|
const row = this.getRepoRow(id);
|
||||||
|
return row ? this.rowToSummary(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async listRepos(): Promise<RepoSummary[]> {
|
async listRepos(): Promise<RepoSummary[]> {
|
||||||
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
|
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
|
||||||
return Promise.all(rows.map((r) => this.rowToSummary(r)));
|
return Promise.all(rows.map((r) => this.rowToSummary(r)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; launchCommands?: LaunchCommand[] }): Promise<RepoSummary> {
|
||||||
const path = opts.path;
|
const path = opts.path;
|
||||||
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
|
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
|
||||||
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
|
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
|
||||||
@@ -187,11 +265,12 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
pre_trust: opts.preTrust ? 1 : 0,
|
pre_trust: opts.preTrust ? 1 : 0,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
hidden: 0,
|
hidden: 0,
|
||||||
|
launch_commands: JSON.stringify(opts.launchCommands ?? []),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden, launch_commands) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
|
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden, row.launch_commands);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
|
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
|
||||||
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
|
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
|
||||||
@@ -220,16 +299,17 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
this.fsWatcher?.pinRepo(row.id, resolve(row.path));
|
this.fsWatcher?.pinRepo(row.id, resolve(row.path));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise<RepoSummary> {
|
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean; launchCommands?: LaunchCommand[] }): Promise<RepoSummary> {
|
||||||
const row = this.getRepoRow(id);
|
const row = this.getRepoRow(id);
|
||||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
||||||
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
||||||
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
||||||
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
|
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
|
||||||
|
if (patch.launchCommands !== undefined) row.launch_commands = JSON.stringify(patch.launchCommands);
|
||||||
this.db
|
this.db
|
||||||
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
|
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ?, launch_commands = ? WHERE id = ?')
|
||||||
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, id);
|
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, row.launch_commands, id);
|
||||||
const summary = await this.rowToSummary(row);
|
const summary = await this.rowToSummary(row);
|
||||||
this.emit('repo_update', summary);
|
this.emit('repo_update', summary);
|
||||||
// P11 : masqué → on libère le watcher permanent du principal ; ré-affiché → on le réarme.
|
// P11 : masqué → on libère le watcher permanent du principal ; ré-affiché → on le réarme.
|
||||||
@@ -287,6 +367,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
pre_trust: 0,
|
pre_trust: 0,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
hidden: 0,
|
hidden: 0,
|
||||||
|
launch_commands: '[]',
|
||||||
};
|
};
|
||||||
const res = insert.run(row.id, row.path, row.label, row.created_at);
|
const res = insert.run(row.id, row.path, row.label, row.created_at);
|
||||||
if (res.changes === 1) {
|
if (res.changes === 1) {
|
||||||
@@ -301,20 +382,28 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
// ---- worktrees ----
|
// ---- 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`
|
* 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é
|
* (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
|
* 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.
|
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
|
||||||
*/
|
*/
|
||||||
private sessionsForCwd(path: string, opts?: { includeHidden?: boolean }): SessionSummary[] {
|
private sessionsForCwd(path: string, opts?: { includeHidden?: boolean; siblings?: string[] }): SessionSummary[] {
|
||||||
const rp = resolve(path);
|
|
||||||
return mergeSessions(this.ptyManager.list(), this.discovery.list())
|
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);
|
.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 {
|
return {
|
||||||
repoId,
|
repoId,
|
||||||
path: w.path,
|
path: w.path,
|
||||||
@@ -325,11 +414,11 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
prunable: w.prunable,
|
prunable: w.prunable,
|
||||||
isMain: resolve(w.path) === resolve(repoPath),
|
isMain: resolve(w.path) === resolve(repoPath),
|
||||||
git: status,
|
git: status,
|
||||||
sessions: this.sessionsForCwd(w.path),
|
sessions: this.sessionsForCwd(w.path, { siblings }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async repoFacts(row: RepoRow, noCache = false): Promise<Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>> {
|
private async repoFacts(row: { id: string; path: string }, noCache = false): Promise<Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>> {
|
||||||
const cached = this.factsCache.get(row.id);
|
const cached = this.factsCache.get(row.id);
|
||||||
if (!noCache && cached && Date.now() - cached.at < FACTS_TTL_MS) return cached.facts;
|
if (!noCache && cached && Date.now() - cached.at < FACTS_TTL_MS) return cached.facts;
|
||||||
const parsed = (await listWorktrees(row.path)).filter((w) => !w.bare);
|
const parsed = (await listWorktrees(row.path)).filter((w) => !w.bare);
|
||||||
@@ -342,7 +431,8 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
const row = this.getRepoRow(repoId);
|
const row = this.getRepoRow(repoId);
|
||||||
if (!row) return [];
|
if (!row) return [];
|
||||||
const facts = await this.repoFacts(row, noCache);
|
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<WorktreeSummary[]> {
|
async listAllWorktrees(): Promise<WorktreeSummary[]> {
|
||||||
@@ -376,9 +466,19 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async emitWorktree(row: RepoRow, path: string): Promise<WorktreeSummary | null> {
|
private async emitWorktree(row: RepoRow, path: string): Promise<WorktreeSummary | null> {
|
||||||
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;
|
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 });
|
this.emit('worktree_update', { repoId: row.id, worktree: summary });
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
@@ -464,6 +564,11 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
|
throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
|
||||||
}
|
}
|
||||||
this.factsCache.delete(repoId);
|
this.factsCache.delete(repoId);
|
||||||
|
// Un commit vide l'index et déplace HEAD : sans cette émission, les vues à contenu paresseux
|
||||||
|
// continuaient d'afficher les fichiers qu'on venait de committer, et l'historique ne voyait pas
|
||||||
|
// le nouveau commit (le watcher FS finissait par le rattraper, mais seulement si ce worktree
|
||||||
|
// était observé, et avec le délai du debounce).
|
||||||
|
this.emit('worktree_changes', { repoId, path: w.path });
|
||||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -485,6 +590,25 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
return listChanges(w.path);
|
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<WorktreeLogResponse> {
|
||||||
|
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<CommitDiffResponse> {
|
||||||
|
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. */
|
/** 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<FileDiffResponse> {
|
async getFileDiff(repoId: string, path: string, file: string, staged: boolean): Promise<FileDiffResponse> {
|
||||||
const { w } = await this.requireWorktree(repoId, path);
|
const { w } = await this.requireWorktree(repoId, path);
|
||||||
@@ -557,6 +681,10 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
return this.withLock(repoId, async () => {
|
return this.withLock(repoId, async () => {
|
||||||
await fetchRemote(w.path).catch((err) => { throw httpError(400, 'FETCH_FAILED', (err as Error).message); });
|
await fetchRemote(w.path).catch((err) => { throw httpError(400, 'FETCH_FAILED', (err as Error).message); });
|
||||||
this.factsCache.delete(repoId);
|
this.factsCache.delete(repoId);
|
||||||
|
// Un fetch ne touche pas l'arbre de travail, mais il change ce que valent les compteurs et
|
||||||
|
// l'historique : uniformité « toute mutation git ré-arme les vues à contenu paresseux », sans
|
||||||
|
// quoi ce bouton est le seul du panneau à ne rien rafraîchir de visible.
|
||||||
|
this.emit('worktree_changes', { repoId, path: w.path });
|
||||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -601,6 +729,55 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
return abs;
|
return abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Démarrer le projet » : résout le répertoire de base du lancement. Priorité au worktree
|
||||||
|
* `worktreePath` (validé comme worktree connu du repo), sinon worktree portant `branch`, sinon
|
||||||
|
* checkout principal. Le client ne passe JAMAIS un chemin brut non validé (défense en profondeur).
|
||||||
|
*/
|
||||||
|
async resolveLaunchBase(repoId: string, opts: { worktreePath?: string; branch?: string }): Promise<string> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const wp = opts.worktreePath?.trim();
|
||||||
|
if (wp) {
|
||||||
|
if (!isSafeAbsolutePath(wp)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
|
||||||
|
const w = await this.findWorktree(row, wp);
|
||||||
|
if (!w) throw httpError(404, 'NO_RESOLVABLE_WORKTREE', 'No such worktree under this repo');
|
||||||
|
return resolve(w.path);
|
||||||
|
}
|
||||||
|
const branch = opts.branch?.trim();
|
||||||
|
if (branch) {
|
||||||
|
const wts = await this.listRepoWorktrees(repoId);
|
||||||
|
const match = wts.find((w) => w.branch === branch);
|
||||||
|
if (!match) throw httpError(404, 'NO_RESOLVABLE_WORKTREE', `No worktree on branch ${branch}`);
|
||||||
|
return resolve(match.path);
|
||||||
|
}
|
||||||
|
return resolve(row.path); // checkout principal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le sous-répertoire relatif d'une commande de lancement, borné au répertoire de base
|
||||||
|
* (anti `..`, anti symlink sortant), calqué sur assertPathInWorktree. Renvoie `base` si vide.
|
||||||
|
*/
|
||||||
|
resolveLaunchSubdir(baseDir: string, relCwd?: string): string {
|
||||||
|
const base = resolve(baseDir);
|
||||||
|
const rel = relCwd?.trim();
|
||||||
|
if (!rel) return base;
|
||||||
|
if (!isSafeRelativePath(rel)) throw httpError(400, 'BAD_PATH', 'Invalid launch cwd');
|
||||||
|
const abs = resolve(join(base, rel));
|
||||||
|
if (abs !== base && !abs.startsWith(base + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Launch cwd escapes the worktree');
|
||||||
|
if (existsSync(abs)) {
|
||||||
|
let real: string;
|
||||||
|
try {
|
||||||
|
real = realpathSync(abs);
|
||||||
|
} catch {
|
||||||
|
throw httpError(400, 'BAD_PATH', 'Cannot resolve launch cwd');
|
||||||
|
}
|
||||||
|
const realBase = realpathSync(base);
|
||||||
|
if (real !== realBase && !real.startsWith(realBase + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Launch cwd escapes the worktree (symlink)');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
/** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
|
/** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
|
||||||
async watch(repoId: string, path: string): Promise<void> {
|
async watch(repoId: string, path: string): Promise<void> {
|
||||||
if (!this.fsWatcher) return;
|
if (!this.fsWatcher) return;
|
||||||
@@ -625,6 +802,9 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
throw httpError(400, 'PUSH_FAILED', (err as Error).message);
|
throw httpError(400, 'PUSH_FAILED', (err as Error).message);
|
||||||
}
|
}
|
||||||
this.factsCache.delete(repoId);
|
this.factsCache.delete(repoId);
|
||||||
|
// Corrige le badge « n commits non poussés » de l'historique, qui restait périmé après un push :
|
||||||
|
// son seul déclencheur est ce jeton d'invalidation.
|
||||||
|
this.emit('worktree_changes', { repoId, path: w.path });
|
||||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -729,8 +909,10 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
const w = await this.findWorktree(row, path);
|
const w = await this.findWorktree(row, path);
|
||||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
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');
|
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.
|
// garde-fou : une session vivante tourne dans ce worktree (ou dans un de ses sous-répertoires, ou
|
||||||
if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
|
// 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');
|
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree: pass force to delete anyway');
|
||||||
}
|
}
|
||||||
return this.withLock(repoId, async () => {
|
return this.withLock(repoId, async () => {
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
ALTER TABLE repos ADD COLUMN credential_id TEXT;
|
ALTER TABLE repos ADD COLUMN credential_id TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// « Démarrer le projet » : commandes de démarrage multi-terminaux, stockées en JSON sur le
|
||||||
|
// repo (miroir de post_create_hooks). Chaque terminal lancé porte un launch_run_id partagé
|
||||||
|
// (regroupement UI + « tout arrêter ») ; pas de FK (cohérent avec sessions.group_id #8).
|
||||||
|
id: 13,
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE repos ADD COLUMN launch_commands TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE sessions ADD COLUMN launch_run_id TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type {
|
import type {
|
||||||
WorktreeChangesResponse,
|
WorktreeChangesResponse,
|
||||||
|
CommitDiffResponse,
|
||||||
FileDiffResponse,
|
FileDiffResponse,
|
||||||
|
WorktreeLogResponse,
|
||||||
WorktreeFilesRequest,
|
WorktreeFilesRequest,
|
||||||
DiscardFilesRequest,
|
DiscardFilesRequest,
|
||||||
FetchWorktreeRequest,
|
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) => {
|
app.get('/api/v1/repos/:id/worktrees/diff', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const q = req.query as { path?: string; file?: string; staged?: string };
|
const q = req.query as { path?: string; file?: string; staged?: string; commit?: string };
|
||||||
if (typeof q.path !== 'string' || q.path === '' || typeof q.file !== 'string' || q.file === '') {
|
if (typeof q.path !== 'string' || q.path === '') {
|
||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and file are required' } });
|
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';
|
const staged = q.staged === '1' || q.staged === 'true';
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import type { CreateRepoRequest, DiscoverReposResponse, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
CreateRepoRequest,
|
||||||
|
DetectLaunchResponse,
|
||||||
|
DiscoverReposResponse,
|
||||||
|
RepoResponse,
|
||||||
|
ReposListResponse,
|
||||||
|
SessionSummary,
|
||||||
|
StartLaunchRequest,
|
||||||
|
StartLaunchResponse,
|
||||||
|
UpdateRepoRequest,
|
||||||
|
} from '@arboretum/shared';
|
||||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
import { readScanRoots } from '../core/scan-settings.js';
|
import { readScanRoots } from '../core/scan-settings.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
import { detectLaunchCommands } from '../core/launch-detect.js';
|
||||||
|
|
||||||
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
||||||
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
@@ -10,7 +24,7 @@ export function sendManagerError(reply: FastifyReply, err: unknown): FastifyRepl
|
|||||||
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
|
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db, manager: PtyManager): void {
|
||||||
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
||||||
|
|
||||||
// Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings).
|
// Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings).
|
||||||
@@ -34,6 +48,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
...(body.label !== undefined ? { label: body.label } : {}),
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
...(body.launchCommands !== undefined ? { launchCommands: body.launchCommands } : {}),
|
||||||
});
|
});
|
||||||
const res: RepoResponse = { repo };
|
const res: RepoResponse = { repo };
|
||||||
return reply.status(201).send(res);
|
return reply.status(201).send(res);
|
||||||
@@ -51,6 +66,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
|
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
|
||||||
|
...(body.launchCommands !== undefined ? { launchCommands: body.launchCommands } : {}),
|
||||||
});
|
});
|
||||||
const res: RepoResponse = { repo };
|
const res: RepoResponse = { repo };
|
||||||
return reply.send(res);
|
return reply.send(res);
|
||||||
@@ -66,4 +82,79 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
}
|
}
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// « Démarrer le projet » : suggestions de commandes détectées dans le répertoire cible
|
||||||
|
// (package.json/Procfile/docker-compose). Le worktree cible est résolu côté serveur.
|
||||||
|
app.get('/api/v1/repos/:id/launch/detect', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const query = (req.query as { worktreePath?: string }) ?? {};
|
||||||
|
try {
|
||||||
|
const base = await wt.resolveLaunchBase(id, {
|
||||||
|
...(typeof query.worktreePath === 'string' ? { worktreePath: query.worktreePath } : {}),
|
||||||
|
});
|
||||||
|
return reply.send({ suggestions: detectLaunchCommands(base) } satisfies DetectLaunchResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// « Démarrer le projet » : lance un terminal (session managée) par commande activée, tous reliés
|
||||||
|
// par un même launchRunId. Le cwd de chaque terminal est borné au worktree cible (anti-traversal).
|
||||||
|
app.post('/api/v1/repos/:id/launch', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<StartLaunchRequest> | null) ?? {};
|
||||||
|
|
||||||
|
let repo;
|
||||||
|
try {
|
||||||
|
repo = await wt.getRepo(id);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
if (!repo) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No repo with this id' } });
|
||||||
|
|
||||||
|
// Sélection explicite par `commandIds` (outrepasse `enabled`) ; sinon toutes les commandes activées.
|
||||||
|
const wanted = Array.isArray(body.commandIds) ? new Set(body.commandIds) : null;
|
||||||
|
const commands = wanted ? repo.launchCommands.filter((c) => wanted.has(c.id)) : repo.launchCommands.filter((c) => c.enabled);
|
||||||
|
if (commands.length === 0) {
|
||||||
|
return reply.status(400).send({ error: { code: 'NO_LAUNCH_COMMANDS', message: 'No launch command to start (define or enable commands first)' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let base: string;
|
||||||
|
try {
|
||||||
|
base = await wt.resolveLaunchBase(id, {
|
||||||
|
...(typeof body.worktreePath === 'string' ? { worktreePath: body.worktreePath } : {}),
|
||||||
|
...(typeof body.branch === 'string' ? { branch: body.branch } : {}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchRunId = randomUUID();
|
||||||
|
const sessions: SessionSummary[] = [];
|
||||||
|
const skipped: Array<{ id: string; reason: string }> = [];
|
||||||
|
for (const cmd of commands) {
|
||||||
|
if (cmd.run.trim() === '') {
|
||||||
|
skipped.push({ id: cmd.id, reason: 'empty command' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cwd = wt.resolveLaunchSubdir(base, cmd.cwd);
|
||||||
|
sessions.push(
|
||||||
|
manager.spawn({ cwd, command: 'bash', login: true, initialInput: cmd.run, title: cmd.label, launchRunId }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
skipped.push({ id: cmd.id, reason: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return reply.status(400).send({ error: { code: 'LAUNCH_FAILED', message: 'No launch command could be started', details: skipped } });
|
||||||
|
}
|
||||||
|
recordAudit(db, {
|
||||||
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
|
action: 'repo.launch',
|
||||||
|
resourceId: id,
|
||||||
|
details: { launchRunId, started: sessions.length, skipped: skipped.length },
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ sessions, skipped } satisfies StartLaunchResponse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function registerWsGateway(
|
|||||||
const binding: ClientBinding = {
|
const binding: ClientBinding = {
|
||||||
channel,
|
channel,
|
||||||
mode: msg.mode,
|
mode: msg.mode,
|
||||||
|
screen: msg.screen ?? true,
|
||||||
controlling: false,
|
controlling: false,
|
||||||
sentBytes: 0,
|
sentBytes: 0,
|
||||||
ackedBytes: 0,
|
ackedBytes: 0,
|
||||||
@@ -200,7 +201,13 @@ export function registerWsGateway(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
channels.set(channel, { sessionId: msg.sessionId, binding });
|
channels.set(channel, { sessionId: msg.sessionId, binding });
|
||||||
|
// ORDRE CRITIQUE : `attached` d'abord, le replay ENSUITE. Le client n'apprend le numéro de
|
||||||
|
// canal qu'avec `attached` ; une frame binaire émise avant tombe sur un canal inconnu et
|
||||||
|
// est jetée en silence, ce qui laissait le terminal vide (un TUI au repos ne réémet rien).
|
||||||
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
||||||
|
// Toujours envoyé quand le client peint, même vide : le resync porte AUSSI l'ordre de reset,
|
||||||
|
// sans quoi une ré-attache après reconnexion empilerait le nouveau flux sur un écran périmé.
|
||||||
|
if (binding.screen) binding.sendResync(res.replay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'detach': {
|
case 'detach': {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Le chemin du CLI claude est mémorisé pour la vie du process (le `which` coûte un fork par spawn).
|
||||||
|
// Régression : un daemon qui tourne des jours voyait ce chemin devenir invalide (bascule de version
|
||||||
|
// nvm/asdf, réinstallation du CLI) et continuait de spawner un fichier disparu. Le PTY mourait sans
|
||||||
|
// produire un seul octet, ce qui donnait un terminal vide et muet. Le cache est donc revalidé.
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
/** Réponse courante du faux `which` : on la fait varier comme le ferait un changement de version. */
|
||||||
|
let onPath: string;
|
||||||
|
|
||||||
|
function makeBin(name: string): string {
|
||||||
|
const path = join(dir, name);
|
||||||
|
writeFileSync(path, '#!/bin/sh\nexit 0\n');
|
||||||
|
chmodSync(path, 0o755);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-claude-bin-'));
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:child_process', () => ({ execFileSync: () => `${onPath}\n` }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock('node:child_process');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveClaudeBin · cache revalidé', () => {
|
||||||
|
it('le chemin caché disparu est re-résolu au lieu d’être servi tel quel', async () => {
|
||||||
|
const first = makeBin('claude-v1');
|
||||||
|
onPath = first;
|
||||||
|
const { resolveClaudeBin } = await import('../src/core/claude-launcher.js');
|
||||||
|
|
||||||
|
expect(resolveClaudeBin()).toBe(first);
|
||||||
|
|
||||||
|
// le CLI est réinstallé ailleurs : l'ancien chemin n'existe plus
|
||||||
|
rmSync(first);
|
||||||
|
const second = makeBin('claude-v2');
|
||||||
|
onPath = second;
|
||||||
|
|
||||||
|
expect(resolveClaudeBin()).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tant que le chemin caché existe, aucun `which` supplémentaire n’est fait', async () => {
|
||||||
|
const bin = makeBin('claude-stable');
|
||||||
|
onPath = bin;
|
||||||
|
const calls: number[] = [];
|
||||||
|
vi.doMock('node:child_process', () => ({
|
||||||
|
execFileSync: () => {
|
||||||
|
calls.push(1);
|
||||||
|
return `${onPath}\n`;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
const { resolveClaudeBin } = await import('../src/core/claude-launcher.js');
|
||||||
|
|
||||||
|
expect(resolveClaudeBin()).toBe(bin);
|
||||||
|
expect(resolveClaudeBin()).toBe(bin);
|
||||||
|
expect(resolveClaudeBin()).toBe(bin);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
|
import { buildSpawnSpec, diagnoseClaudeBin, INHERITED_CLAUDE_MARKERS, resolveClaudeBin, sanitizeInheritedEnv } from '../src/core/claude-launcher.js';
|
||||||
|
|
||||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
@@ -52,3 +52,56 @@ describe('resolveClaudeBin / diagnoseClaudeBin · override de chemin (réglage U
|
|||||||
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Régression vécue : l'app de bureau avait été lancée depuis une session Claude Code, donc le daemon
|
||||||
|
// héritait de `CLAUDE_CODE_CHILD_SESSION=1` et le repassait à CHAQUE session lancée. Le CLI se croyait
|
||||||
|
// sous-session et coupait la sauvegarde du transcript : « Transcript saving is off », plus aucun
|
||||||
|
// historique, plus de `--resume`, et `claudeSessionId` restait null (l'état fin tombe avec lui).
|
||||||
|
describe('sanitizeInheritedEnv · marqueurs de session parente', () => {
|
||||||
|
const polluted = {
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
CLAUDECODE: '1',
|
||||||
|
CLAUDE_CODE_CHILD_SESSION: '1',
|
||||||
|
CLAUDE_CODE_SESSION_ID: 'afad72f8-a987-462a-9406-7fd144e05905',
|
||||||
|
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||||
|
CLAUDE_CODE_EXECPATH: '/home/u/.local/share/claude/versions/2.1.222',
|
||||||
|
CLAUDE_PID: '1813704',
|
||||||
|
CLAUDE_EFFORT: 'xhigh',
|
||||||
|
CLAUDE_CONFIG_DIR: '/home/u/.claude',
|
||||||
|
ANTHROPIC_API_KEY: 'sk-test',
|
||||||
|
HTTPS_PROXY: 'http://proxy:3128',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('retire les marqueurs d’exécution hérités', () => {
|
||||||
|
const env = sanitizeInheritedEnv(polluted);
|
||||||
|
for (const key of INHERITED_CLAUDE_MARKERS) expect(env[key]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conserve la configuration légitime de l’utilisateur', () => {
|
||||||
|
const env = sanitizeInheritedEnv(polluted);
|
||||||
|
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/u/.claude');
|
||||||
|
expect(env.ANTHROPIC_API_KEY).toBe('sk-test');
|
||||||
|
expect(env.HTTPS_PROXY).toBe('http://proxy:3128');
|
||||||
|
expect(env.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne mute pas la source', () => {
|
||||||
|
const copy = { ...polluted };
|
||||||
|
sanitizeInheritedEnv(copy);
|
||||||
|
expect(copy.CLAUDE_CODE_CHILD_SESSION).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildSpawnSpec assainit l’env, pour claude ET pour le shell', () => {
|
||||||
|
const previous = process.env.CLAUDE_CODE_CHILD_SESSION;
|
||||||
|
process.env.CLAUDE_CODE_CHILD_SESSION = '1';
|
||||||
|
try {
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
|
||||||
|
// un `claude` tapé à la main dans ce shell hériterait sinon du même marqueur
|
||||||
|
expect(buildSpawnSpec({ command: 'bash' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).env.TERM).toBe('xterm-256color');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CLAUDE_CODE_CHILD_SESSION;
|
||||||
|
else process.env.CLAUDE_CODE_CHILD_SESSION = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
xmlEscape,
|
xmlEscape,
|
||||||
systemdUnitPath,
|
systemdUnitPath,
|
||||||
launchAgentPlistPath,
|
launchAgentPlistPath,
|
||||||
|
windowsCreateArgs,
|
||||||
} from '../src/cli/install.js';
|
} from '../src/cli/install.js';
|
||||||
|
|
||||||
describe('cli install · detectPlatform', () => {
|
describe('cli install · detectPlatform', () => {
|
||||||
@@ -20,9 +21,10 @@ describe('cli install · detectPlatform', () => {
|
|||||||
expect(detectPlatform('darwin')).toBe('darwin');
|
expect(detectPlatform('darwin')).toBe('darwin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejette les autres plateformes avec un message clair', () => {
|
it('rejette les plateformes sans superviseur connu, avec un message clair', () => {
|
||||||
expect(() => detectPlatform('win32')).toThrow(/Linux \(systemd\) and macOS \(launchd\)/);
|
// win32 est désormais SUPPORTÉ (Planificateur de tâches) : cf. la suite dédiée plus bas.
|
||||||
expect(() => detectPlatform('freebsd')).toThrow(/freebsd/);
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import {
|
|||||||
amendCommit,
|
amendCommit,
|
||||||
lastCommit,
|
lastCommit,
|
||||||
isUnpushed,
|
isUnpushed,
|
||||||
|
commitLog,
|
||||||
|
commitDiff,
|
||||||
|
isValidCommitish,
|
||||||
|
parseLogZ,
|
||||||
} from '../src/core/git.js';
|
} from '../src/core/git.js';
|
||||||
import { appendFileSync } from 'node:fs';
|
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();
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { detectLaunchCommands } from '../src/core/launch-detect.js';
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'launch-detect-'));
|
||||||
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
function fixture(name: string, files: Record<string, string>): string {
|
||||||
|
const dir = join(tmp, name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const [f, content] of Object.entries(files)) writeFileSync(join(dir, f), content);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectLaunchCommands', () => {
|
||||||
|
it('extrait les scripts npm (par défaut : npm run), active les scripts de dev', () => {
|
||||||
|
const dir = fixture('npm', {
|
||||||
|
'package.json': JSON.stringify({ scripts: { dev: 'vite', build: 'vite build', 'test:unit': 'vitest' } }),
|
||||||
|
});
|
||||||
|
const cmds = detectLaunchCommands(dir);
|
||||||
|
const dev = cmds.find((c) => c.run === 'npm run dev');
|
||||||
|
const build = cmds.find((c) => c.run === 'npm run build');
|
||||||
|
expect(dev?.enabled).toBe(true); // heuristique : dev/start/serve/watch activés
|
||||||
|
expect(build?.enabled).toBe(false);
|
||||||
|
expect(cmds.some((c) => c.run === 'npm run test:unit')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('utilise pnpm/yarn selon le lockfile présent', () => {
|
||||||
|
const pnpm = fixture('pnpm', { 'package.json': JSON.stringify({ scripts: { dev: 'x' } }), 'pnpm-lock.yaml': '' });
|
||||||
|
expect(detectLaunchCommands(pnpm).find((c) => c.label === 'dev')?.run).toBe('pnpm run dev');
|
||||||
|
const yarn = fixture('yarn', { 'package.json': JSON.stringify({ scripts: { dev: 'x' } }), 'yarn.lock': '' });
|
||||||
|
expect(detectLaunchCommands(yarn).find((c) => c.label === 'dev')?.run).toBe('yarn dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lit le Procfile (name: command), toutes activées', () => {
|
||||||
|
const dir = fixture('proc', { Procfile: 'web: bundle exec puma\nworker: rake jobs:work\n# comment\n' });
|
||||||
|
const cmds = detectLaunchCommands(dir);
|
||||||
|
expect(cmds.find((c) => c.label === 'web')?.run).toBe('bundle exec puma');
|
||||||
|
expect(cmds.find((c) => c.label === 'worker')?.enabled).toBe(true);
|
||||||
|
expect(cmds).toHaveLength(2); // la ligne de commentaire est ignorée
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggère docker compose up si un fichier compose est présent', () => {
|
||||||
|
const dir = fixture('compose', { 'compose.yaml': 'services: {}' });
|
||||||
|
expect(detectLaunchCommands(dir).some((c) => c.run === 'docker compose up')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolère un répertoire vide ou un package.json invalide', () => {
|
||||||
|
expect(detectLaunchCommands(join(tmp, 'inexistant'))).toEqual([]);
|
||||||
|
const bad = fixture('bad', { 'package.json': '{ not json' });
|
||||||
|
expect(detectLaunchCommands(bad)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,10 +77,11 @@ type BindingSpies = ClientBinding & {
|
|||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
let channelSeq = 1;
|
let channelSeq = 1;
|
||||||
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
function makeBinding(mode: 'interactive' | 'observer', screen = true): BindingSpies {
|
||||||
return {
|
return {
|
||||||
channel: channelSeq++,
|
channel: channelSeq++,
|
||||||
mode,
|
mode,
|
||||||
|
screen,
|
||||||
controlling: false,
|
controlling: false,
|
||||||
sentBytes: 0,
|
sentBytes: 0,
|
||||||
ackedBytes: 0,
|
ackedBytes: 0,
|
||||||
@@ -252,10 +253,12 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
|
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
const res = manager.attach(summary.id, b, 80, 24);
|
const res = manager.attach(summary.id, b, 80, 24);
|
||||||
expect(res).toEqual({ ok: true, controlling: true });
|
expect(res).toMatchObject({ ok: true, controlling: true });
|
||||||
|
|
||||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
// Le replay est RENVOYÉ (la gateway l'émet après `attached`), jamais envoyé par attach :
|
||||||
const payload = b.sendResync.mock.calls[0]![0]!;
|
// une frame binaire qui précède `attached` tombe sur un canal inconnu du client et est jetée.
|
||||||
|
expect(b.sendResync).not.toHaveBeenCalled();
|
||||||
|
const payload = (res as { replay: Buffer }).replay;
|
||||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||||
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
|
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
|
||||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||||
@@ -264,12 +267,31 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
expect(b.ackedBytes).toBe(0);
|
expect(b.ackedBytes).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ring vide → resync avec payload vide', () => {
|
it('ring vide → replay vide', () => {
|
||||||
const { summary } = spawnBash();
|
const { summary } = spawnBash();
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
manager.attach(summary.id, b, 80, 24);
|
const res = manager.attach(summary.id, b, 80, 24);
|
||||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
expect((res as { replay: Buffer }).replay.length).toBe(0);
|
||||||
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
|
expect(b.sendResync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attache sans écran : ni contrôle, ni resize, ni sortie, ni replay', () => {
|
||||||
|
const { summary, pty } = spawnBash();
|
||||||
|
pty.emitData('hello');
|
||||||
|
const blind = makeBinding('interactive', false);
|
||||||
|
const res = manager.attach(summary.id, blind, 120, 32);
|
||||||
|
expect(res).toMatchObject({ ok: true, controlling: false });
|
||||||
|
expect((res as { replay: Buffer }).replay.length).toBe(0);
|
||||||
|
expect(pty.resize).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// le vrai terminal ouvert ensuite prend bien le contrôle et impose SA géométrie
|
||||||
|
const real = makeBinding('interactive');
|
||||||
|
expect(manager.attach(summary.id, real, 200, 50)).toMatchObject({ ok: true, controlling: true });
|
||||||
|
expect(pty.resize).toHaveBeenCalledWith(200, 50);
|
||||||
|
|
||||||
|
pty.emitData('world');
|
||||||
|
expect(blind.sendOutput).not.toHaveBeenCalled();
|
||||||
|
expect(real.sendOutput).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
|
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
|
||||||
@@ -278,13 +300,13 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
const a = makeBinding('interactive');
|
const a = makeBinding('interactive');
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
|
|
||||||
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
|
expect(manager.attach(summary.id, obs, 80, 24)).toMatchObject({ ok: true, controlling: false });
|
||||||
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
||||||
|
|
||||||
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
|
expect(manager.attach(summary.id, a, 100, 30)).toMatchObject({ ok: true, controlling: true });
|
||||||
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
||||||
|
|
||||||
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
|
expect(manager.attach(summary.id, b, 200, 50)).toMatchObject({ ok: true, controlling: false });
|
||||||
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
|
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
|
||||||
expect(manager.get(summary.id)?.clients).toBe(3);
|
expect(manager.get(summary.id)?.clients).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -531,8 +553,9 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
|
|
||||||
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
|
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
|
||||||
expect(a.lagging).toBe(false);
|
expect(a.lagging).toBe(false);
|
||||||
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
|
// seul le rattrapage passe par sendResync : le replay d'attache est renvoyé à la gateway
|
||||||
const payload = a.sendResync.mock.calls[1]![0]!;
|
expect(a.sendResync).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = a.sendResync.mock.calls[0]![0]!;
|
||||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||||
expect(a.sentBytes).toBe(0);
|
expect(a.sentBytes).toBe(0);
|
||||||
@@ -592,7 +615,7 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
manager.attach(summary.id, a, 80, 24);
|
manager.attach(summary.id, a, 80, 24);
|
||||||
manager.detach(summary.id, a);
|
manager.detach(summary.id, a);
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
|
expect(manager.attach(summary.id, b, 80, 24)).toMatchObject({ ok: true, controlling: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cacheControlFor } from '../src/app.js';
|
||||||
|
|
||||||
|
// Régression : à la mise à jour de l'app desktop, l'index.html mis en cache par le client était
|
||||||
|
// revalidé en 304 (etag = taille+mtime, mtime figé à 1985 par npm pack dans le tarball) et
|
||||||
|
// continuait donc de référencer des /assets/<hash> disparus → modules servis en text/html par le
|
||||||
|
// fallback SPA → page noire. Seuls les noms hachés sont cachables.
|
||||||
|
describe('cacheControlFor', () => {
|
||||||
|
it('rend les assets hachés cachables indéfiniment', () => {
|
||||||
|
expect(cacheControlFor('/assets/index-94trXkeo.js')).toBe('public, max-age=31536000, immutable');
|
||||||
|
expect(cacheControlFor('/assets/inter-latin-wght-normal-Dx4kXJAl.woff2')).toBe(
|
||||||
|
'public, max-age=31536000, immutable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interdit la mise en cache de l'index.html", () => {
|
||||||
|
expect(cacheControlFor('/index.html')).toBe('no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interdit la mise en cache des fichiers racine non hachés', () => {
|
||||||
|
for (const p of ['/sw.js', '/theme-boot.js', '/manifest.webmanifest', '/favicon.ico', '/icon-512.png']) {
|
||||||
|
expect(cacheControlFor(p)).toBe('no-store');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -212,6 +212,33 @@ describe('WorktreeManager', () => {
|
|||||||
expect(w.git.dirtyCount).toBe(0);
|
expect(w.git.dirtyCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Les vues à contenu paresseux (liste de fichiers modifiés, historique) ne se rechargent QUE sur
|
||||||
|
// `worktree_changes`. Il manquait sur commit, fetch et push, d'où un badge « non poussé » périmé et
|
||||||
|
// des fichiers déjà commités encore listés.
|
||||||
|
it('commit, fetch et push émettent worktree_changes (pas seulement worktree_update)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const remote = mkdtempSync(join(tmpdir(), 'arb-remote-'));
|
||||||
|
dirs.push(remote);
|
||||||
|
execFileSync('git', ['init', '--bare', '-b', 'main'], { cwd: remote, stdio: 'pipe' });
|
||||||
|
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: repo, stdio: 'pipe' });
|
||||||
|
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const changed: Array<{ repoId: string; path: string }> = [];
|
||||||
|
wt.on('worktree_changes', (e) => changed.push(e));
|
||||||
|
|
||||||
|
writeFileSync(join(repo, 'f.txt'), 'x\n');
|
||||||
|
await wt.commitWorktree(r.id, repo, 'add f');
|
||||||
|
expect(changed).toHaveLength(1);
|
||||||
|
|
||||||
|
const pushed = await wt.pushWorktree(r.id, repo);
|
||||||
|
expect(changed).toHaveLength(2);
|
||||||
|
expect(pushed.git.ahead).toBe(0); // documente le nouveau canPush : plus rien à pousser
|
||||||
|
|
||||||
|
await wt.fetch(r.id, repo);
|
||||||
|
expect(changed).toHaveLength(3);
|
||||||
|
expect(changed.every((e) => e.repoId === r.id && resolve(e.path) === resolve(repo))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => {
|
it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => {
|
||||||
const repo = makeTmpRepo();
|
const repo = makeTmpRepo();
|
||||||
const r = await wt.addRepo({ path: repo });
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Ordre des trames à l'attache : régression de « le terminal reste tout noir alors que la session
|
||||||
|
// tourne ». Le replay d'attache est une frame BINAIRE ; le client n'apprend le numéro de canal
|
||||||
|
// qu'avec le message `attached`, et jette toute frame binaire portant un canal inconnu. Émettre le
|
||||||
|
// replay avant `attached` revenait donc à ne rien afficher jusqu'au prochain octet spontané du PTY,
|
||||||
|
// c'est-à-dire jamais pour un TUI au repos (Claude à son prompt).
|
||||||
|
//
|
||||||
|
// On instrumente la gateway avec un faux socket et de faux bus d'événements : c'est le seul moyen
|
||||||
|
// d'observer l'ORDRE réel des `socket.send` sans monter un vrai serveur WebSocket (couvert par
|
||||||
|
// scripts/acceptance-p17.mjs).
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { BINARY_FRAME, decodeBinaryFrame, PROTOCOL_VERSION } from '@arboretum/shared';
|
||||||
|
import { registerWsGateway } from '../src/ws/gateway.js';
|
||||||
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
|
||||||
|
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
|
||||||
|
|
||||||
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
|
let nextPid = 200_000;
|
||||||
|
class FakePtyImpl {
|
||||||
|
pid = nextPid++;
|
||||||
|
write = vi.fn();
|
||||||
|
resize = vi.fn();
|
||||||
|
pause = vi.fn();
|
||||||
|
resume = vi.fn();
|
||||||
|
kill = vi.fn();
|
||||||
|
private dataCbs: Array<(d: string) => void> = [];
|
||||||
|
constructor(
|
||||||
|
readonly file: string,
|
||||||
|
readonly args: string[],
|
||||||
|
readonly opts: unknown,
|
||||||
|
) {}
|
||||||
|
onData(cb: (d: string) => void): { dispose: () => void } {
|
||||||
|
this.dataCbs.push(cb);
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
onExit(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
emitData(d: string): void {
|
||||||
|
for (const cb of this.dataCbs) cb(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
|
||||||
|
const p = new FakePtyImpl(file, args, opts);
|
||||||
|
ptyMock.instances.push(p);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bus d'événements inerte : la gateway s'abonne à 7 services dont un seul nous intéresse. */
|
||||||
|
const inertBus = (): { on: () => void; off: () => void } => ({ on: () => {}, off: () => {} });
|
||||||
|
|
||||||
|
interface FakeSocket {
|
||||||
|
readyState: number;
|
||||||
|
OPEN: number;
|
||||||
|
sent: Array<string | Uint8Array>;
|
||||||
|
send(data: string | Uint8Array): void;
|
||||||
|
on(event: string, cb: (...args: unknown[]) => void): void;
|
||||||
|
ping(): void;
|
||||||
|
terminate(): void;
|
||||||
|
close(): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSocket(): FakeSocket {
|
||||||
|
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||||
|
return {
|
||||||
|
readyState: 1,
|
||||||
|
OPEN: 1,
|
||||||
|
sent: [],
|
||||||
|
send(data) {
|
||||||
|
this.sent.push(data);
|
||||||
|
},
|
||||||
|
on(event, cb) {
|
||||||
|
const list = handlers.get(event) ?? [];
|
||||||
|
list.push(cb);
|
||||||
|
handlers.set(event, list);
|
||||||
|
},
|
||||||
|
ping() {},
|
||||||
|
terminate() {},
|
||||||
|
close() {},
|
||||||
|
emit(event, ...args) {
|
||||||
|
for (const cb of handlers.get(event) ?? []) cb(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gateway · ordre des trames à l’attache', () => {
|
||||||
|
let db: Db;
|
||||||
|
let manager: PtyManager;
|
||||||
|
let socket: FakeSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ptyMock.instances.length = 0;
|
||||||
|
db = openDb(':memory:');
|
||||||
|
manager = new PtyManager(db);
|
||||||
|
socket = makeSocket();
|
||||||
|
|
||||||
|
let handler: ((s: unknown, req: unknown) => void) | null = null;
|
||||||
|
const app = {
|
||||||
|
get: (_path: string, _opts: unknown, h: (s: unknown, req: unknown) => void) => {
|
||||||
|
handler = h;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerWsGateway(
|
||||||
|
app as never,
|
||||||
|
manager,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
'0.0.0-test',
|
||||||
|
);
|
||||||
|
handler!(socket, {});
|
||||||
|
socket.emit('message', Buffer.from(JSON.stringify({ type: 'hello', protocol: PROTOCOL_VERSION })), false);
|
||||||
|
socket.sent.length = 0; // on ignore le hello_ok
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (msg: unknown): void => socket.emit('message', Buffer.from(JSON.stringify(msg)), false);
|
||||||
|
const texts = (): Array<Record<string, unknown>> =>
|
||||||
|
socket.sent.filter((f): f is string => typeof f === 'string').map((f) => JSON.parse(f) as Record<string, unknown>);
|
||||||
|
|
||||||
|
it('`attached` d’abord, replay binaire ENSUITE, sur le même canal', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
(ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('prompt$ ');
|
||||||
|
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
expect(socket.sent).toHaveLength(2);
|
||||||
|
const attached = JSON.parse(socket.sent[0] as string) as { type: string; channel: number };
|
||||||
|
expect(attached.type).toBe('attached');
|
||||||
|
|
||||||
|
const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array);
|
||||||
|
expect(frame.type).toBe(BINARY_FRAME.RESYNC);
|
||||||
|
expect(frame.channel).toBe(attached.channel);
|
||||||
|
expect(Buffer.from(frame.payload).toString()).toBe('prompt$ ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ring vide : le resync part quand même (il porte l’ordre de reset)', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
expect(socket.sent).toHaveLength(2);
|
||||||
|
const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array);
|
||||||
|
expect(frame.type).toBe(BINARY_FRAME.RESYNC);
|
||||||
|
expect(frame.payload.byteLength).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attache sans écran : `attached` seul, aucune frame binaire', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
(ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('bruit');
|
||||||
|
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 120, rows: 32, screen: false });
|
||||||
|
|
||||||
|
expect(texts().map((m) => m.type)).toEqual(['attached']);
|
||||||
|
expect(socket.sent.every((f) => typeof f === 'string')).toBe(true);
|
||||||
|
expect(texts()[0]).toMatchObject({ controlling: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un observer reçoit aussi son replay (il peint, lui)', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
(ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('ecran');
|
||||||
|
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'observer', cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array);
|
||||||
|
expect(frame.type).toBe(BINARY_FRAME.RESYNC);
|
||||||
|
expect(Buffer.from(frame.payload).toString()).toBe('ecran');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session introuvable : erreur seule, pas de canal ni de frame binaire', () => {
|
||||||
|
send({ type: 'attach', sessionId: 'inconnue', mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
expect(texts()).toEqual([{ type: 'error', code: 'NOT_FOUND', message: 'Cannot attach: NOT_FOUND' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Types REST partagés (préfixe /api/v1).
|
// Types REST partagés (préfixe /api/v1).
|
||||||
import type { CloneOperation, GroupSummary, PostCreateHook, RepoSummary, SessionSummary, SettingsBroadcast, WorktreeSummary } from './protocol.js';
|
import type { CloneOperation, GroupSummary, LaunchCommand, PostCreateHook, RepoSummary, SessionSummary, SettingsBroadcast, WorktreeSummary } from './protocol.js';
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string; details?: unknown };
|
error: { code: string; message: string; details?: unknown };
|
||||||
@@ -99,6 +99,8 @@ export interface CreateRepoRequest {
|
|||||||
label?: string;
|
label?: string;
|
||||||
postCreateHooks?: PostCreateHook[];
|
postCreateHooks?: PostCreateHook[];
|
||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
|
/** commandes de démarrage du projet (« Démarrer le projet »). */
|
||||||
|
launchCommands?: LaunchCommand[];
|
||||||
}
|
}
|
||||||
export interface UpdateRepoRequest {
|
export interface UpdateRepoRequest {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -106,6 +108,8 @@ export interface UpdateRepoRequest {
|
|||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
/** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */
|
/** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** commandes de démarrage du projet (« Démarrer le projet »). */
|
||||||
|
launchCommands?: LaunchCommand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résultat d'un scan de découverte (POST /api/v1/repos/discover). */
|
/** Résultat d'un scan de découverte (POST /api/v1/repos/discover). */
|
||||||
@@ -233,6 +237,37 @@ export interface FileDiffResponse {
|
|||||||
/** texte du diff unifié git (vide si binaire ou tooLarge). */
|
/** texte du diff unifié git (vide si binaire ou tooLarge). */
|
||||||
diff: string;
|
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). */
|
/** GET /api/v1/repos/:id/files/content?wt=&path= : contenu d'un fichier (pour l'éditeur Monaco). */
|
||||||
export interface FileContentResponse {
|
export interface FileContentResponse {
|
||||||
/** chemin relatif au worktree (POSIX). */
|
/** chemin relatif au worktree (POSIX). */
|
||||||
@@ -313,6 +348,31 @@ export interface StartRepoSessionRequest {
|
|||||||
newBranch?: boolean;
|
newBranch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- « Démarrer le projet » : lancement multi-terminaux ----
|
||||||
|
/**
|
||||||
|
* POST /api/v1/repos/:id/launch : lance un terminal par commande de démarrage activée du repo.
|
||||||
|
* Le worktree cible est résolu côté serveur (le client ne passe jamais de chemin absolu brut) :
|
||||||
|
* `worktreePath` prioritaire, sinon worktree de `branch`, sinon checkout principal.
|
||||||
|
*/
|
||||||
|
export interface StartLaunchRequest {
|
||||||
|
/** worktree cible (chemin absolu d'un worktree connu du repo) ; défaut : checkout principal. */
|
||||||
|
worktreePath?: string;
|
||||||
|
/** alternative à `worktreePath` : worktree portant cette branche. */
|
||||||
|
branch?: string;
|
||||||
|
/** ids des commandes à lancer (défaut : toutes les commandes activées du repo). */
|
||||||
|
commandIds?: string[];
|
||||||
|
}
|
||||||
|
export interface StartLaunchResponse {
|
||||||
|
/** un terminal (session managée) par commande lancée, partageant le même launchRunId. */
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
/** commandes non lancées (répertoire cible introuvable, cwd invalide…). */
|
||||||
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
|
}
|
||||||
|
/** GET /api/v1/repos/:id/launch/detect : suggestions de commandes détectées dans le projet. */
|
||||||
|
export interface DetectLaunchResponse {
|
||||||
|
suggestions: LaunchCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Groupes de travail (P5) ----
|
// ---- Groupes de travail (P5) ----
|
||||||
export interface GroupsListResponse {
|
export interface GroupsListResponse {
|
||||||
groups: GroupSummary[];
|
groups: GroupSummary[];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './protocol.js';
|
export * from './protocol.js';
|
||||||
export * from './api.js';
|
export * from './api.js';
|
||||||
export * from './wt-key.js';
|
export * from './wt-key.js';
|
||||||
|
export * from './path-match.js';
|
||||||
|
|||||||
@@ -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<T extends { path: string }>(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;
|
||||||
|
}
|
||||||
@@ -122,6 +122,9 @@ export interface SessionSummary {
|
|||||||
addedDirs?: string[];
|
addedDirs?: string[];
|
||||||
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
|
// ---- Lancement de projet multi-terminaux (additif) ----
|
||||||
|
/** identifiant partagé par tous les terminaux d'un même « Démarrer le projet » ; null/absent sinon. */
|
||||||
|
launchRunId?: string | null;
|
||||||
// ---- Masquage (additif) ----
|
// ---- Masquage (additif) ----
|
||||||
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -139,6 +142,22 @@ export interface PostCreateHook {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commande de démarrage d'un projet (« Démarrer le projet ») : une commande shell longue durée
|
||||||
|
* lancée dans un terminal PTY managé (serveur front, back, base…). Plusieurs commandes activées
|
||||||
|
* = plusieurs terminaux ouverts d'un seul geste. Persistée en JSON sur le repo.
|
||||||
|
*/
|
||||||
|
export interface LaunchCommand {
|
||||||
|
id: string;
|
||||||
|
/** libellé court affiché dans l'onglet du terminal (ex. « web », « api »). */
|
||||||
|
label: string;
|
||||||
|
/** commande shell auto-tapée dans un shell de login interactif (ex. `npm run dev`). */
|
||||||
|
run: string;
|
||||||
|
/** sous-répertoire relatif au worktree où exécuter la commande (borné, jamais hors du worktree). */
|
||||||
|
cwd?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RepoSummary {
|
export interface RepoSummary {
|
||||||
id: string;
|
id: string;
|
||||||
/** chemin absolu de la racine du repo (main worktree). */
|
/** chemin absolu de la racine du repo (main worktree). */
|
||||||
@@ -146,6 +165,8 @@ export interface RepoSummary {
|
|||||||
label: string;
|
label: string;
|
||||||
defaultBranch: string | null;
|
defaultBranch: string | null;
|
||||||
postCreateHooks: PostCreateHook[];
|
postCreateHooks: PostCreateHook[];
|
||||||
|
/** commandes de démarrage du projet (« Démarrer le projet ») ; [] si aucune définie. */
|
||||||
|
launchCommands: LaunchCommand[];
|
||||||
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
||||||
preTrust: boolean;
|
preTrust: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -245,7 +266,11 @@ export interface CloneOperation {
|
|||||||
// ---- Messages client → serveur ----
|
// ---- Messages client → serveur ----
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'hello'; protocol: number }
|
| { type: 'hello'; protocol: number }
|
||||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
// `screen` (défaut true) : false = le client n'affiche RIEN, il attache seulement pour écrire
|
||||||
|
// (ex. répondre à un dialogue depuis la liste « À traiter »). Le serveur ne lui envoie alors ni
|
||||||
|
// sortie ni replay, ne lui confie jamais le contrôle et ignore ses dimensions : sans ce drapeau,
|
||||||
|
// une telle attache volait le `controlling` et figeait la géométrie du TUI pour le vrai terminal.
|
||||||
|
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number; screen?: boolean }
|
||||||
| { type: 'detach'; channel: number }
|
| { type: 'detach'; channel: number }
|
||||||
| { type: 'stdin'; channel: number; data: string }
|
| { type: 'stdin'; channel: number; data: string }
|
||||||
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
||||||
@@ -319,7 +344,7 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
|
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
|
||||||
case 'attach':
|
case 'attach':
|
||||||
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
||||||
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
|
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows, screen: typeof m.screen === 'boolean' ? m.screen : true }
|
||||||
: null;
|
: null;
|
||||||
case 'detach':
|
case 'detach':
|
||||||
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,13 +77,24 @@ describe('parseClientMessage · cas valides', () => {
|
|||||||
it('attach avec dimensions aux bornes', () => {
|
it('attach avec dimensions aux bornes', () => {
|
||||||
const make = (cols: number, rows: number): string =>
|
const make = (cols: number, rows: number): string =>
|
||||||
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
|
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
|
||||||
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2 });
|
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2, screen: true });
|
||||||
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000 });
|
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000, screen: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attach en mode observer', () => {
|
it('attach en mode observer', () => {
|
||||||
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
|
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
|
||||||
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 });
|
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24, screen: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// `screen` est ADDITIF : absent, il vaut true (un vieux client garde le comportement d'origine).
|
||||||
|
// À false, l'attache n'affiche rien : ni contrôle du PTY, ni sortie, ni replay.
|
||||||
|
it('attach avec screen : défaut true, false respecté, valeur non booléenne ignorée', () => {
|
||||||
|
const attach = (screen?: unknown): unknown =>
|
||||||
|
parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, screen }));
|
||||||
|
expect(attach(false)).toMatchObject({ screen: false });
|
||||||
|
expect(attach(true)).toMatchObject({ screen: true });
|
||||||
|
expect(attach()).toMatchObject({ screen: true });
|
||||||
|
expect(attach('nope')).toMatchObject({ screen: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detach avec channel aux bornes u32', () => {
|
it('detach avec channel aux bornes u32', () => {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
<!-- Anti-FOUC : applique la préférence de thème (arb-theme) avant le premier paint.
|
<!-- Anti-FOUC : applique la préférence de thème (arb.theme, même clé que l'app) avant le premier paint.
|
||||||
Doit rester inline/synchrone. Synchronisé avec src/lib/theme.ts. -->
|
Doit rester inline/synchrone. Synchronisé avec src/lib/theme.ts. -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var mode = localStorage.getItem('arb-theme') || 'dark';
|
var mode = localStorage.getItem('arb.theme') || 'dark';
|
||||||
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
var theme = dark ? 'dark' : 'light';
|
var theme = dark ? 'dark' : 'light';
|
||||||
var bg = dark ? '#09090b' : '#fafafa';
|
var bg = dark ? '#09090b' : '#fafafa';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/site",
|
"name": "@arboretum/site",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://git-arboretum.com/</loc>
|
<loc>https://git-arboretum.com/</loc>
|
||||||
<lastmod>2026-06-19</lastmod>
|
<lastmod>2026-08-04</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import ProblemSection from './components/ProblemSection.vue';
|
|||||||
import FeaturesSection from './components/FeaturesSection.vue';
|
import FeaturesSection from './components/FeaturesSection.vue';
|
||||||
import ShowcaseSection from './components/ShowcaseSection.vue';
|
import ShowcaseSection from './components/ShowcaseSection.vue';
|
||||||
import WorkspaceShowcase from './components/WorkspaceShowcase.vue';
|
import WorkspaceShowcase from './components/WorkspaceShowcase.vue';
|
||||||
|
import LaunchShowcase from './components/LaunchShowcase.vue';
|
||||||
import DownloadSection from './components/DownloadSection.vue';
|
import DownloadSection from './components/DownloadSection.vue';
|
||||||
|
import AccessSection from './components/AccessSection.vue';
|
||||||
import RemoteGitSection from './components/RemoteGitSection.vue';
|
import RemoteGitSection from './components/RemoteGitSection.vue';
|
||||||
import WorkGroupsSection from './components/WorkGroupsSection.vue';
|
import WorkGroupsSection from './components/WorkGroupsSection.vue';
|
||||||
import HowItWorksSection from './components/HowItWorksSection.vue';
|
import HowItWorksSection from './components/HowItWorksSection.vue';
|
||||||
@@ -15,6 +17,7 @@ import SecuritySection from './components/SecuritySection.vue';
|
|||||||
import FaqSection from './components/FaqSection.vue';
|
import FaqSection from './components/FaqSection.vue';
|
||||||
import FinalCta from './components/FinalCta.vue';
|
import FinalCta from './components/FinalCta.vue';
|
||||||
import AppFooter from './components/AppFooter.vue';
|
import AppFooter from './components/AppFooter.vue';
|
||||||
|
import BackToTop from './components/BackToTop.vue';
|
||||||
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
|
|
||||||
@@ -68,7 +71,9 @@ const glowStyle = {
|
|||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
<ShowcaseSection />
|
<ShowcaseSection />
|
||||||
<WorkspaceShowcase />
|
<WorkspaceShowcase />
|
||||||
|
<LaunchShowcase />
|
||||||
<DownloadSection />
|
<DownloadSection />
|
||||||
|
<AccessSection />
|
||||||
<RemoteGitSection />
|
<RemoteGitSection />
|
||||||
<WorkGroupsSection />
|
<WorkGroupsSection />
|
||||||
<HowItWorksSection />
|
<HowItWorksSection />
|
||||||
@@ -78,5 +83,6 @@ const glowStyle = {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<BackToTop />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Les trois façons d'utiliser Arboretum. Le « mode serveur web » (le daemon servi à ses propres
|
||||||
|
// appareils) n'était décrit nulle part sur le site, alors que c'est l'usage central du produit ; et
|
||||||
|
// l'extension VS Code n'avait qu'une carte de fonctionnalité, sans lien ni mode d'emploi.
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DOWNLOADS, DESKTOP_SRC } from '../lib/links';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const ways = [
|
||||||
|
{
|
||||||
|
key: 'wayDesktop',
|
||||||
|
body: 'wayDesktopBody',
|
||||||
|
code: null,
|
||||||
|
link: { href: DOWNLOADS.deb, label: 'wayDesktopLink' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wayServer',
|
||||||
|
body: 'wayServerBody',
|
||||||
|
code: 'tailscale serve --bg 7317',
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wayVscode',
|
||||||
|
body: 'wayVscodeBody',
|
||||||
|
code: null,
|
||||||
|
link: { href: DOWNLOADS.vsix, label: 'wayVscodeLink' },
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="access" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div v-reveal class="mb-[40px] max-w-[720px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('wayKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,38px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
|
{{ t('wayTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('wayBody') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-reveal class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="w in ways"
|
||||||
|
:key="w.key"
|
||||||
|
class="flex flex-col gap-3 rounded-[14px] border border-border bg-surface-0 p-5 shadow-card"
|
||||||
|
>
|
||||||
|
<h3 class="m-0 text-[15px] font-semibold text-fg">{{ t(w.key) }}</h3>
|
||||||
|
<p class="m-0 flex-1 text-[14px] leading-[1.55] text-fg-muted">{{ t(w.body) }}</p>
|
||||||
|
<code
|
||||||
|
v-if="w.code"
|
||||||
|
class="block overflow-x-auto whitespace-nowrap rounded-lg border border-border bg-surface-1 px-3 py-2 font-mono text-[12.5px] text-fg"
|
||||||
|
>
|
||||||
|
<span class="text-accent">$ </span>{{ w.code }}
|
||||||
|
</code>
|
||||||
|
<a
|
||||||
|
v-if="w.link"
|
||||||
|
:href="w.link.href"
|
||||||
|
class="text-[13.5px] font-medium text-accent no-underline hover:underline"
|
||||||
|
>
|
||||||
|
{{ t(w.link.label) }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-reveal class="mt-6 max-w-[760px] text-[13.5px] leading-[1.6] text-fg-subtle">{{ t('wayOriginNote') }}</p>
|
||||||
|
<p v-reveal class="mt-2 max-w-[760px] text-[13.5px] leading-[1.6] text-fg-subtle">
|
||||||
|
{{ t('wayBuildNote') }}
|
||||||
|
<a :href="DESKTOP_SRC" target="_blank" rel="noopener" class="text-fg-muted underline decoration-border underline-offset-2 hover:text-accent">
|
||||||
|
packages/desktop
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { INSTALL_COMMAND } from '../composables/useCopy';
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
import { REPO, LICENSE, COFFEE } from '../lib/links';
|
import { COFFEE, LICENSE, REPO, VERSIONS } from '../lib/links';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
import CopyButton from './CopyButton.vue';
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@ const { t } = useI18n();
|
|||||||
<code class="font-mono text-[12.5px] text-fg-subtle">{{ INSTALL_COMMAND }}</code>
|
<code class="font-mono text-[12.5px] text-fg-subtle">{{ INSTALL_COMMAND }}</code>
|
||||||
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Versions publiées : le site n'en affichait aucune, impossible de savoir ce qu'il décrit. -->
|
||||||
|
<span class="font-mono text-[12px] text-fg-subtle">
|
||||||
|
daemon {{ VERSIONS.daemon }} · desktop {{ VERSIONS.desktop }} · vscode {{ VERSIONS.vscode }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { REPO } from '../lib/links';
|
import { REPO } from '../lib/links';
|
||||||
import LangToggle from './LangToggle.vue';
|
import LangToggle from './LangToggle.vue';
|
||||||
@@ -7,22 +8,50 @@ import IconGitea from './icons/IconGitea.vue';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation du site. `tier` = largeur à partir de laquelle le lien apparaît dans la barre :
|
||||||
|
* 1 = dès 900px, 2 = à partir de 1024px, 3 = à partir de 1180px.
|
||||||
|
*
|
||||||
|
* Les huit liens ne tiennent qu'au-delà de ~1180px. En dessous, la barre flex les compressait au lieu
|
||||||
|
* de les masquer, cassant « Start project », « Git services » et « How it works » sur deux ou trois
|
||||||
|
* lignes, chevauchant le logo et expulsant le bouton Gitea. Le menu compact, lui, montre toujours la
|
||||||
|
* liste complète : aucun lien n'est perdu, il change juste de place.
|
||||||
|
*/
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: '#features', key: 'navFeatures' },
|
{ href: '#features', key: 'navFeatures', tier: 1 },
|
||||||
{ href: '#workspace', key: 'navWorkspace' },
|
{ href: '#workspace', key: 'navWorkspace', tier: 1 },
|
||||||
{ href: '#download', key: 'navDownload' },
|
{ href: '#launch', key: 'navLaunch', tier: 2 },
|
||||||
{ href: '#how', key: 'navHow' },
|
{ href: '#remotegit', key: 'navRemoteGit', tier: 3 },
|
||||||
{ href: '#security', key: 'navSecurity' },
|
{ href: '#download', key: 'navDownload', tier: 1 },
|
||||||
{ href: '#faq', key: 'navFaq' },
|
{ href: '#how', key: 'navHow', tier: 3 },
|
||||||
|
{ href: '#security', key: 'navSecurity', tier: 2 },
|
||||||
|
{ href: '#faq', key: 'navFaq', tier: 1 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** Tailwind ne génère que des classes littérales : la table évite toute classe construite à la volée. */
|
||||||
|
const TIER_CLASS: Record<number, string> = {
|
||||||
|
1: '',
|
||||||
|
2: 'hidden min-[1024px]:inline',
|
||||||
|
3: 'hidden min-[1180px]:inline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuOpen = ref(false);
|
||||||
|
const closeMenu = (): void => {
|
||||||
|
menuOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') closeMenu();
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]">
|
||||||
class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]"
|
<div class="mx-auto flex h-16 max-w-[1200px] items-center gap-4 px-6">
|
||||||
>
|
<!-- shrink-0 : le logo ne doit jamais être rogné ni recouvert par la nav. -->
|
||||||
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
|
<a href="#top" class="flex shrink-0 items-center gap-2.5 text-fg no-underline" @click="closeMenu">
|
||||||
<a href="#top" class="flex items-center gap-2.5 text-fg no-underline">
|
|
||||||
<img
|
<img
|
||||||
src="/assets/arboretum-mark.png"
|
src="/assets/arboretum-mark.png"
|
||||||
alt="Arboretum"
|
alt="Arboretum"
|
||||||
@@ -33,31 +62,83 @@ const navLinks = [
|
|||||||
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
|
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="hidden items-center gap-[30px] min-[900px]:flex">
|
<nav class="hidden min-w-0 flex-1 items-center justify-center gap-6 min-[900px]:flex min-[1180px]:gap-[30px]">
|
||||||
<a
|
<a
|
||||||
v-for="link in navLinks"
|
v-for="link in navLinks"
|
||||||
:key="link.href"
|
:key="link.href"
|
||||||
:href="link.href"
|
:href="link.href"
|
||||||
class="text-[14.5px] text-fg-muted no-underline transition-colors hover:text-fg"
|
class="shrink-0 whitespace-nowrap text-[14.5px] text-fg-muted no-underline transition-colors hover:text-fg"
|
||||||
|
:class="TIER_CLASS[link.tier]"
|
||||||
>
|
>
|
||||||
{{ t(link.key) }}
|
{{ t(link.key) }}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3.5">
|
<div class="ml-auto flex shrink-0 items-center gap-2 min-[900px]:ml-0 min-[900px]:gap-3.5">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
|
<!-- Sous 900px, Gitea vit dans le panneau : à 390px, logo + 3 contrôles + menu débordaient et
|
||||||
|
le bouton menu se retrouvait tronqué au bord de l'écran. -->
|
||||||
<a
|
<a
|
||||||
:href="REPO"
|
:href="REPO"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
aria-label="Gitea"
|
aria-label="Gitea"
|
||||||
class="inline-flex items-center gap-[7px] rounded-lg border border-border px-[13px] py-[7px] text-[13.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent hover:text-accent"
|
class="hidden items-center gap-[7px] rounded-lg border border-border px-[13px] py-[7px] text-[13.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent hover:text-accent min-[900px]:inline-flex"
|
||||||
|
>
|
||||||
|
<IconGitea :size="16" />
|
||||||
|
<!-- Libellé masqué tant que la barre est serrée : l'icône suffit, l'aria-label reste. -->
|
||||||
|
<span class="hidden min-[1180px]:inline">Gitea</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center rounded-lg border border-border p-[7px] text-fg-muted transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70 min-[900px]:hidden"
|
||||||
|
:aria-label="t('navMenu')"
|
||||||
|
:aria-expanded="menuOpen"
|
||||||
|
aria-controls="site-mobile-nav"
|
||||||
|
@click="menuOpen = !menuOpen"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<template v-if="menuOpen">
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M4 12h16" />
|
||||||
|
<path d="M4 17h16" />
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau compact sous 900px : liste complète, une entrée par ligne, refermée au choix d'un lien. -->
|
||||||
|
<nav
|
||||||
|
v-if="menuOpen"
|
||||||
|
id="site-mobile-nav"
|
||||||
|
class="border-t border-border-soft bg-surface-0/95 px-6 py-2 backdrop-blur-[14px] min-[900px]:hidden"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-for="link in navLinks"
|
||||||
|
:key="link.href"
|
||||||
|
:href="link.href"
|
||||||
|
class="block py-2 text-[15px] text-fg-muted no-underline transition-colors hover:text-fg"
|
||||||
|
@click="closeMenu"
|
||||||
|
>
|
||||||
|
{{ t(link.key) }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
:href="REPO"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="mt-1 flex items-center gap-2 border-t border-border-soft py-2.5 text-[15px] text-fg-muted no-underline transition-colors hover:text-accent"
|
||||||
|
@click="closeMenu"
|
||||||
>
|
>
|
||||||
<IconGitea :size="16" />
|
<IconGitea :size="16" />
|
||||||
Gitea
|
Gitea
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Bouton « remonter en haut », en bas à droite. Reprend les tokens existants (bordure `border`, fond
|
||||||
|
// `surface-1`, accent au survol, radius 12px, ombre `shadow-card`) : rien de neuf visuellement.
|
||||||
|
// Il n'apparaît qu'après un vrai défilement et disparaît en haut de page, pour ne jamais recouvrir le
|
||||||
|
// contenu sans raison.
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
/** Au-delà d'un écran de défilement, remonter rend un vrai service. */
|
||||||
|
const SHOW_AFTER = 600;
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const onScroll = (): void => {
|
||||||
|
visible.value = window.scrollY > SHOW_AFTER;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toTop(): void {
|
||||||
|
// Respecte la préférence système : pas de défilement animé si l'utilisateur les a réduites.
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="btt">
|
||||||
|
<button
|
||||||
|
v-if="visible"
|
||||||
|
type="button"
|
||||||
|
class="fixed right-5 bottom-5 z-40 inline-flex h-11 w-11 items-center justify-center rounded-[12px] border border-border bg-surface-1/90 text-fg-muted shadow-card backdrop-blur-[10px] transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70 sm:right-7 sm:bottom-7"
|
||||||
|
:aria-label="t('backToTop')"
|
||||||
|
:title="t('backToTop')"
|
||||||
|
@click="toTop"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btt-enter-active,
|
||||||
|
.btt-leave-active {
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.btt-enter-from,
|
||||||
|
.btt-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btt-enter-active,
|
||||||
|
.btt-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// Téléchargements réels : chaque plateforme porte son lien direct vers l'asset de la release flottante
|
||||||
|
// `desktop-latest` (le tag est recréé par la CI à chaque version, les URL restent donc valables).
|
||||||
|
// Avant, les trois cartes étaient décoratives et un unique bouton renvoyait vers la page des releases.
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { RELEASES, DESKTOP_SRC } from '../lib/links';
|
import { DOWNLOADS, DESKTOP_SRC, RELEASES, VERSIONS } from '../lib/links';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const platforms = [
|
const platforms = [
|
||||||
{ key: 'dlLinux', hint: 'dlLinuxHint' },
|
{ key: 'dlLinux', hint: 'dlLinuxHint', href: DOWNLOADS.deb },
|
||||||
{ key: 'dlWin', hint: 'dlWinHint' },
|
{ key: 'dlWin', hint: 'dlWinHint', href: DOWNLOADS.windows },
|
||||||
{ key: 'dlMac', hint: 'dlMacHint' },
|
// macOS n'est pas buildé par la CI (aucun runner) : la carte renvoie donc vers les sources.
|
||||||
|
{ key: 'dlMac', hint: 'dlMacHint', href: DESKTOP_SRC },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
||||||
@@ -21,21 +25,38 @@ const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
|||||||
{{ t('dlTitle') }}
|
{{ t('dlTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('dlBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('dlBody') }}</p>
|
||||||
|
<p class="mt-2 font-mono text-[12.5px] text-fg-subtle">{{ t('dlVersion', { version: VERSIONS.desktop }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-reveal class="grid gap-4 md:grid-cols-3">
|
<div v-reveal class="grid gap-4 md:grid-cols-3">
|
||||||
<div
|
<a
|
||||||
v-for="p in platforms"
|
v-for="p in platforms"
|
||||||
:key="p.key"
|
:key="p.key"
|
||||||
class="flex flex-col gap-1 rounded-[14px] border border-border bg-surface-0 p-5 shadow-card"
|
:href="p.href"
|
||||||
|
class="group flex flex-col gap-1 rounded-[14px] border border-border bg-surface-0 p-5 no-underline shadow-card transition-colors hover:border-accent/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-fg">
|
<div class="flex items-center gap-2 text-fg">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
||||||
<span class="text-[15px] font-semibold">{{ t(p.key) }}</span>
|
<span class="text-[15px] font-semibold group-hover:text-accent">{{ t(p.key) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="pl-[26px] font-mono text-[12.5px] text-fg-subtle">{{ t(p.hint) }}</span>
|
<span class="pl-[26px] font-mono text-[12.5px] text-fg-subtle">{{ t(p.hint) }}</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<p v-reveal class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] text-fg-subtle">
|
||||||
|
<a
|
||||||
|
:href="DOWNLOADS.appImage"
|
||||||
|
class="text-fg-muted underline decoration-border underline-offset-2 hover:text-accent"
|
||||||
|
>
|
||||||
|
{{ t('dlLinuxAlt') }}
|
||||||
|
</a>
|
||||||
|
<a :href="RELEASES" target="_blank" rel="noopener" class="text-fg-muted underline decoration-border underline-offset-2 hover:text-accent">
|
||||||
|
{{ t('dlAllAssets') }}
|
||||||
|
</a>
|
||||||
|
<a :href="DESKTOP_SRC" target="_blank" rel="noopener" class="text-fg-muted underline decoration-border underline-offset-2 hover:text-accent">
|
||||||
|
{{ t('dlSource') }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul v-reveal class="mt-6 flex flex-wrap gap-x-6 gap-y-2">
|
<ul v-reveal class="mt-6 flex flex-wrap gap-x-6 gap-y-2">
|
||||||
<li v-for="b in bullets" :key="b" class="flex items-center gap-2 text-[14px] text-fg-muted">
|
<li v-for="b in bullets" :key="b" class="flex items-center gap-2 text-[14px] text-fg-muted">
|
||||||
@@ -44,25 +65,9 @@ const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-reveal class="mt-7 flex flex-wrap items-center gap-3">
|
<!-- Premier lancement : ni l'installeur Windows ni l'app macOS ne sont signés. Le dire ICI évite
|
||||||
<a
|
qu'un visiteur conclue à un binaire cassé. -->
|
||||||
:href="RELEASES"
|
<p v-reveal class="mt-6 max-w-[720px] text-[13.5px] leading-[1.55] text-fg-subtle">{{ t('dlUnsigned') }}</p>
|
||||||
target="_blank"
|
<p v-reveal class="mt-2 max-w-[720px] text-[13.5px] leading-[1.55] text-fg-subtle">{{ t('dlNote') }}</p>
|
||||||
rel="noopener"
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg bg-accent-solid px-4 py-2.5 text-[14.5px] font-semibold text-white no-underline transition-colors hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
|
||||||
>
|
|
||||||
{{ t('dlGet') }}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="DESKTOP_SRC"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-[14.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
|
||||||
>
|
|
||||||
{{ t('dlSource') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-reveal class="mt-4 max-w-[720px] text-[13.5px] leading-[1.55] text-fg-subtle">{{ t('dlNote') }}</p>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -120,6 +120,27 @@ const { t } = useI18n();
|
|||||||
</template>
|
</template>
|
||||||
{{ t('feat15Desc') }}
|
{{ t('feat15Desc') }}
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat16Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" /><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat16Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat17Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v5h5" /><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" /><path d="M12 7v5l4 2" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat17Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat18Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z" /><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat18Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { INSTALL_COMMAND } from '../composables/useCopy';
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { NPMRC_LINE } from '../lib/links';
|
||||||
import CopyButton from './CopyButton.vue';
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -15,6 +16,20 @@ const LOCAL_URL = 'http://localhost:7317';
|
|||||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-fg">{{ t('howTitle') }}</h2>
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-fg">{{ t('howTitle') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Prérequis : le paquet vit sur un registre npm PRIVÉ. Sans cette ligne dans ~/.npmrc, le `npx`
|
||||||
|
de l'étape 1 renvoie un 404. C'était le premier mur pour tout nouvel arrivant. -->
|
||||||
|
<div v-reveal class="mb-4 rounded-xl border border-border bg-surface-1/50 p-[26px]">
|
||||||
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-fg">{{ t('prereqTitle') }}</h3>
|
||||||
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-fg-muted">{{ t('prereqDesc') }}</p>
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-border bg-surface-0 p-[11px]">
|
||||||
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-fg">
|
||||||
|
{{ NPMRC_LINE }}
|
||||||
|
</code>
|
||||||
|
<CopyButton variant="icon" :text="NPMRC_LINE" :label="t('copyCommand')" />
|
||||||
|
</div>
|
||||||
|
<p class="m-0 mt-3 text-[13.5px] leading-[1.55] text-fg-subtle">{{ t('prereqNote') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
||||||
<div class="rounded-xl border border-border bg-surface-1/50 p-[26px]">
|
<div class="rounded-xl border border-border bg-surface-1/50 p-[26px]">
|
||||||
<div class="mb-[18px] font-mono text-[13px] text-accent">01</div>
|
<div class="mb-[18px] font-mono text-[13px] text-accent">01</div>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Commandes de démarrage du mockup (fidele a LaunchProjectModal : label + commande mono).
|
||||||
|
const cmds = [
|
||||||
|
{ label: 'web', run: 'npm run dev' },
|
||||||
|
{ label: 'api', run: 'npm run api' },
|
||||||
|
{ label: 'db', run: 'docker compose up' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="launch" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
|
<!-- texte -->
|
||||||
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('launchScKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
|
{{ t('launchScTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('launchScBody') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mockup : modal « Démarrer le projet » puis dock a un onglet terminal par commande -->
|
||||||
|
<div class="flex min-w-[280px] flex-[1_1_440px] flex-col gap-3">
|
||||||
|
<!-- modal (fidele a LaunchProjectModal) -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-border bg-surface-1 shadow-card">
|
||||||
|
<header class="flex items-center gap-2 border-b border-border px-3 py-2.5">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="text-accent" aria-hidden="true"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" /><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></svg>
|
||||||
|
<span class="text-sm font-semibold text-fg">{{ t('launchModalTitle') }}</span>
|
||||||
|
<span class="ml-auto rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[11px] text-fg-muted">api</span>
|
||||||
|
</header>
|
||||||
|
<div class="flex flex-col gap-2 p-3">
|
||||||
|
<div v-for="c in cmds" :key="c.label" class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex h-4 w-4 flex-none items-center justify-center rounded bg-accent-solid text-white">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="w-16 flex-none truncate rounded bg-surface-2 px-1.5 py-1 text-xs font-medium text-fg">{{ c.label }}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate rounded border border-border bg-surface-0 px-2 py-1 font-mono text-xs text-fg-muted">{{ c.run }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 text-xs font-medium text-fg-muted">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z" /></svg>
|
||||||
|
{{ t('launchDetect') }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-accent-solid px-2.5 py-1.5 text-xs font-semibold text-white">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="6 3 20 12 6 21 6 3" /></svg>
|
||||||
|
{{ t('launchStartN') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- fleche vers le dock -->
|
||||||
|
<div class="flex justify-center text-fg-subtle">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14" /><path d="m19 12-7 7-7-7" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- dock IDE : un onglet terminal par commande lancée -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-border bg-surface-0 shadow-card">
|
||||||
|
<div class="flex items-stretch border-b border-border text-[11px]">
|
||||||
|
<span class="label-mono flex items-center gap-1 px-2 py-1.5">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
||||||
|
{{ t('mTerminal') }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 border-l border-border bg-surface-0 px-2.5 py-1.5 font-mono text-fg">
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>web
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 font-mono text-fg-muted">api</span>
|
||||||
|
<span class="flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 font-mono text-fg-muted">db</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-[104px] p-[11px] font-mono text-[11px] leading-[1.7] text-fg-muted">
|
||||||
|
<div class="text-fg-subtle">$ npm run dev</div>
|
||||||
|
<div class="text-accent">VITE v6 ready in 312 ms</div>
|
||||||
|
<div class="text-fg-subtle">Local: http://localhost:5173/</div>
|
||||||
|
<div><span class="text-accent">$</span><span class="ml-1 inline-block h-[11px] w-1.5 animate-blink bg-fg align-middle"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -61,6 +61,10 @@ function marker(type: DiffLine['type']): string {
|
|||||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsTerminal')">
|
<span class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsTerminal')">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 4e onglet : Groupes (l'app en a quatre, le mockup n'en montrait que trois) -->
|
||||||
|
<span class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsGroups')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7.5 4.27 9 5.15" /><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></svg>
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- arbre unifie : plusieurs projets a la fois -->
|
<!-- arbre unifie : plusieurs projets a la fois -->
|
||||||
@@ -77,6 +81,12 @@ function marker(type: DiffLine['type']): string {
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||||
<span class="truncate text-accent">feat/auth</span>
|
<span class="truncate text-accent">feat/auth</span>
|
||||||
|
<!-- compteurs git compacts : ahead / indexés / non indexés, comme dans l'app -->
|
||||||
|
<span class="ml-auto flex shrink-0 items-center gap-1 text-[11px]">
|
||||||
|
<span class="text-accent">↑2</span>
|
||||||
|
<span class="text-accent">●1</span>
|
||||||
|
<span class="text-warn">○1</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- session claude corrélée -->
|
<!-- session claude corrélée -->
|
||||||
<div class="flex items-center gap-1.5 rounded py-0.5 pr-2 pl-[38px] text-fg-muted">
|
<div class="flex items-center gap-1.5 rounded py-0.5 pr-2 pl-[38px] text-fg-muted">
|
||||||
@@ -94,6 +104,7 @@ function marker(type: DiffLine['type']): string {
|
|||||||
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg-muted">
|
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg-muted">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
<span class="font-medium">web</span>
|
<span class="font-medium">web</span>
|
||||||
|
<span class="ml-auto shrink-0 text-[11px] text-warn">○3</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg-muted">
|
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg-muted">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
|
|||||||