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).
119 lines
5.5 KiB
TypeScript
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) });
|
|
}
|
|
}
|
|
}
|