Files
arboretum/packages/server/src/core/clone-manager.ts
T
johanleroy 08695a707d feat(p12a): services git distants (PAT/app_password) + clone HTTPS
Modèle de données :
- migration #11 git_credentials (secrets chiffrés SecretBox : secret_encrypted ; colonnes ssh/oauth posées pour P12b/P12c) ; #12 repos ALTER remote_url/git_service/credential_id (pas de FK)
- types partagés api.ts (GitCredentialSummary sans secret + hasSecret/secretLast4, CRUD, RemoteRepoSummary, Clone*) ; protocole additif : topic 'clones' + message clone_update (CloneOperation)

Backend :
- core/git-credentials.ts (GitCredentialsManager(db, box)) : CRUD chiffré, test() (GET /user), getSecret()/authContext() internes, NULLification de repos.credential_id à la suppression
- core/git-clients/ (github/gitlab/gitea) via fetch, sans dépendance : verify()+listRepos() paginés, erreurs typées AUTH_FAILED/RATE_LIMITED/UNREACHABLE, SSRF base_url http(s)
- core/git-auth.ts : withGitAuth (GIT_ASKPASS éphémère 0o700, secret par env, GIT_TERMINAL_PROMPT=0, jamais dans l'URL/.git/config, nettoyage finally)
- core/git.ts cloneRepo (spawn git clone --progress, parse progression, timeout)
- core/clone-manager.ts (EventEmitter) : clone async, dest confiné sous scanRoots + non existant, auto-enregistrement via addRepo + métadonnées de provenance, nettoyage du clone partiel, events topic 'clones'
- routes/git-connections.ts (CRUD + /test + /:id/repos + POST /repos/clone 202 + GET /repos/clone/:id) ; app.ts câble box→GitCredentialsManager + CloneManager→gateway ; gateway relaie 'clones'

Frontend :
- ws-client subscribeClones ; stores git-connections + clone (suivi WS)
- components/settings/GitConnectionsSection (liste + formulaire pat/app_password, secret jamais ré-affiché) inséré dans SettingsView ; CloneRepoModal (connexion → repos distants paginés → dest scanRoots[0] → barre de progression WS → redirection) ; bouton « Cloner » dans DashboardView ; i18n EN+FR

Tests : git-credentials (round-trip SecretBox, résumé sans secret, NULLification) ; acceptance-p12.mjs (clone bare local file:// → clone_update done + repo enregistré + secret ABSENT de l'API, de la DB et du .git/config)

Sous-phases restantes : P12b (SSH), P12c (OAuth device flow).
2026-06-27 14:27:18 +02:00

119 lines
5.5 KiB
TypeScript

// 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<CloneManagerEvents> {
private readonly ops = new Map<string, CloneOperation>();
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<string> {
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<CloneOperation>): 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<void> {
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) });
}
}
}