feat: découverte automatique des dépôts git (scan + montrer/cacher)
Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations).
This commit is contained in:
@@ -11,6 +11,7 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
|
||||
import { PtyManager } from './core/pty-manager.js';
|
||||
import { DiscoveryService } from './core/discovery-service.js';
|
||||
import { WorktreeManager } from './core/worktree-manager.js';
|
||||
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
||||
import { GroupManager } from './core/group-manager.js';
|
||||
import { PushService } from './core/push-service.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
@@ -37,6 +38,7 @@ export interface AppBundle {
|
||||
auth: AuthService;
|
||||
manager: PtyManager;
|
||||
discovery: DiscoveryService;
|
||||
repoDiscovery: RepoDiscoveryService;
|
||||
worktrees: WorktreeManager;
|
||||
groups: GroupManager;
|
||||
push: PushService;
|
||||
@@ -54,6 +56,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
sessionsDir: config.claudeSessionsDir,
|
||||
});
|
||||
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||
// Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp.
|
||||
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
||||
const groups = new GroupManager(db);
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
@@ -95,7 +99,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerSessionRoutes(app, manager, discovery);
|
||||
registerRepoRoutes(app, worktrees);
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
@@ -119,5 +123,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
});
|
||||
}
|
||||
|
||||
return { app, auth, manager, discovery, worktrees, groups, push };
|
||||
return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface Config {
|
||||
claudeSessionsDir: string;
|
||||
/** sujet VAPID des notifications Web Push (mailto: ou URL). */
|
||||
vapidContact: string;
|
||||
/** découverte auto des repos au démarrage + périodique (désactivable via --no-discover). */
|
||||
autoDiscover: boolean;
|
||||
}
|
||||
|
||||
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
@@ -33,6 +35,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
'claude-home': { type: 'string' },
|
||||
// sujet VAPID des notifications push (contact requis par la spec Web Push).
|
||||
'vapid-contact': { type: 'string' },
|
||||
// désactive la découverte auto des repos (boot + périodique) — utilisé par les tests d'acceptation.
|
||||
'no-discover': { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
@@ -60,5 +64,6 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
claudeProjectsDir: join(claudeHome, 'projects'),
|
||||
claudeSessionsDir: join(claudeHome, 'sessions'),
|
||||
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
|
||||
autoDiscover: !(values['no-discover'] ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
42
packages/server/src/core/repo-discovery.ts
Normal file
42
packages/server/src/core/repo-discovery.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Planificateur de la découverte auto des repos : scan au démarrage + re-scan périodique.
|
||||
// Calqué sur DiscoveryService (sessions) — start()/stop() avec timer .unref(). Démarré depuis
|
||||
// runDaemon() UNIQUEMENT (jamais buildApp), ce qui isole naturellement les tests vitest du scan.
|
||||
// Lui-même sans état : il lit les racines/l'intervalle dans `settings` et délègue à WorktreeManager.
|
||||
import type { Db } from '../db/index.js';
|
||||
import type { WorktreeManager } from './worktree-manager.js';
|
||||
import { readScanIntervalMin, readScanRoots } from './scan-settings.js';
|
||||
|
||||
export class RepoDiscoveryService {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly worktrees: WorktreeManager,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
void this.refresh(); // scan initial asynchrone : ne bloque pas le boot
|
||||
const intervalMin = readScanIntervalMin(this.db);
|
||||
if (intervalMin > 0) {
|
||||
this.timer = setInterval(() => void this.refresh(), intervalMin * 60_000);
|
||||
this.timer.unref(); // ne maintient pas le process en vie
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relit les racines (changement effectif sans redémarrage) et lance un scan. Ne lève jamais. */
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.worktrees.discoverRepos({ roots: readScanRoots(this.db) });
|
||||
} catch {
|
||||
/* scan tolérant : une erreur ne doit pas tuer le timer */
|
||||
}
|
||||
}
|
||||
}
|
||||
92
packages/server/src/core/repo-scanner.ts
Normal file
92
packages/server/src/core/repo-scanner.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Découverte auto des dépôts git : marche bornée du système de fichiers à la recherche de `.git`.
|
||||
// Fonction PURE et tolérante (ne lève jamais) — testable isolément comme parseWorktreePorcelain.
|
||||
// N'appelle JAMAIS git (détection par présence de `.git`) : la validation réelle (isRepo) et la
|
||||
// résolution de default_branch se font paresseusement à l'enregistrement, pas par dépôt scanné.
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface ScanLimits {
|
||||
/** profondeur maximale de descente sous chaque racine (la racine = 0). */
|
||||
maxDepth: number;
|
||||
/** nombre maximal de repos retournés (garde-fou anti-explosion d'un FS pathologique). */
|
||||
maxRepos: number;
|
||||
/** noms de dossiers à ne jamais ouvrir (en plus des dotdirs, toujours exclus). */
|
||||
excludeDirs?: Set<string>;
|
||||
}
|
||||
|
||||
/** Dossiers jamais explorés : grosses arborescences sans repos racine, ou bruit de build. */
|
||||
export const DEFAULT_EXCLUDE_DIRS = new Set<string>([
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'target',
|
||||
'dist',
|
||||
'build',
|
||||
'.cache',
|
||||
'venv',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
]);
|
||||
|
||||
interface Frame {
|
||||
dir: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
|
||||
* Règles :
|
||||
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
||||
* on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ;
|
||||
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
||||
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
||||
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
||||
* - tolérance : un `readdir` qui échoue (EACCES/ENOENT) est ignoré, le scan continue ;
|
||||
* - racine inexistante/illisible : ignorée silencieusement.
|
||||
* Retourne les chemins absolus dédupliqués des racines de repos, et `truncated` si une borne a coupé.
|
||||
*/
|
||||
export async function scanForRepos(
|
||||
roots: string[],
|
||||
limits: ScanLimits,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ paths: string[]; truncated: boolean }> {
|
||||
const excludes = limits.excludeDirs ?? DEFAULT_EXCLUDE_DIRS;
|
||||
const found = new Set<string>();
|
||||
const seen = new Set<string>(); // ceinture-bretelles anti-cycle (chemins déjà visités)
|
||||
let truncated = false;
|
||||
|
||||
// pile partagée entre toutes les racines : un seul plafond global maxRepos.
|
||||
const stack: Frame[] = [];
|
||||
for (const root of roots) stack.push({ dir: root, depth: 0 });
|
||||
|
||||
while (stack.length > 0) {
|
||||
if (signal?.aborted || found.size >= limits.maxRepos) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const { dir, depth } = stack.pop() as Frame;
|
||||
if (seen.has(dir)) continue;
|
||||
seen.add(dir);
|
||||
|
||||
// un repo : on l'enregistre et on ne descend pas.
|
||||
if (existsSync(join(dir, '.git'))) {
|
||||
found.add(dir);
|
||||
continue;
|
||||
}
|
||||
if (depth >= limits.maxDepth) continue;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue; // EACCES/ENOENT/… : dossier ignoré, on poursuit
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue; // symlinks non suivis (isDirectory() est false pour un lien)
|
||||
if (e.name.startsWith('.') || excludes.has(e.name)) continue;
|
||||
stack.push({ dir: join(dir, e.name), depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return { paths: [...found], truncated };
|
||||
}
|
||||
65
packages/server/src/core/scan-settings.ts
Normal file
65
packages/server/src/core/scan-settings.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
||||
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
||||
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
||||
import { homedir } from 'node:os';
|
||||
import { getSetting } from '../db/index.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { isSafeAbsolutePath } from './git.js';
|
||||
|
||||
export const SCAN_ROOTS_KEY = 'scan_roots';
|
||||
export const SCAN_INTERVAL_KEY = 'scan_interval_min';
|
||||
|
||||
/** Intervalle par défaut du re-scan périodique (minutes). 0 = désactivé. */
|
||||
export const DEFAULT_SCAN_INTERVAL_MIN = 5;
|
||||
/** Borne haute de l'intervalle (24 h) et nombre maximal de racines. */
|
||||
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
||||
export const MAX_SCAN_ROOTS = 16;
|
||||
|
||||
/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */
|
||||
export function readScanRoots(db: Db): string[] {
|
||||
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
||||
if (!raw) return [homedir()];
|
||||
const parsed = normalizeScanRoots(safeParse(raw));
|
||||
return parsed && parsed.length > 0 ? parsed : [homedir()];
|
||||
}
|
||||
|
||||
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
||||
export function readScanIntervalMin(db: Db): number {
|
||||
const raw = getSetting(db, SCAN_INTERVAL_KEY);
|
||||
if (raw === null) return DEFAULT_SCAN_INTERVAL_MIN;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 0 && n <= MAX_SCAN_INTERVAL_MIN ? n : DEFAULT_SCAN_INTERVAL_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide/normalise une liste de racines : tableau de chemins absolus normalisés (isSafeAbsolutePath),
|
||||
* jamais `/` (scan catastrophique), dédupliqués, ≤ MAX_SCAN_ROOTS. Retourne null si invalide (⇒ 400).
|
||||
* Une liste vide est valide (revient au défaut côté lecture).
|
||||
*/
|
||||
export function normalizeScanRoots(raw: unknown): string[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
if (raw.length > MAX_SCAN_ROOTS) return null;
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'string') return null;
|
||||
const p = item.trim();
|
||||
if (!isSafeAbsolutePath(p) || p === '/') return null;
|
||||
if (!out.includes(p)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Valide un intervalle (entier 0–MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
|
||||
export function normalizeScanIntervalMin(raw: unknown): number | null {
|
||||
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
|
||||
if (raw < 0 || raw > MAX_SCAN_INTERVAL_MIN) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function safeParse(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type {
|
||||
DiscoverReposResponse,
|
||||
HookRunResult,
|
||||
PostCreateHook,
|
||||
RepoSummary,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
import type { Db } from '../db/index.js';
|
||||
import type { PtyManager } from './pty-manager.js';
|
||||
import { DiscoveryService, mergeSessions } from './discovery-service.js';
|
||||
import { scanForRepos } from './repo-scanner.js';
|
||||
import { preTrustProject } from './claude-trust.js';
|
||||
import {
|
||||
addWorktree,
|
||||
@@ -35,6 +37,10 @@ import {
|
||||
const FACTS_TTL_MS = 2500;
|
||||
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||
const HOOK_OUTPUT_MAX = 64 * 1024;
|
||||
// Bornes du scan de découverte (anti-explosion sur un home volumineux).
|
||||
const SCAN_MAX_DEPTH = 6;
|
||||
const SCAN_MAX_REPOS = 2000;
|
||||
const SCAN_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface RepoRow {
|
||||
id: string;
|
||||
@@ -44,6 +50,7 @@ interface RepoRow {
|
||||
post_create_hooks: string;
|
||||
pre_trust: number;
|
||||
created_at: string;
|
||||
hidden: number;
|
||||
}
|
||||
|
||||
export interface WorktreeManagerEvents {
|
||||
@@ -96,6 +103,8 @@ function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
||||
export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>();
|
||||
private readonly locks = new Map<string, Promise<unknown>>();
|
||||
/** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */
|
||||
private scanInFlight: Promise<DiscoverReposResponse> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
@@ -121,6 +130,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
preTrust: row.pre_trust === 1,
|
||||
createdAt: row.created_at,
|
||||
valid: await isRepo(row.path),
|
||||
hidden: row.hidden === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,24 +153,35 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []),
|
||||
pre_trust: opts.preTrust ? 1 : 0,
|
||||
created_at: new Date().toISOString(),
|
||||
hidden: 0,
|
||||
};
|
||||
this.db
|
||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at);
|
||||
try {
|
||||
this.db
|
||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
|
||||
} catch (err) {
|
||||
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
|
||||
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
|
||||
if (String((err as { code?: string }).code).includes('CONSTRAINT')) {
|
||||
throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const summary = await this.rowToSummary(row);
|
||||
this.emit('repo_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
||||
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise<RepoSummary> {
|
||||
const row = this.getRepoRow(id);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
||||
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
||||
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
||||
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
|
||||
this.db
|
||||
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ? WHERE id = ?')
|
||||
.run(row.label, row.post_create_hooks, row.pre_trust, id);
|
||||
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
|
||||
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, id);
|
||||
const summary = await this.rowToSummary(row);
|
||||
this.emit('repo_update', summary);
|
||||
return summary;
|
||||
@@ -174,6 +195,53 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Découvre les repos git sous `roots` et auto-enregistre les NOUVEAUX (path absent de la DB).
|
||||
* Idempotent et anti-résurrection : un path déjà présent — visible OU masqué — n'est jamais
|
||||
* réécrit (INSERT ... ON CONFLICT DO NOTHING). Les scans concurrents sont coalescés. Tolérant :
|
||||
* ne lève pas (le scanner avale les erreurs FS). N'appelle aucun git pendant le scan
|
||||
* (default_branch=NULL, résolu paresseusement par rowToSummary à l'affichage).
|
||||
*/
|
||||
discoverRepos(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
|
||||
if (this.scanInFlight) return this.scanInFlight;
|
||||
this.scanInFlight = this.runDiscovery(opts).finally(() => {
|
||||
this.scanInFlight = null;
|
||||
});
|
||||
return this.scanInFlight;
|
||||
}
|
||||
|
||||
private async runDiscovery(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
|
||||
const t0 = Date.now();
|
||||
const { paths, truncated } = await scanForRepos(
|
||||
opts.roots,
|
||||
{ maxDepth: opts.maxDepth ?? SCAN_MAX_DEPTH, maxRepos: opts.maxRepos ?? SCAN_MAX_REPOS },
|
||||
AbortSignal.timeout(SCAN_TIMEOUT_MS),
|
||||
);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden)
|
||||
VALUES (?, ?, ?, NULL, '[]', 0, ?, 0) ON CONFLICT(path) DO NOTHING`,
|
||||
);
|
||||
let added = 0;
|
||||
for (const path of paths) {
|
||||
const row: RepoRow = {
|
||||
id: randomUUID(),
|
||||
path,
|
||||
label: basename(path),
|
||||
default_branch: null,
|
||||
post_create_hooks: '[]',
|
||||
pre_trust: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
hidden: 0,
|
||||
};
|
||||
const res = insert.run(row.id, row.path, row.label, row.created_at);
|
||||
if (res.changes === 1) {
|
||||
added++;
|
||||
this.emit('repo_update', await this.rowToSummary(row)); // nouveaux uniquement
|
||||
}
|
||||
}
|
||||
return { scanned: paths.length, added, durationMs: Date.now() - t0, truncated };
|
||||
}
|
||||
|
||||
// ---- worktrees ----
|
||||
|
||||
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
|
||||
@@ -214,8 +282,13 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
}
|
||||
|
||||
async listAllWorktrees(): Promise<WorktreeSummary[]> {
|
||||
const rows = this.db.prepare('SELECT id FROM repos ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id)));
|
||||
// Les repos masqués sont exclus du dashboard : inutile de calculer leurs worktrees (sous-process
|
||||
// git par repo). Le front charge paresseusement ceux d'un repo masqué via listRepoWorktrees
|
||||
// quand l'utilisateur active « afficher les masqués ».
|
||||
const rows = this.db.prepare('SELECT id FROM repos WHERE hidden = 0 ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||
// Tolérance par repo : avec la découverte auto, un repo douteux (git en échec, chemin disparu,
|
||||
// permission) ne doit JAMAIS faire planter tout l'endpoint — il ne contribue alors aucun worktree.
|
||||
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id).catch(() => [])));
|
||||
return lists.flat();
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
CREATE INDEX idx_group_repos_repo ON group_repos(repo_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Découverte auto : un repo masqué reste en DB (exclu du dashboard) pour qu'un
|
||||
// re-scan ne le ressuscite pas. hidden=1 = masqué.
|
||||
id: 6,
|
||||
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
@@ -14,11 +14,12 @@ const pkg = JSON.parse(
|
||||
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
||||
export async function runDaemon(config: Config): Promise<void> {
|
||||
const db = openDb(config.dbPath);
|
||||
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
|
||||
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
|
||||
|
||||
const bootstrapToken = auth.ensureBootstrapToken();
|
||||
await app.listen({ port: config.port, host: config.bind });
|
||||
discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes
|
||||
if (config.autoDiscover) repoDiscovery.start(); // découverte auto des repos : scan au boot + re-scan périodique
|
||||
|
||||
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||
@@ -35,6 +36,7 @@ export async function runDaemon(config: Config): Promise<void> {
|
||||
shuttingDown = true;
|
||||
app.log.info(`${signal} received — draining sessions then exiting`);
|
||||
discovery.stop();
|
||||
repoDiscovery.stop();
|
||||
manager.shutdown();
|
||||
setTimeout(() => {
|
||||
void app.close().then(() => process.exit(0));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { CreateRepoRequest, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
|
||||
import type { CreateRepoRequest, DiscoverReposResponse, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
|
||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { readScanRoots } from '../core/scan-settings.js';
|
||||
|
||||
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
||||
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
@@ -8,9 +10,19 @@ export function sendManagerError(reply: FastifyReply, err: unknown): FastifyRepl
|
||||
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
||||
}
|
||||
|
||||
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
||||
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
|
||||
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
||||
|
||||
// Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings).
|
||||
app.post('/api/v1/repos/discover', async (_req, reply) => {
|
||||
try {
|
||||
const res: DiscoverReposResponse = await wt.discoverRepos({ roots: readScanRoots(db) });
|
||||
return reply.send(res);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/repos', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateRepoRequest> | null;
|
||||
if (!body || typeof body.path !== 'string') {
|
||||
@@ -38,6 +50,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): v
|
||||
...(body.label !== undefined ? { label: body.label } : {}),
|
||||
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
|
||||
});
|
||||
const res: RepoResponse = { repo };
|
||||
return reply.send(res);
|
||||
|
||||
@@ -6,8 +6,16 @@ import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arbor
|
||||
import type { Config } from '../config.js';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
import type { PushService } from '../core/push-service.js';
|
||||
import {
|
||||
SCAN_INTERVAL_KEY,
|
||||
SCAN_ROOTS_KEY,
|
||||
normalizeScanIntervalMin,
|
||||
normalizeScanRoots,
|
||||
readScanIntervalMin,
|
||||
readScanRoots,
|
||||
} from '../core/scan-settings.js';
|
||||
|
||||
// Seule clé de `settings` modifiable via l'API. Les secrets ne figurent JAMAIS ici.
|
||||
// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||
const GITEA_URL_KEY = 'gitea_url';
|
||||
|
||||
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
|
||||
@@ -42,7 +50,14 @@ export function registerSettingsRoutes(
|
||||
vapidPublicKey: push.publicKey() || null,
|
||||
vapidContact: config.vapidContact,
|
||||
});
|
||||
const snapshot = (): SettingsResponse => ({ settings: { giteaUrl: readGiteaUrl() }, server: serverInfo() });
|
||||
const snapshot = (): SettingsResponse => ({
|
||||
settings: {
|
||||
giteaUrl: readGiteaUrl(),
|
||||
scanRoots: readScanRoots(db),
|
||||
scanIntervalMin: readScanIntervalMin(db),
|
||||
},
|
||||
server: serverInfo(),
|
||||
});
|
||||
|
||||
app.get('/api/v1/settings', async (): Promise<SettingsResponse> => snapshot());
|
||||
|
||||
@@ -62,6 +77,20 @@ export function registerSettingsRoutes(
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } });
|
||||
}
|
||||
}
|
||||
if ('scanRoots' in body) {
|
||||
const roots = normalizeScanRoots(body.scanRoots);
|
||||
if (!roots) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanRoots must be an array of ≤16 absolute, normalized paths (never "/")' } });
|
||||
}
|
||||
setSetting(db, SCAN_ROOTS_KEY, JSON.stringify(roots));
|
||||
}
|
||||
if ('scanIntervalMin' in body) {
|
||||
const interval = normalizeScanIntervalMin(body.scanIntervalMin);
|
||||
if (interval === null) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanIntervalMin must be an integer between 0 and 1440' } });
|
||||
}
|
||||
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
||||
}
|
||||
return reply.send(snapshot());
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user