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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 0–100 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':
|
||||
|
||||
Reference in New Issue
Block a user