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

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

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

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

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

View File

@@ -1,5 +1,5 @@
// 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 {
error: { code: string; message: string; details?: unknown };
@@ -99,6 +99,8 @@ export interface CreateRepoRequest {
label?: string;
postCreateHooks?: PostCreateHook[];
preTrust?: boolean;
/** commandes de démarrage du projet (« Démarrer le projet »). */
launchCommands?: LaunchCommand[];
}
export interface UpdateRepoRequest {
label?: string;
@@ -106,6 +108,8 @@ export interface UpdateRepoRequest {
preTrust?: boolean;
/** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */
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). */
@@ -313,6 +317,31 @@ export interface StartRepoSessionRequest {
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) ----
export interface GroupsListResponse {
groups: GroupSummary[];

View File

@@ -122,6 +122,9 @@ export interface SessionSummary {
addedDirs?: string[];
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
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) ----
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
hidden?: boolean;
@@ -139,6 +142,22 @@ export interface PostCreateHook {
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 {
id: string;
/** chemin absolu de la racine du repo (main worktree). */
@@ -146,6 +165,8 @@ export interface RepoSummary {
label: string;
defaultBranch: string | null;
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. */
preTrust: boolean;
createdAt: string;

View File

@@ -7,6 +7,7 @@ import ProblemSection from './components/ProblemSection.vue';
import FeaturesSection from './components/FeaturesSection.vue';
import ShowcaseSection from './components/ShowcaseSection.vue';
import WorkspaceShowcase from './components/WorkspaceShowcase.vue';
import LaunchShowcase from './components/LaunchShowcase.vue';
import DownloadSection from './components/DownloadSection.vue';
import RemoteGitSection from './components/RemoteGitSection.vue';
import WorkGroupsSection from './components/WorkGroupsSection.vue';
@@ -68,6 +69,7 @@ const glowStyle = {
<FeaturesSection />
<ShowcaseSection />
<WorkspaceShowcase />
<LaunchShowcase />
<DownloadSection />
<RemoteGitSection />
<WorkGroupsSection />

View File

@@ -120,6 +120,13 @@ const { t } = useI18n();
</template>
{{ t('feat15Desc') }}
</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>
</div>
</section>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ import type {
ReposListResponse,
SessionResponse,
SessionsListResponse,
StartLaunchRequest,
StartLaunchResponse,
StartRepoSessionRequest,
WorktreeResponse,
WorktreesListResponse,
@@ -111,6 +113,11 @@ export class RestClient {
return this.request<SessionResponse>('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<StartLaunchResponse> {
return this.request<StartLaunchResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/launch`, body);
}
// ---- sessions ----
createSession(body: CreateSessionRequest): Promise<SessionResponse> {
return this.request<SessionResponse>('POST', '/api/v1/sessions', body);

View File

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

View File

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

View File

@@ -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(

View File

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

View File

@@ -53,7 +53,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useI18n } from 'vue-i18n';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus, Rocket } from '@lucide/vue';
import type { WorktreeSummary } from '@arboretum/shared';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
@@ -63,6 +63,7 @@ import { useModalsStore } from '../stores/modals';
import { useCommandPalette } from '../composables/useCommandPalette';
import NewSessionModal from './ide/modals/NewSessionModal.vue';
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
import LaunchProjectModal from './ide/modals/LaunchProjectModal.vue';
interface PaletteItem {
id: string;
@@ -103,6 +104,14 @@ const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = [];
for (const r of worktrees.repos) {
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), run: () => revealRepo(r.id) });
out.push({
id: `launch-${r.id}`,
type: 'action',
label: t('launch.startNamed', { name: r.label }),
icon: Rocket as Component,
keywords: `${t('launch.startProject')} ${r.label}`.toLowerCase(),
run: () => void modals.open(LaunchProjectModal, { repoId: r.id }),
});
}
for (const w of worktrees.worktrees) {
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), run: () => revealWorktree(w) });

View File

@@ -93,6 +93,7 @@ import {
Scissors,
Eye,
EyeOff,
Rocket,
} from '@lucide/vue';
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { ApiError } from '../../lib/api';
@@ -107,6 +108,7 @@ import SessionStateBadge from '../SessionStateBadge.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
import LaunchProjectModal from './modals/LaunchProjectModal.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
@@ -144,6 +146,7 @@ function onWtClick(wt: WorktreeSummary): void {
// --- menu contextuel repo ---
function openRepoMenu(e: MouseEvent): void {
const items: ContextMenuItem[] = [
{ label: t('launch.startProject'), icon: Rocket, onSelect: () => void modals.open(LaunchProjectModal, { repoId: props.repo.id }) },
{ label: t('ide.menu.newWorktree'), icon: GitBranch, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'worktree' }) },
{ label: t('ide.menu.workOnMain'), icon: Home, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'main' }) },
{ label: t('ide.menu.prune'), icon: Scissors, onSelect: () => void onPrune() },
@@ -190,6 +193,7 @@ function confirmRemoveRepo(): void {
// --- menu contextuel worktree ---
function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
const items: ContextMenuItem[] = [
{ label: t('launch.startProject'), icon: Rocket, onSelect: () => void modals.open(LaunchProjectModal, { repoId: wt.repoId, worktreePath: wt.path, ...(wt.branch ? { targetLabel: wt.branch } : {}) }) },
{ label: t('ide.menu.openTerminal'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal, { cwd: wt.path }) },
{ label: t('ide.menu.commit'), icon: GitCompare, onSelect: () => openGitPanel(wt) },
{ label: t('ide.menu.push'), icon: Upload, onSelect: () => void onPush(wt) },

View File

@@ -62,6 +62,8 @@ import {
ArchiveRestore,
RefreshCw,
FolderPlus,
Rocket,
Square,
} from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
@@ -77,6 +79,7 @@ import AttentionList from './AttentionList.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
import NewProjectModal from '../NewProjectModal.vue';
import LaunchProjectModal from './modals/LaunchProjectModal.vue';
const { t } = useI18n();
const ide = useIdeStore();
@@ -120,6 +123,12 @@ function menuFor(s: SessionSummary): ContextMenuItem[] {
else if (s.source === 'discovered') items.push({ label: t('sessions.hide'), icon: EyeOff, onSelect: () => void actions.hide(s.id) });
if (s.archived) items.push({ label: t('sessions.unarchive'), icon: ArchiveRestore, onSelect: () => void actions.unarchive(s.id) });
else if (s.source === 'managed' && !s.live) items.push({ label: t('sessions.archive'), icon: Archive, onSelect: () => void actions.archive(s.id) });
// « Démarrer le projet » : arrêter d'un coup tous les terminaux vivants du même lancement.
if (s.live && s.launchRunId) {
const runId = s.launchRunId;
const n = store.sessions.filter((x) => x.live && x.launchRunId === runId).length;
if (n >= 2) items.push({ label: t('launch.stopAll', { n }), icon: Square, danger: true, onSelect: () => void store.stopLaunch(runId) });
}
return items;
}
@@ -129,10 +138,20 @@ function openRowMenu(e: MouseEvent, s: SessionSummary): void {
}
function openAddMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
const items: ContextMenuItem[] = [
{ label: t('sessions.newSession'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal) },
{ label: t('project.new'), icon: FolderPlus, onSelect: () => void modals.open(NewProjectModal) },
]);
];
// Raccourci « Démarrer le projet » ciblant le worktree actif de l'IDE, quand il existe.
const active = ide.activeContext;
if (active?.repoId) {
items.push({
label: t('launch.startProject'),
icon: Rocket,
onSelect: () => void modals.open(LaunchProjectModal, { repoId: active.repoId, ...(active.wtPath ? { worktreePath: active.wtPath } : {}) }),
});
}
ctx.open(e.clientX, e.clientY, items);
}
function openMoreMenu(e: MouseEvent): void {

View File

@@ -0,0 +1,185 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<Rocket :size="16" class="text-accent" />
<h2 class="font-semibold text-fg">{{ t('launch.title') }}</h2>
<span v-if="targetLabel" class="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[11px] text-fg-muted">{{ targetLabel }}</span>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p class="text-xs text-fg-subtle">{{ t('launch.hint') }}</p>
<!-- lignes de commandes éditables -->
<div class="flex flex-col gap-2">
<div v-for="(c, i) in rows" :key="c.id" class="flex items-center gap-2">
<input
v-model="c.enabled"
type="checkbox"
class="size-4 shrink-0 accent-[var(--color-accent-solid)]"
:title="t('launch.enabledTitle')"
/>
<input v-model="c.label" class="input w-28 shrink-0 text-xs" :placeholder="t('launch.labelPlaceholder')" />
<input v-model="c.run" class="input min-w-0 flex-1 font-mono text-xs" :placeholder="t('launch.runPlaceholder')" />
<input v-model="c.cwd" class="input w-24 shrink-0 font-mono text-xs" :placeholder="t('launch.cwdPlaceholder')" />
<button type="button" class="btn shrink-0 px-1.5" :title="t('launch.remove')" @click="removeRow(i)">
<Trash2 :size="13" />
</button>
</div>
<p v-if="rows.length === 0" class="rounded border border-dashed border-border px-3 py-4 text-center text-xs text-fg-subtle">
{{ t('launch.empty') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn inline-flex items-center gap-1 text-xs" @click="addRow">
<Plus :size="13" /> {{ t('launch.add') }}
</button>
<button type="button" class="btn inline-flex items-center gap-1 text-xs" :disabled="detecting" @click="detect">
<Sparkles :size="13" /> {{ detecting ? t('common.working') : t('launch.detect') }}
</button>
</div>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<footer class="mt-1 flex items-center gap-2 border-t border-border pt-3">
<button type="button" class="btn text-xs" :disabled="saving || working" @click="onSave">
{{ saving ? t('common.working') : t('launch.save') }}
</button>
<button
type="button"
class="btn-primary ml-auto inline-flex items-center gap-1.5"
:disabled="working || saving || enabledCount === 0"
@click="onStart"
>
<Play :size="14" />
{{ working ? t('common.working') : t('launch.start', { n: enabledCount }) }}
</button>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
// « Démarrer le projet » : édite les commandes de démarrage d'un repo (label / commande / sous-dossier
// / activée), détecte des suggestions (package.json/Procfile/docker-compose), enregistre, et lance
// un terminal par commande activée (ide.openTerminal en boucle). Le worktree cible est passé en prop.
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Play, Plus, Rocket, Sparkles, Trash2 } from '@lucide/vue';
import type { LaunchCommand } from '@arboretum/shared';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useSessionsStore } from '../../../stores/sessions';
import { useIdeStore } from '../../../stores/ide';
import { useToastsStore } from '../../../stores/toasts';
const props = defineProps<{ repoId: string; worktreePath?: string; targetLabel?: string }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const ide = useIdeStore();
const toasts = useToastsStore();
// Copie éditable locale des commandes du repo (jamais muter le store directement).
const rows = ref<LaunchCommand[]>([]);
const detecting = ref(false);
const saving = ref(false);
const working = ref(false);
const errorMsg = ref<string | null>(null);
const enabledCount = computed(() => rows.value.filter((c) => c.enabled && c.run.trim() !== '').length);
function newId(): string {
return globalThis.crypto?.randomUUID?.() ?? `lc-${Date.now()}-${rows.value.length}`;
}
onMounted(async () => {
const repo = worktrees.repos.find((r) => r.id === props.repoId);
rows.value = (repo?.launchCommands ?? []).map((c) => ({ ...c }));
// Auto-détection au premier ouvert si aucune commande n'est encore définie.
if (rows.value.length === 0) await detect();
});
function addRow(): void {
rows.value = [...rows.value, { id: newId(), label: '', run: '', enabled: true }];
}
function removeRow(i: number): void {
rows.value = rows.value.filter((_, idx) => idx !== i);
}
async function detect(): Promise<void> {
if (detecting.value) return;
detecting.value = true;
errorMsg.value = null;
try {
const suggestions = await worktrees.detectLaunch(props.repoId, props.worktreePath);
// Fusion : n'ajoute que les commandes dont le `run` n'est pas déjà présent.
const known = new Set(rows.value.map((c) => c.run.trim()));
const fresh = suggestions.filter((s) => !known.has(s.run.trim()));
if (fresh.length === 0 && suggestions.length > 0) toasts.info(t('launch.detectNone'));
rows.value = [...rows.value, ...fresh.map((c) => ({ ...c, id: newId() }))];
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
detecting.value = false;
}
}
/** Normalise avant persistance : trim, retire les lignes sans commande, enlève cwd vide. */
function normalized(): LaunchCommand[] {
return rows.value
.map((c) => ({
id: c.id,
label: c.label.trim() || c.run.trim().slice(0, 24),
run: c.run.trim(),
enabled: c.enabled,
...(c.cwd && c.cwd.trim() !== '' ? { cwd: c.cwd.trim() } : {}),
}))
.filter((c) => c.run !== '');
}
async function persist(): Promise<void> {
const cmds = normalized();
await worktrees.updateRepoLaunchCommands(props.repoId, cmds);
rows.value = cmds.map((c) => ({ ...c }));
}
async function onSave(): Promise<void> {
if (saving.value) return;
saving.value = true;
errorMsg.value = null;
try {
await persist();
toasts.success(t('launch.saved'));
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function onStart(): Promise<void> {
if (working.value || enabledCount.value === 0) return;
working.value = true;
errorMsg.value = null;
try {
// On enregistre d'abord (les ids persistés doivent correspondre aux commandIds envoyés).
await persist();
const ids = normalized().filter((c) => c.enabled).map((c) => c.id);
const res = await sessions.startLaunch(props.repoId, {
commandIds: ids,
...(props.worktreePath ? { worktreePath: props.worktreePath } : {}),
});
for (const s of res.sessions) ide.openTerminal(s.id);
if (res.skipped.length > 0) toasts.info(t('launch.partial', { started: res.sessions.length, skipped: res.skipped.length }));
else toasts.success(t('launch.started', { n: res.sessions.length }));
emit('close');
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
working.value = false;
}
}
</script>

View File

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

View File

@@ -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 lIDE',
files: 'Fichiers',

View File

@@ -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<StartLaunchResponse> {
const res = await api.post<StartLaunchResponse>(`/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<void> {
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<SessionSummary> {
const res = await api.post<SessionResponse>(`/api/v1/sessions/${id}/resume`);
@@ -180,6 +194,8 @@ export const useSessionsStore = defineStore('sessions', () => {
createSession,
createProject,
killSession,
startLaunch,
stopLaunch,
resumeSession,
forkSession,
hideSession,

View File

@@ -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<RepoSummary> {
const res = await api.patch<RepoResponse>(`/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<LaunchCommand[]> {
const qs = worktreePath ? `?worktreePath=${encodeURIComponent(worktreePath)}` : '';
const res = await api.get<DetectLaunchResponse>(`/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<DiscoverReposResponse> {
const res = await api.post<DiscoverReposResponse>('/api/v1/repos/discover');
@@ -183,6 +199,8 @@ export const useWorktreesStore = defineStore('worktrees', () => {
addRepo,
removeRepo,
setHidden,
updateRepoLaunchCommands,
detectLaunch,
discover,
refreshRepoWorktrees,
createWorktree,

View File

@@ -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 sil 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 dun dépôt ou dun 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 dun 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 nimporte quelle ligne de session.',
},
],
},
{