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
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:
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
202
packages/server/scripts/acceptance-p13.mjs
Normal file
202
packages/server/scripts/acceptance-p13.mjs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
83
packages/server/src/core/launch-detect.ts
Normal file
83
packages/server/src/core/launch-detect.ts
Normal 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)];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
55
packages/server/test/launch-detect.test.ts
Normal file
55
packages/server/test/launch-detect.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user