diff --git a/README.fr.md b/README.fr.md index 3751b04..ea1ef56 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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). - **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. +- **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. - **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. diff --git a/README.md b/README.md index ceb23a9..19bc22c 100644 --- a/README.md +++ b/README.md @@ -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). - **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. +- **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. - **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. diff --git a/package-lock.json b/package-lock.json index d37f782..7829dd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7933,7 +7933,7 @@ }, "packages/server": { "name": "@johanleroy/git-arboretum", - "version": "3.2.0", + "version": "3.3.0", "license": "MIT", "dependencies": { "@fastify/cookie": "^11.0.0", @@ -8063,7 +8063,7 @@ }, "packages/vscode": { "name": "git-arboretum", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@arboretum/shared": "0.1.0", diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 3739124..c074380 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@arboretum/desktop", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@arboretum/desktop", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4ab29e6..428381c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@arboretum/desktop", "private": true, - "version": "0.1.2", + "version": "0.1.3", "description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI", "homepage": "https://git-arboretum.com", "license": "MIT", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index bc418b9..d68b7dc 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -3,6 +3,14 @@ Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code extension keeps its own changelog in `packages/vscode/CHANGELOG.md`. +## 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 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). diff --git a/packages/server/package.json b/packages/server/package.json index 9c8d2b6..6af27f3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@johanleroy/git-arboretum", - "version": "3.2.0", + "version": "3.3.0", "description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them", "license": "MIT", "type": "module", diff --git a/packages/server/scripts/acceptance-p13.mjs b/packages/server/scripts/acceptance-p13.mjs new file mode 100644 index 0000000..9a0ffa5 --- /dev/null +++ b/packages/server/scripts/acceptance-p13.mjs @@ -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); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 781d58b..19cd5ad 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -195,7 +195,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund registerAuthRoutes(app, auth, limiter, serverVersion, db); registerSessionRoutes(app, manager, discovery, sessionArchive, db); registerProjectRoutes(app, manager, db); - registerRepoRoutes(app, worktrees, db); + registerRepoRoutes(app, worktrees, db, manager); registerGroupRoutes(app, groups, db, worktrees, manager); registerWorktreeRoutes(app, worktrees, db); registerGitRoutes(app, worktrees, db); diff --git a/packages/server/src/core/claude-launcher.ts b/packages/server/src/core/claude-launcher.ts index 86d1518..6df84dd 100644 --- a/packages/server/src/core/claude-launcher.ts +++ b/packages/server/src/core/claude-launcher.ts @@ -15,6 +15,13 @@ export interface SpawnOptions { addDirs?: string[]; /** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */ 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; } /** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */ @@ -81,6 +88,20 @@ export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiag 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 de login pour « Démarrer le projet » : `$SHELL` s'il est un shell interactif connu + * (bash/zsh/fish), sinon fallback `bash`. Évite qu'un `$SHELL` exotique (dash…) sorte aussitôt + * avec `-l -i` et laisse un terminal vide. + */ +function loginShell(): string { + const shell = process.env.SHELL; + if (shell && KNOWN_LOGIN_SHELLS.has(shell.split('/').pop() ?? '')) return shell; + return 'bash'; +} + /** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { const env: NodeJS.ProcessEnv = { @@ -89,6 +110,13 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { COLORTERM: 'truecolor', }; if (opts.command === 'bash') { + // Lancement de projet : shell de login interactif de l'utilisateur (charge PATH/nvm/asdf). + // `-l` (login) exécute les profils, `-i` (interactif) reste attaché après la commande auto-tapée. + // On n'utilise `$SHELL` que s'il fait partie des shells interactifs connus supportant `-l -i` + // (bash/zsh/fish) ; sinon fallback bash (ex. `$SHELL=dash` sortirait avec `-l -i`). + if (opts.login) { + return { file: loginShell(), args: ['-l', '-i'], env }; + } return { file: 'bash', args: ['--norc'], env }; } const args: string[] = []; diff --git a/packages/server/src/core/launch-detect.ts b/packages/server/src/core/launch-detect.ts new file mode 100644 index 0000000..cfc4171 --- /dev/null +++ b/packages/server/src/core/launch-detect.ts @@ -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 → ` + + diff --git a/packages/site/src/i18n/en.ts b/packages/site/src/i18n/en.ts index 4de1435..fa90ec2 100644 --- a/packages/site/src/i18n/en.ts +++ b/packages/site/src/i18n/en.ts @@ -39,6 +39,8 @@ export default { feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.', feat10Title: 'New project in one click', feat10Desc: 'Create a project folder anywhere (optional git init) and start a Claude session in it, from any device.', + feat16Title: 'Start your project', + feat16Desc: 'Define your dev commands once (dev server, API, database), then launch each in its own terminal in a single click.', scAKicker: 'Session supervision', scATitle: 'See what needs you, before anything stalls', scABody: @@ -148,6 +150,15 @@ export default { wsUnstage: 'Unstage', wsDiscard: 'Discard', + // « Start the project » : lancement multi-terminaux + launchScKicker: 'Start the project', + launchScTitle: 'One click, every terminal your project needs', + launchScBody: + 'Most projects need more than one long-running command: a dev server, an API, a database. Define them once per project (auto-detected from package.json, Procfile or docker-compose), then launch them all at once. Each command opens in its own docked terminal you can attach to, restart, or stop as a group.', + launchModalTitle: 'Start the project', + launchDetect: 'Detect', + launchStartN: 'Start (3)', + // Remote git services + clone rgKicker: 'Remote git · encrypted', rgTitle: 'Connect your git services and clone in a click', diff --git a/packages/site/src/i18n/fr.ts b/packages/site/src/i18n/fr.ts index a33089e..2486311 100644 --- a/packages/site/src/i18n/fr.ts +++ b/packages/site/src/i18n/fr.ts @@ -39,6 +39,8 @@ export default { feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.', feat10Title: 'Nouveau projet en un clic', feat10Desc: "Créez un dossier de projet où vous voulez (git init optionnel) et lancez-y une session Claude, depuis n'importe quel appareil.", + feat16Title: 'Démarrez votre projet', + feat16Desc: 'Définissez vos commandes de démarrage une fois (serveur de dev, API, base de données), puis lancez chacune dans son terminal en un seul clic.', scAKicker: 'Supervision des sessions', scATitle: 'Voyez ce qui vous attend, avant que ça ne bloque', scABody: @@ -148,6 +150,15 @@ export default { wsUnstage: 'Désindexer', wsDiscard: 'Annuler', + // « Démarrer le projet » : lancement multi-terminaux + launchScKicker: 'Démarrer le projet', + launchScTitle: 'Un clic, tous les terminaux dont votre projet a besoin', + launchScBody: + "La plupart des projets exigent plusieurs commandes longue durée : un serveur de dev, une API, une base de données. Définissez-les une fois par projet (détectées depuis package.json, Procfile ou docker-compose), puis lancez-les toutes d'un coup. Chaque commande ouvre son propre terminal en dock, que vous pouvez attacher, relancer ou arrêter en groupe.", + launchModalTitle: 'Démarrer le projet', + launchDetect: 'Détecter', + launchStartN: 'Démarrer (3)', + // Services git distants + clone rgKicker: 'Git distant · chiffré', rgTitle: 'Connectez vos services git et clonez en un clic', diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index c4c79e8..d31cb26 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.4.0 + +Reflects the daemon's **"Start the project"** milestone in supervision (no editor duplicated here). + +- **Start Project.** A new command on repos and worktrees launches the project's configured start + commands, one native terminal per command. The commands themselves are defined in the web IDE; when + none are set yet, the action offers to open it. +- Sessions started by a launch are badged `launch` in the tree, and a **Stop Launch** action stops the + whole set (all terminals sharing the launch run) at once. + ## 0.3.0 Aligns with the daemon's **multi-project IDE** milestone: the web UI is now a single IDE that holds all diff --git a/packages/vscode/package.json b/packages/vscode/package.json index db5452f..34603ee 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "git-arboretum", "displayName": "Arboretum", "description": "Pilot your git worktrees and Claude Code sessions from VS Code: native terminals, live tree, waiting alerts.", - "version": "0.3.0", + "version": "0.4.0", "private": true, "publisher": "johanleroy", "license": "MIT", @@ -203,6 +203,18 @@ "category": "Arboretum", "icon": "$(play)" }, + { + "command": "arboretum.startProject", + "title": "Start Project", + "category": "Arboretum", + "icon": "$(rocket)" + }, + { + "command": "arboretum.stopLaunch", + "title": "Stop Launch", + "category": "Arboretum", + "icon": "$(stop-circle)" + }, { "command": "arboretum.startSessionHere", "title": "Start Session in Current Folder", @@ -253,6 +265,11 @@ "when": "viewItem == arboretum:repo", "group": "1_session" }, + { + "command": "arboretum.startProject", + "when": "viewItem == arboretum:repo", + "group": "1_session" + }, { "command": "arboretum.openWorktreeIde", "when": "viewItem == arboretum:worktree", @@ -293,6 +310,11 @@ "when": "viewItem == arboretum:worktree", "group": "3_session" }, + { + "command": "arboretum.startProject", + "when": "viewItem == arboretum:worktree", + "group": "3_session" + }, { "command": "arboretum.attachSession", "when": "viewItem =~ /arboretum:session:live/", @@ -313,6 +335,11 @@ "when": "viewItem =~ /arboretum:session:live/", "group": "9_danger" }, + { + "command": "arboretum.stopLaunch", + "when": "viewItem =~ /arboretum:session:live.*:launch/", + "group": "9_danger" + }, { "command": "arboretum.resumeSession", "when": "viewItem =~ /arboretum:session:dead/", @@ -407,6 +434,14 @@ "command": "arboretum.startGroupSession", "when": "false" }, + { + "command": "arboretum.startProject", + "when": "false" + }, + { + "command": "arboretum.stopLaunch", + "when": "false" + }, { "command": "arboretum.revealWorktree", "when": "false" diff --git a/packages/vscode/src/api/rest-client.ts b/packages/vscode/src/api/rest-client.ts index e726985..42adcae 100644 --- a/packages/vscode/src/api/rest-client.ts +++ b/packages/vscode/src/api/rest-client.ts @@ -20,6 +20,8 @@ import type { ReposListResponse, SessionResponse, SessionsListResponse, + StartLaunchRequest, + StartLaunchResponse, StartRepoSessionRequest, WorktreeResponse, WorktreesListResponse, @@ -111,6 +113,11 @@ export class RestClient { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body); } + /** « Démarrer le projet » : lance un terminal par commande de démarrage activée du repo. */ + startLaunch(repoId: string, body: StartLaunchRequest): Promise { + return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/launch`, body); + } + // ---- sessions ---- createSession(body: CreateSessionRequest): Promise { return this.request('POST', '/api/v1/sessions', body); diff --git a/packages/vscode/src/commands.ts b/packages/vscode/src/commands.ts index f144206..f3d2053 100644 --- a/packages/vscode/src/commands.ts +++ b/packages/vscode/src/commands.ts @@ -236,6 +236,21 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command if (group) void startGroupSessionFlow(deps, group); }); + // ---- « Démarrer le projet » (lancement multi-terminaux) ---- + cmd('arboretum.startProject', (node) => { + const wt = asWorktree(node as AnyNode); + if (wt) { + void startProjectFlow(deps, wt.repoId, { worktreePath: wt.worktree.path }); + return; + } + const repo = asRepo(node as AnyNode); + if (repo) void startProjectFlow(deps, repo.id, {}); + }); + cmd('arboretum.stopLaunch', (node) => { + const s = asSession(node as AnyNode); + if (s?.launchRunId) void stopLaunchFlow(deps, s.launchRunId); + }); + // ---- conscience du workspace (Phase D) ---- cmd('arboretum.startSessionHere', () => { const repoId = workspace.currentRepoId(); @@ -331,3 +346,47 @@ async function startGroupSessionFlow(deps: CommandDeps, group: GroupSummary): Pr } }); } + +/** + * Lance les commandes de démarrage du repo (un terminal natif par commande activée). L'édition des + * commandes reste dans l'IDE web : si aucune n'est configurée, on propose de l'ouvrir. + */ +async function startProjectFlow( + deps: CommandDeps, + repoId: string, + body: { worktreePath?: string }, +): Promise { + await run('start project', async () => { + const res = await deps.rest.startLaunch(repoId, body); + for (const s of res.sessions) deps.terminals.open(s, 'interactive'); + if (res.sessions.length === 0) { + const OPEN = 'Open web IDE'; + const pick = await vscode.window.showInformationMessage( + 'Arboretum: no launch commands configured for this project. Define them in the web IDE.', + OPEN, + ); + if (pick === OPEN) void vscode.env.openExternal(vscode.Uri.parse(deps.rest.url)); + return; + } + if (res.skipped.length > 0) { + void vscode.window.showWarningMessage( + `Arboretum: ${res.skipped.length} command(s) skipped: ${res.skipped.map((s) => s.reason).join('; ')}`, + ); + } + }); +} + +/** Arrête d'un coup tous les terminaux vivants d'un même lancement (partageant le launchRunId). */ +async function stopLaunchFlow(deps: CommandDeps, launchRunId: string): Promise { + const live = deps.store.allSessions().filter((s) => s.live && s.launchRunId === launchRunId); + if (live.length === 0) return; + const ok = await vscode.window.showWarningMessage( + `Stop all ${live.length} launch terminal(s)?`, + { modal: true }, + 'Stop all', + ); + if (ok !== 'Stop all') return; + await run('stop launch', async () => { + await Promise.all(live.map((s) => deps.rest.killSession(s.id))); + }); +} diff --git a/packages/vscode/src/views/tree-items.ts b/packages/vscode/src/views/tree-items.ts index 3f5ee18..0053915 100644 --- a/packages/vscode/src/views/tree-items.ts +++ b/packages/vscode/src/views/tree-items.ts @@ -58,6 +58,9 @@ export function sessionContextValue(s: SessionSummary): string { let v = `arboretum:session:${s.live ? 'live' : 'dead'}`; if (s.source === 'discovered') v += ':discovered'; if (s.live && s.activity === 'waiting') v += ':waiting'; + // Session issue de « Démarrer le projet » : suffixe additif (les regex de menu existantes, + // ancrées sur le préfixe, restent valides) qui gate l'action « Stop launch ». + if (s.launchRunId) v += ':launch'; return v; } @@ -94,6 +97,7 @@ function sessionDescription(s: SessionSummary): string { if (s.archived) parts.push('archived'); if (s.source === 'discovered') parts.push('external'); if (s.groupId) parts.push('group'); + if (s.launchRunId) parts.push('launch'); if (s.waitingFor) parts.push(`· ${s.waitingFor}`); return parts.join(' '); } diff --git a/packages/vscode/test/rest-client.test.ts b/packages/vscode/test/rest-client.test.ts index d17775f..c5b7a3f 100644 --- a/packages/vscode/test/rest-client.test.ts +++ b/packages/vscode/test/rest-client.test.ts @@ -62,6 +62,18 @@ describe('RestClient', () => { expect(JSON.parse(String(captured[1]?.init.body))).toEqual({ path: '/w', mode: 'rebase' }); }); + it('startLaunch POST /repos/:id/launch avec le corps (commandIds / worktreePath)', async () => { + const captured: Captured[] = []; + const rest = new RestClient( + { baseUrl: 'http://h:1', token: 't' }, + fakeFetch({ ok: true, status: 200, body: { sessions: [], skipped: [] } }, captured), + ); + await rest.startLaunch('repo id', { worktreePath: '/w', commandIds: ['a', 'b'] }); + expect(captured[0]?.url).toBe('http://h:1/api/v1/repos/repo%20id/launch'); + expect(captured[0]?.init.method).toBe('POST'); + expect(JSON.parse(String(captured[0]?.init.body))).toEqual({ worktreePath: '/w', commandIds: ['a', 'b'] }); + }); + it('listSessions construit la query includeHidden/includeArchived', async () => { const captured: Captured[] = []; const rest = new RestClient( diff --git a/packages/vscode/test/store.test.ts b/packages/vscode/test/store.test.ts index 7d063f3..2d23eea 100644 --- a/packages/vscode/test/store.test.ts +++ b/packages/vscode/test/store.test.ts @@ -9,6 +9,7 @@ function repo(id: string): RepoSummary { label: id, defaultBranch: 'main', postCreateHooks: [], + launchCommands: [], preTrust: false, createdAt: '2026-01-01T00:00:00Z', valid: true, diff --git a/packages/web/src/components/CommandPalette.vue b/packages/web/src/components/CommandPalette.vue index 822bce1..816781e 100644 --- a/packages/web/src/components/CommandPalette.vue +++ b/packages/web/src/components/CommandPalette.vue @@ -53,7 +53,7 @@ diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 74ea1ab..cb3bff8 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -206,6 +206,27 @@ export default { create: 'Create', creating: 'Creating…', }, + launch: { + title: 'Start project', + startProject: 'Start project', + startNamed: 'Start project: {name}', + hint: 'Define the commands that start this project. Each enabled command opens its own terminal (e.g. dev server, API, database).', + labelPlaceholder: 'label', + runPlaceholder: 'npm run dev', + cwdPlaceholder: 'subdir', + enabledTitle: 'Launch this command', + remove: 'Remove', + empty: 'No launch command yet. Add one or detect them from the project.', + add: 'Add command', + detect: 'Detect', + detectNone: 'No new command detected.', + save: 'Save', + saved: 'Launch commands saved.', + start: 'Start terminals ({n})', + started: 'Started {n} terminals.', + partial: 'Started {started}, {skipped} skipped.', + stopAll: 'Stop launch ({n})', + }, workspace: { open: 'Open IDE', files: 'Files', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index acae0fe..9da660f 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -208,6 +208,27 @@ const fr: typeof en = { create: 'Créer', creating: 'Création…', }, + launch: { + title: 'Démarrer le projet', + startProject: 'Démarrer le projet', + startNamed: 'Démarrer le projet : {name}', + hint: 'Définissez les commandes qui démarrent ce projet. Chaque commande activée ouvre son propre terminal (ex. serveur de dev, API, base de données).', + labelPlaceholder: 'libellé', + runPlaceholder: 'npm run dev', + cwdPlaceholder: 'sous-dossier', + enabledTitle: 'Lancer cette commande', + remove: 'Retirer', + empty: 'Aucune commande de démarrage. Ajoutez-en une ou détectez-les depuis le projet.', + add: 'Ajouter une commande', + detect: 'Détecter', + detectNone: 'Aucune nouvelle commande détectée.', + save: 'Enregistrer', + saved: 'Commandes de démarrage enregistrées.', + start: 'Démarrer les terminaux ({n})', + started: 'Terminaux démarrés : {n}.', + partial: 'Démarrés : {started}, ignorés : {skipped}.', + stopAll: 'Arrêter le lancement ({n})', + }, workspace: { open: 'Ouvrir l’IDE', files: 'Fichiers', diff --git a/packages/web/src/stores/sessions.ts b/packages/web/src/stores/sessions.ts index ef1b840..3a4340d 100644 --- a/packages/web/src/stores/sessions.ts +++ b/packages/web/src/stores/sessions.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; -import type { CreateProjectRequest, CreateProjectResponse, CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared'; +import type { CreateProjectRequest, CreateProjectResponse, CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse, StartLaunchRequest, StartLaunchResponse } from '@arboretum/shared'; import { api } from '../lib/api'; import { wsClient, type SessionEvent } from '../lib/ws-client'; @@ -152,6 +152,20 @@ export const useSessionsStore = defineStore('sessions', () => { await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`); } + // « Démarrer le projet » : lance un terminal par commande activée (un seul appel serveur). + // Les sessions renvoyées partagent un launchRunId ; le caller ouvre les onglets correspondants. + async function startLaunch(repoId: string, opts: StartLaunchRequest = {}): Promise { + const res = await api.post(`/api/v1/repos/${repoId}/launch`, opts); + for (const s of res.sessions) upsert(s); + return res; + } + + // « Tout arrêter » : coupe tous les terminaux vivants d'un même lancement de projet. + async function stopLaunch(launchRunId: string): Promise { + const targets = sessions.value.filter((s) => s.launchRunId === launchRunId && s.live); + await Promise.all(targets.map((s) => killSession(s.id))); + } + // Reprise d'une session morte (nouveau PTY managé dans son cwd d'origine, côté serveur). async function resumeSession(id: string): Promise { const res = await api.post(`/api/v1/sessions/${id}/resume`); @@ -180,6 +194,8 @@ export const useSessionsStore = defineStore('sessions', () => { createSession, createProject, killSession, + startLaunch, + stopLaunch, resumeSession, forkSession, hideSession, diff --git a/packages/web/src/stores/worktrees.ts b/packages/web/src/stores/worktrees.ts index f8d2ee4..409ea67 100644 --- a/packages/web/src/stores/worktrees.ts +++ b/packages/web/src/stores/worktrees.ts @@ -3,7 +3,9 @@ import { ref } from 'vue'; import type { CreateWorktreeRequest, CreateWorktreeResponse, + DetectLaunchResponse, DiscoverReposResponse, + LaunchCommand, RepoBranchesResponse, RepoResponse, ReposListResponse, @@ -104,6 +106,20 @@ export const useWorktreesStore = defineStore('worktrees', () => { upsertRepo(res.repo); } + /** Enregistre les commandes de démarrage d'un projet (« Démarrer le projet »). */ + async function updateRepoLaunchCommands(id: string, launchCommands: LaunchCommand[]): Promise { + const res = await api.patch(`/api/v1/repos/${id}`, { launchCommands }); + upsertRepo(res.repo); + return res.repo; + } + + /** Détecte des commandes candidates (package.json/Procfile/docker-compose) dans le worktree cible. */ + async function detectLaunch(id: string, worktreePath?: string): Promise { + const qs = worktreePath ? `?worktreePath=${encodeURIComponent(worktreePath)}` : ''; + const res = await api.get(`/api/v1/repos/${id}/launch/detect${qs}`); + return res.suggestions; + } + /** Lance un scan de découverte ; les nouveaux repos arrivent par WS, on resynchronise par sûreté. */ async function discover(): Promise { const res = await api.post('/api/v1/repos/discover'); @@ -183,6 +199,8 @@ export const useWorktreesStore = defineStore('worktrees', () => { addRepo, removeRepo, setHidden, + updateRepoLaunchCommands, + detectLaunch, discover, refreshRepoWorktrees, createWorktree, diff --git a/packages/web/src/views/help-content.ts b/packages/web/src/views/help-content.ts index 2e2e819..3beca79 100644 --- a/packages/web/src/views/help-content.ts +++ b/packages/web/src/views/help-content.ts @@ -55,6 +55,10 @@ const en: HelpSection[] = [ title: 'Delete & prune', body: 'Delete a worktree from its card (force-delete if dirty or locked), or prune all orphaned worktrees at the repo level.', }, + { + title: 'Start the project', + body: 'From a repo or worktree menu (or the command palette), choose “Start project”. Define the commands that boot your project (dev server, API, database), auto-detected from package.json / Procfile / docker-compose, then launch them all at once: one terminal per command, opened in the dock. Each runs in your login shell so npm/docker are on PATH, and stays live after the command exits so failures stay visible. Stop the whole set from any of its session rows.', + }, ], }, { @@ -235,6 +239,10 @@ const fr: HelpSection[] = [ title: 'Supprimer & élaguer', body: 'Supprimez un worktree depuis sa carte (suppression forcée s’il est sale ou verrouillé), ou élaguez tous les worktrees orphelins au niveau du dépôt.', }, + { + title: 'Démarrer le projet', + body: 'Depuis le menu d’un dépôt ou d’un worktree (ou la palette de commandes), choisissez « Démarrer le projet ». Définissez les commandes qui démarrent votre projet (serveur de dev, API, base de données), auto-détectées depuis package.json / Procfile / docker-compose, puis lancez-les toutes d’un coup : un terminal par commande, ouvert dans le dock. Chacune tourne dans votre shell de login (npm/docker sont donc dans le PATH) et reste vivante après la fin de la commande, pour que les échecs restent visibles. Arrêtez tout le lot depuis n’importe quelle ligne de session.', + }, ], }, {