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).
This commit is contained in:
2026-06-27 14:27:18 +02:00
parent e8d10b7ec0
commit 08695a707d
22 changed files with 1583 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
// Types REST partagés (préfixe /api/v1).
import type { GroupSummary, PostCreateHook, RepoSummary, SessionSummary, SettingsBroadcast, WorktreeSummary } from './protocol.js';
import type { CloneOperation, GroupSummary, PostCreateHook, RepoSummary, SessionSummary, SettingsBroadcast, WorktreeSummary } from './protocol.js';
export interface ApiError {
error: { code: string; message: string; details?: unknown };
@@ -471,3 +471,88 @@ export interface DeleteMyDataResponse {
/** récapitulatif de ce qui sera/a été supprimé. */
summary: { pushSubscriptions: number; tokenRevoked: boolean };
}
// ---- Services git distants + clone (P12) ----
export type GitService = 'gitea' | 'gitlab' | 'github';
export type GitAuthType = 'pat' | 'app_password' | 'ssh_key' | 'oauth';
/**
* Résumé d'un credential exposé à l'UI. Ne contient JAMAIS le secret : `hasSecret` indique sa
* présence, `secretLast4` n'en révèle que les 4 derniers caractères (repère visuel, non sensible).
*/
export interface GitCredentialSummary {
id: string;
label: string;
service: GitService;
baseUrl: string | null;
authType: GitAuthType;
username: string | null;
hasSecret: boolean;
secretLast4: string | null;
createdAt: string;
lastTestedAt: string | null;
/** diagnostic du dernier /test : 'ok' | 'AUTH_FAILED' | 'RATE_LIMITED' | 'UNREACHABLE' | … */
testResult: string | null;
}
export interface GitCredentialsListResponse {
credentials: GitCredentialSummary[];
}
export interface CreateGitCredentialRequest {
label: string;
service: GitService;
/** P12a : 'pat' | 'app_password' (HTTPS). 'ssh_key'/'oauth' arrivent en P12b/P12c. */
authType: GitAuthType;
/** instance self-hosted (Gitea/GitLab) ; absent = service public (github.com / gitlab.com). */
baseUrl?: string;
username?: string;
/** PAT ou app password — chiffré côté serveur (SecretBox), jamais relu. */
secret?: string;
}
export interface UpdateGitCredentialRequest {
label?: string;
baseUrl?: string;
username?: string;
/** si fourni, re-chiffre le secret ; absent = inchangé. */
secret?: string;
}
export interface GitCredentialResponse {
credential: GitCredentialSummary;
}
export interface TestCredentialResponse {
ok: boolean;
/** login renvoyé par le service en cas de succès. */
user?: string;
/** code d'erreur typé en cas d'échec (AUTH_FAILED, RATE_LIMITED, UNREACHABLE). */
error?: string;
}
/** Dépôt distant listé via l'API du service. */
export interface RemoteRepoSummary {
/** owner/name */
fullName: string;
/** URL HTTPS de clone. */
cloneUrl: string;
private: boolean;
description: string | null;
defaultBranch: string | null;
}
export interface RemoteReposResponse {
repos: RemoteRepoSummary[];
/** numéro de page suivant si d'autres résultats existent, sinon null. */
nextPage: number | null;
}
export interface CloneRequest {
credentialId: string;
/** URL HTTPS du dépôt à cloner. */
remoteUrl: string;
/** dossier de destination (sous une racine de scan) — créé par le clone, refus si existant. */
dest: string;
}
export interface CloneStartResponse {
operationId: string;
}
/** État d'un clone (GET /repos/clone/:id) — même forme que le push WS clone_update. */
export interface CloneStatusResponse {
operation: CloneOperation;
}

View File

@@ -225,6 +225,23 @@ export interface SettingsBroadcast {
purgeDays: number;
}
// ---- Clone d'un dépôt distant (P12) ----
export type CloneState = 'pending' | 'running' | 'done' | 'error';
/** État d'une opération de clone (poussé en WS sous le topic 'clones' ET lu en REST). */
export interface CloneOperation {
id: string;
state: CloneState;
/** progression 0100 si git la communique, sinon null. */
progress: number | null;
/** phase courante rapportée par git (« Receiving objects », « Resolving deltas »…). */
phase: string | null;
error: string | null;
/** id du repo enregistré une fois le clone terminé (null avant). */
repoId: string | null;
/** dossier de destination du clone. */
dest: string;
}
// ---- Messages client → serveur ----
export type ClientMessage =
| { type: 'hello'; protocol: number }
@@ -237,7 +254,7 @@ export type ClientMessage =
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
| { type: 'resize'; channel: number; cols: number; rows: number }
| { type: 'ack'; channel: number; bytes: number }
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups' | 'settings'> }
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> }
// P7 — abonnement ciblé au détail d'un worktree (diff/statut fin) : on ne pousse le détail
// qu'aux connexions qui « regardent » ce worktree (clé = repoId + path), indépendamment du
// topic global 'worktrees' (qui ne transporte que les compteurs légers).
@@ -259,6 +276,9 @@ export type ServerMessage =
// P11 — un réglage a changé (PATCH /settings) : on diffuse le snapshot non sensible aux abonnés
// du topic 'settings' pour que tous les clients (et onglets) se synchronisent sans polling.
| { type: 'settings_update'; settings: SettingsBroadcast }
// P12 — progression/fin/erreur d'un clone, poussé aux abonnés du topic 'clones'. L'état complet
// (state) discrimine progress/done/error en un seul message.
| { type: 'clone_update'; operation: CloneOperation }
| { type: 'repo_update'; repo: RepoSummary }
| { type: 'repo_removed'; repoId: string }
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
@@ -326,8 +346,8 @@ export function parseClientMessage(raw: string): ClientMessage | null {
? { type: 'ack', channel: m.channel, bytes: m.bytes }
: null;
case 'sub':
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups' || t === 'settings')
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups' | 'settings'> }
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups' || t === 'settings' || t === 'clones')
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> }
: null;
case 'watch':
case 'unwatch':