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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user