release: git-arboretum 3.3.0 (« Démarrer le projet » : lancement multi-terminaux), vscode 0.4.0, desktop 0.1.3
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m17s
CI / Build & test (Node 24) (push) Successful in 10m13s
CI / No em/en dashes (push) Successful in 4s
Deploy site (production) / build-and-deploy (push) Successful in 24s
Release / Publish to Gitea npm registry (push) Successful in 10m17s
VSCode Release / Package VSIX (push) Successful in 9m39s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 15m41s
CI / Pack & boot smoke (Node 22) (push) Successful in 10m3s

« Démarrer le projet » : un repo définit une fois ses commandes de démarrage
(serveur de dev, API, base de données), un clic ouvre un terminal PTY par
commande dans le dock IDE.

Serveur (additif, PROTOCOL_VERSION inchangé) :
- LaunchCommand[] persistées sur repos.launch_commands (migration 13) ; champ additif SessionSummary.launchRunId.
- POST /repos/:id/launch : résolution du worktree côté serveur, cwd de commande borné (anti-traversal), commandIds outrepasse enabled.
- GET /repos/:id/launch/detect : détection package.json / Procfile / docker-compose.
- Shell de login interactif ($SHELL -l -i, charge le PATH nvm/asdf) + auto-type de la commande ; le shell survit à la commande (échec visible).

Web : LaunchProjectModal + actions (ProjectTreeNode, SessionsPanel, CommandPalette), stores sessions/worktrees, i18n EN/FR.

Alignement du reste du projet :
- Extension VS Code 0.4.0 : commande Start Project (repo/worktree), Stop Launch, badge « launch » dans l'arbre, méthode REST startLaunch.
- Site vitrine : 16e feature card (Rocket) + section showcase « Start the project » (mockup fidèle au modal), i18n EN/FR.
- Documentation : README (EN + FR), help-content (EN + FR), CHANGELOGs server + vscode.

Vérifié : 430 tests, typecheck, build (web + site + vscode), acceptance-p13 ALL GREEN, VSIX packagé, garde anti-tirets, vérif visuelle du site (thèmes clair et sombre).
This commit is contained in:
2026-07-21 13:54:16 +02:00
parent 7327407193
commit a7e04278fd
39 changed files with 1226 additions and 25 deletions

View File

@@ -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).

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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[] = [];

View File

@@ -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)];
}

View File

@@ -40,8 +40,29 @@ type HistoricalRow = {
added_dirs: string | null;
group_id: 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. */
function parseAddedDirs(raw: string | null): string[] {
if (!raw) return [];
@@ -85,6 +106,8 @@ interface ManagedSession {
addedDirs: string[];
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
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). */
tracker: SessionActivityTracker | null;
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
@@ -117,6 +140,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
addDirs?: string[];
/** groupe propriétaire (session de groupe, P6). */
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 {
const cwd = opts.cwd;
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
@@ -138,6 +169,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
...(claudeBinPath ? { claudeBinPath } : {}),
...(opts.resume ? { resume: opts.resume } : {}),
...(addedDirs.length ? { addDirs: addedDirs } : {}),
...(opts.login ? { login: true } : {}),
});
const id = randomUUID();
const proc = pty.spawn(spec.file, spec.args, {
@@ -151,7 +183,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
id,
cwd,
command,
title: null,
title: opts.title ?? null,
createdAt: new Date().toISOString(),
proc,
ring: new RingBuffer(RING_CAPACITY),
@@ -162,6 +194,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
claudeSessionId: null,
addedDirs,
groupId: opts.groupId ?? null,
launchRunId: opts.launchRunId ?? null,
tracker: null,
prevActivity: null,
notifyTimer: null,
@@ -177,19 +210,28 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
this.live.set(id, session);
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(
id,
cwd,
command,
session.title,
session.createdAt,
opts.resume?.claudeSessionId ?? null,
addedDirs.length ? JSON.stringify(addedDirs) : null,
session.groupId,
session.launchRunId,
);
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
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);
const summary = this.summarize(session);
@@ -269,7 +311,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
const liveIds = new Set(this.live.keys());
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[];
const historical: SessionSummary[] = rows
.filter((r) => !liveIds.has(r.id))
@@ -302,6 +344,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
registryStatus: null,
...(addedDirs.length ? { addedDirs } : {}),
groupId: r.group_id,
launchRunId: r.launch_run_id,
archived: r.archived_at != null,
};
}
@@ -314,7 +357,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
emitHistoricalUpdate(id: string): void {
if (this.live.has(id)) return;
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;
if (!r) return;
this.emit('session_update', this.historicalSummary(r));
@@ -558,6 +601,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
dialog: act?.dialog ?? null,
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
groupId: s.groupId,
launchRunId: s.launchRunId,
};
}
}

View File

@@ -9,6 +9,7 @@ import { existsSync, realpathSync } from 'node:fs';
import type {
DiscoverReposResponse,
HookRunResult,
LaunchCommand,
PostCreateHook,
RepoSummary,
SessionSummary,
@@ -71,6 +72,8 @@ interface RepoRow {
pre_trust: number;
created_at: string;
hidden: number;
/** commandes de démarrage du projet (JSON array de LaunchCommand) ; '[]' par défaut. */
launch_commands: string;
}
export interface WorktreeManagerEvents {
@@ -101,6 +104,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> {
return new Promise((resolveP) => {
const t0 = Date.now();
@@ -160,6 +185,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
label: row.label,
defaultBranch: row.default_branch,
postCreateHooks: parseHooks(row.post_create_hooks),
launchCommands: parseLaunchCommands(row.launch_commands),
preTrust: row.pre_trust === 1,
createdAt: row.created_at,
valid: await isRepo(row.path),
@@ -167,12 +193,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[]> {
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
return Promise.all(rows.map((r) => this.rowToSummary(r)));
}
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; launchCommands?: LaunchCommand[] }): Promise<RepoSummary> {
const path = opts.path;
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
@@ -187,11 +219,12 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
pre_trust: opts.preTrust ? 1 : 0,
created_at: new Date().toISOString(),
hidden: 0,
launch_commands: JSON.stringify(opts.launchCommands ?? []),
};
try {
this.db
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
.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, row.launch_commands);
} catch (err) {
// 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.
@@ -220,16 +253,17 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
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);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
if (patch.launchCommands !== undefined) row.launch_commands = JSON.stringify(patch.launchCommands);
this.db
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, 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, row.launch_commands, id);
const summary = await this.rowToSummary(row);
this.emit('repo_update', summary);
// P11 : masqué → on libère le watcher permanent du principal ; ré-affiché → on le réarme.
@@ -287,6 +321,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
pre_trust: 0,
created_at: new Date().toISOString(),
hidden: 0,
launch_commands: '[]',
};
const res = insert.run(row.id, row.path, row.label, row.created_at);
if (res.changes === 1) {
@@ -601,6 +636,55 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
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. */
async watch(repoId: string, path: string): Promise<void> {
if (!this.fsWatcher) return;

View File

@@ -191,6 +191,16 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
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;

View File

@@ -1,8 +1,22 @@
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 { PtyManager } from '../core/pty-manager.js';
import type { Db } from '../db/index.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. */
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' } });
}
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() }));
// 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.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
...(body.launchCommands !== undefined ? { launchCommands: body.launchCommands } : {}),
});
const res: RepoResponse = { repo };
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.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
...(body.launchCommands !== undefined ? { launchCommands: body.launchCommands } : {}),
});
const res: RepoResponse = { repo };
return reply.send(res);
@@ -66,4 +82,79 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
}
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);
});
}

View File

@@ -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([]);
});
});