// 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, GroupSessionResponse, GroupsListResponse, MeResponse, PromoteWorktreeRequest, PushWorktreeRequest, RepoBranchesResponse, ReposListResponse, SessionResponse, SessionsListResponse, 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); } 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); } // ---- sessions ---- createSession(body: CreateSessionRequest): Promise { return this.request('POST', '/api/v1/sessions', body); } listSessions(includeHidden = false): Promise { const q = includeHidden ? '?includeHidden=true' : ''; return this.request('GET', `/api/v1/sessions${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; } }