// Client REST typé du daemon Arboretum : module pur (pas d'import vscode), testable. // Auth par en-tête `Authorization: Bearer ` (le hook preValidation du serveur l'accepte // au même titre que le cookie). Un client Node n'envoie pas d'en-tête Origin → il passe le check // Origin strict côté serveur. Réutilise les types de `@arboretum/shared`. import type { ApiError, CommitWorktreeRequest, CreateGroupSessionRequest, CreateSessionRequest, CreateWorktreeRequest, CreateWorktreeResponse, FetchWorktreeRequest, GroupSessionResponse, GroupsListResponse, MeResponse, PromoteWorktreeRequest, PullWorktreeRequest, PushWorktreeRequest, RepoBranchesResponse, ReposListResponse, SessionResponse, SessionsListResponse, StartLaunchRequest, StartLaunchResponse, StartRepoSessionRequest, WorktreeResponse, WorktreesListResponse, } from '@arboretum/shared'; import { normalizeBaseUrl } from '../config.js'; /** Erreur normalisée portant le code/HTTP renvoyés par le serveur. */ export class RestError extends Error { constructor( message: string, readonly status: number, readonly code: string, ) { super(message); this.name = 'RestError'; } } type FetchLike = typeof fetch; export class RestClient { private baseUrl: string; private token: string | null; constructor( opts: { baseUrl: string; token?: string | null }, private readonly fetchImpl: FetchLike = fetch, ) { this.baseUrl = normalizeBaseUrl(opts.baseUrl); this.token = opts.token ?? null; } setBaseUrl(url: string): void { this.baseUrl = normalizeBaseUrl(url); } setToken(token: string | null): void { this.token = token; } get url(): string { return this.baseUrl; } // ---- auth ---- /** Valide le token courant ; lève RestError(401) si invalide. */ me(): Promise { return this.request('GET', '/api/v1/auth/me'); } // ---- repos & worktrees ---- listRepos(): Promise { return this.request('GET', '/api/v1/repos'); } listWorktrees(): Promise { return this.request('GET', '/api/v1/worktrees'); } getBranches(repoId: string): Promise { return this.request('GET', `/api/v1/repos/${encodeURIComponent(repoId)}/branches`); } createWorktree(repoId: string, body: CreateWorktreeRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees`, body); } commitWorktree(repoId: string, body: CommitWorktreeRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/commit`, body); } pushWorktree(repoId: string, body: PushWorktreeRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/push`, body); } fetchWorktree(repoId: string, body: FetchWorktreeRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/fetch`, body); } pullWorktree(repoId: string, body: PullWorktreeRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/pull`, body); } promoteWorktree(repoId: string, body: PromoteWorktreeRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/promote`, body); } startRepoSession(repoId: string, body: StartRepoSessionRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body); } /** « Démarrer le projet » : lance un terminal par commande de démarrage activée du repo. */ startLaunch(repoId: string, body: StartLaunchRequest): Promise { return this.request('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/launch`, body); } // ---- sessions ---- createSession(body: CreateSessionRequest): Promise { return this.request('POST', '/api/v1/sessions', body); } listSessions(opts: { includeHidden?: boolean; includeArchived?: boolean } = {}): Promise { const params = new URLSearchParams(); if (opts.includeHidden) params.set('includeHidden', 'true'); if (opts.includeArchived) params.set('includeArchived', 'true'); const q = params.toString(); return this.request('GET', `/api/v1/sessions${q ? `?${q}` : ''}`); } resumeSession(id: string): Promise { return this.request('POST', `/api/v1/sessions/${encodeURIComponent(id)}/resume`, {}); } forkSession(id: string): Promise { return this.request('POST', `/api/v1/sessions/${encodeURIComponent(id)}/fork`, {}); } hideSession(id: string): Promise<{ ok: true }> { return this.request<{ ok: true }>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/hide`, {}); } killSession(id: string): Promise<{ ok: true }> { return this.request<{ ok: true }>('DELETE', `/api/v1/sessions/${encodeURIComponent(id)}`); } // ---- groupes ---- listGroups(): Promise { return this.request('GET', '/api/v1/groups'); } createGroupSession(groupId: string, body: CreateGroupSessionRequest): Promise { return this.request('POST', `/api/v1/groups/${encodeURIComponent(groupId)}/session`, body); } // ---- interne ---- private async request(method: string, path: string, body?: unknown): Promise { const headers: Record = { Accept: 'application/json' }; if (this.token) headers.Authorization = `Bearer ${this.token}`; if (body !== undefined) headers['Content-Type'] = 'application/json'; const init: RequestInit = { method, headers }; if (body !== undefined) init.body = JSON.stringify(body); let res: Response; try { res = await this.fetchImpl(`${this.baseUrl}${path}`, init); } catch (err) { throw new RestError(`Cannot reach Arboretum at ${this.baseUrl}: ${(err as Error).message}`, 0, 'NETWORK'); } if (!res.ok) { let code = `HTTP_${res.status}`; let message = `${method} ${path} → ${res.status}`; try { const data = (await res.json()) as ApiError; if (data?.error) { code = data.error.code ?? code; message = data.error.message ?? message; } } catch { /* corps non-JSON : on garde le message générique */ } throw new RestError(message, res.status, code); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } }