// Orchestration des clones (P12). Asynchrone : POST /repos/clone répond 202 avec un operationId, // la progression est poussée en WS (topic 'clones') et lisible en REST (survit au refresh). À la fin, // le repo cloné est auto-enregistré via WorktreeManager.addRepo (réutilise unicité/validation/event). // `dest` est strictement confiné SOUS une racine de scan + non existant (mkdir implicite par git). import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; import { rm, stat } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import type { CloneOperation, GitService } from '@arboretum/shared'; import type { Db } from '../db/index.js'; import { isSafeAbsolutePath, cloneRepo } from './git.js'; import { readScanRoots } from './scan-settings.js'; import { withGitAuth } from './git-auth.js'; import type { GitAuth } from './git-clients/index.js'; import type { GitCredentialsManager } from './git-credentials.js'; import type { WorktreeManager } from './worktree-manager.js'; import { recordAudit } from './audit-log.js'; export interface CloneManagerEvents { clone_update: [CloneOperation]; } function httpError(statusCode: number, code: string, message: string): Error & { statusCode: number; code: string } { return Object.assign(new Error(message), { statusCode, code }); } export class CloneManager extends EventEmitter { private readonly ops = new Map(); constructor( private readonly db: Db, private readonly worktrees: WorktreeManager, private readonly credentials: GitCredentialsManager, ) { super(); } get(id: string): CloneOperation | null { return this.ops.get(id) ?? null; } /** * Démarre un clone (validation synchrone du dest → throw 4xx ; clone asynchrone ensuite). * Retourne l'operationId à suivre via WS/REST. `actor` pour l'audit. */ async start(opts: { credentialId: string; remoteUrl: string; dest: string }, actor: string): Promise { const dest = resolve(opts.dest); if (!isSafeAbsolutePath(dest)) throw httpError(400, 'BAD_REQUEST', 'dest must be an absolute, normalized path'); if (typeof opts.remoteUrl !== 'string' || opts.remoteUrl.trim() === '') throw httpError(400, 'BAD_REQUEST', 'remoteUrl is required'); const ctx = this.credentials.authContext(opts.credentialId); if (!ctx) throw httpError(404, 'NOT_FOUND', 'No usable credential with this id'); // Confinement : dest DOIT être sous une racine de scan configurée (jamais d'écriture arbitraire). const roots = readScanRoots(this.db); if (roots.length === 0) throw httpError(400, 'NO_SCAN_ROOT', 'Configure a scan folder first (Settings → Discovery)'); const underRoot = roots.some((r) => { const root = resolve(r); return dest === root || dest.startsWith(root + sep); }); if (!underRoot) throw httpError(400, 'OUTSIDE_SCAN_ROOT', 'dest must be inside a configured scan folder'); // Le parent doit exister ; le dest ne doit pas exister (git clone le crée). const parent = dest.slice(0, dest.lastIndexOf(sep)) || sep; try { const st = await stat(parent); if (!st.isDirectory()) throw httpError(400, 'BAD_DEST', 'Parent of dest is not a directory'); } catch (err) { if ((err as { statusCode?: number }).statusCode) throw err; throw httpError(404, 'NOT_FOUND', `Parent directory does not exist: ${parent}`); } if (await stat(dest).then(() => true).catch(() => false)) throw httpError(409, 'DEST_EXISTS', `Destination already exists: ${dest}`); const id = randomUUID(); const op: CloneOperation = { id, state: 'pending', progress: null, phase: null, error: null, repoId: null, dest }; this.ops.set(id, op); recordAudit(this.db, { actor, action: 'repo.clone', resourceId: id, details: { service: ctx.service, dest } }); // Lancement asynchrone (ne bloque pas la réponse 202). void this.run(op, opts.remoteUrl, opts.credentialId, ctx); return id; } private update(op: CloneOperation, patch: Partial): void { Object.assign(op, patch); this.emit('clone_update', { ...op }); } private async run( op: CloneOperation, remoteUrl: string, credentialId: string, ctx: { service: GitService; baseUrl: string | null; auth: GitAuth }, ): Promise { this.update(op, { state: 'running' }); let lastPct = -10; const onProgress = (p: { phase: string; percent: number | null }): void => { // throttle : on ne pousse que sur changement de phase ou +3% pour éviter le flood WS. if (p.percent == null || p.percent - lastPct >= 3 || p.phase !== op.phase) { lastPct = p.percent ?? lastPct; this.update(op, { phase: p.phase, progress: p.percent }); } }; try { await withGitAuth(ctx.service, ctx.auth, (env) => cloneRepo({ url: remoteUrl, dest: op.dest, env, onProgress }), ); // auto-enregistrement du repo cloné + métadonnées de provenance. const repo = await this.worktrees.addRepo({ path: op.dest }); this.db .prepare('UPDATE repos SET remote_url = ?, git_service = ?, credential_id = ? WHERE id = ?') .run(remoteUrl, ctx.service, credentialId, repo.id); this.update(op, { state: 'done', progress: 100, repoId: repo.id }); } catch (err) { // nettoyage du clone partiel (best-effort). await rm(op.dest, { recursive: true, force: true }).catch(() => {}); this.update(op, { state: 'error', error: err instanceof Error ? err.message : String(err) }); } } }