feat(settings): réglages Claude CLI (binaire + ~/.claude) & notif « disponible »

- Réglages → Claude CLI : override du chemin du binaire `claude` (effet à la
  prochaine session, fallback `which claude`), diagnostic de détection, et
  override de la racine ~/.claude (effet au redémarrage). Validateurs stricts.
- Push : notifie aussi sur le front busy→idle (session redevenue disponible).
- Renomme l'état affiché idle → « disponible » / « available » (web EN/FR + VS Code) ;
  l'enum SessionActivity du protocole reste inchangé.
This commit is contained in:
2026-06-24 10:15:29 +02:00
parent 2506dfb1f3
commit b5236b41c8
16 changed files with 519 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
@@ -27,3 +27,28 @@ describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
expect(spec.args).toEqual(['--norc']);
});
});
describe('resolveClaudeBin / diagnoseClaudeBin — override de chemin (réglage UI)', () => {
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
const realBin = process.execPath;
it('buildSpawnSpec utilise le chemin configuré tel quel quand fourni', () => {
const spec = buildSpawnSpec({ command: 'claude', claudeBinPath: realBin });
expect(spec.file).toBe(realBin);
});
it('resolveClaudeBin retombe sur le PATH (which) sans override', () => {
expect(resolveClaudeBin()).toBe('/usr/bin/claude');
expect(resolveClaudeBin(null)).toBe('/usr/bin/claude');
});
it('resolveClaudeBin throw si le chemin configuré nest pas exécutable', () => {
expect(() => resolveClaudeBin('/no/such/claude')).toThrow(/not executable/);
});
it('diagnoseClaudeBin : configuré+exécutable → ok ; configuré absent → !ok ; sinon PATH', () => {
expect(diagnoseClaudeBin(realBin)).toEqual({ path: realBin, source: 'configured', ok: true });
expect(diagnoseClaudeBin('/no/such/claude')).toEqual({ path: '/no/such/claude', source: 'configured', ok: false });
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
});
});

View File

@@ -0,0 +1,89 @@
// Réglages CLI Claude (chemin du binaire + override ~/.claude) : validateurs + lecture/écriture DB.
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { openDb, setSetting, type Db } from '../src/db/index.js';
import {
CLAUDE_BIN_PATH_KEY,
CLAUDE_HOME_KEY,
normalizeClaudeBinPath,
normalizeClaudeHome,
readClaudeBinPath,
readClaudeHome,
} from '../src/core/claude-settings.js';
let dir: string;
let execFile: string;
let plainFile: string;
let subDir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'arboretum-claude-settings-'));
execFile = join(dir, 'claude');
writeFileSync(execFile, '#!/bin/sh\n');
chmodSync(execFile, 0o755); // exécutable
plainFile = join(dir, 'notexec');
writeFileSync(plainFile, 'x');
chmodSync(plainFile, 0o644); // non exécutable
subDir = join(dir, 'home');
mkdirSync(subDir);
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
describe('normalizeClaudeBinPath', () => {
it('accepte un chemin absolu vers un fichier exécutable', () => {
expect(normalizeClaudeBinPath(execFile)).toBe(execFile);
});
it('"" = réinitialisation (auto-détection)', () => {
expect(normalizeClaudeBinPath('')).toBe('');
expect(normalizeClaudeBinPath(' ')).toBe('');
});
it('rejette un fichier non exécutable, un répertoire, un chemin relatif ou avec ".." (null)', () => {
expect(normalizeClaudeBinPath(plainFile)).toBeNull();
expect(normalizeClaudeBinPath(subDir)).toBeNull(); // répertoire, pas un fichier
expect(normalizeClaudeBinPath('relative/claude')).toBeNull();
expect(normalizeClaudeBinPath(join(dir, '..', 'x'))).toBeNull();
expect(normalizeClaudeBinPath(join(dir, 'missing'))).toBeNull();
expect(normalizeClaudeBinPath(42)).toBeNull();
});
});
describe('normalizeClaudeHome', () => {
it('accepte un chemin absolu vers un répertoire existant', () => {
expect(normalizeClaudeHome(subDir)).toBe(subDir);
});
it('"" = réinitialisation (défaut)', () => {
expect(normalizeClaudeHome('')).toBe('');
});
it('rejette un fichier, un chemin relatif/inexistant (null)', () => {
expect(normalizeClaudeHome(execFile)).toBeNull(); // fichier, pas un répertoire
expect(normalizeClaudeHome('relative/.claude')).toBeNull();
expect(normalizeClaudeHome(join(dir, 'missing'))).toBeNull();
expect(normalizeClaudeHome(null)).toBeNull();
});
});
describe('readClaudeBinPath / readClaudeHome', () => {
let db: Db;
beforeAll(() => {
db = openDb(join(dir, 'settings.db'));
});
afterAll(() => db.close());
it('null quand non défini', () => {
expect(readClaudeBinPath(db)).toBeNull();
expect(readClaudeHome(db)).toBeNull();
});
it('lit la valeur stockée et traite "" stockée comme null', () => {
setSetting(db, CLAUDE_BIN_PATH_KEY, execFile);
setSetting(db, CLAUDE_HOME_KEY, subDir);
expect(readClaudeBinPath(db)).toBe(execFile);
expect(readClaudeHome(db)).toBe(subDir);
setSetting(db, CLAUDE_BIN_PATH_KEY, ''); // réinitialisé
setSetting(db, CLAUDE_HOME_KEY, '');
expect(readClaudeBinPath(db)).toBeNull();
expect(readClaudeHome(db)).toBeNull();
});
});

View File

@@ -414,6 +414,44 @@ describe('PtyManager (pty mocké)', () => {
rmSync(sessDir, { recursive: true, force: true });
}
});
it('une notif sur le front montant busy→idle (Claude a terminé → disponible), après le debounce', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-idle-'));
const notifies: PushPayload[] = [];
const fakePush = {
notify: async (p: PushPayload) => {
notifies.push(p);
},
} as unknown as PushService;
const m = new PtyManager(db, sessDir, fakePush);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
const regFile = join(sessDir, `${p.pid}.json`);
const writeReg = (status: string): void =>
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status }));
// busy d'abord : front montant vers busy, pas de notif
writeReg('busy');
p.emitData('working…');
await sleep(260);
expect(notifies).toHaveLength(0);
// idle : Claude a terminé → front busy→idle → planifie la notif (debounce 1500ms)
writeReg('idle');
p.emitData('\x1b[2J\x1b[HDone.\r\n');
await sleep(260);
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
await sleep(1500); // dépasse le debounce
expect(notifies).toHaveLength(1);
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: null, url: `/sessions/${summary.id}` });
expect(notifies[0].body).toMatch(/available/i);
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('flow control', () => {

View File

@@ -48,6 +48,8 @@ beforeAll(() => {
dataDir: dir,
allowedOrigins: ['https://host.tailnet.ts.net'],
printToken: false,
claudeHome: join(dir, 'claude'),
claudeHomeFromFlag: false,
claudeProjectsDir: join(dir, 'claude', 'projects'),
claudeSessionsDir: join(dir, 'claude', 'sessions'),
vapidContact: 'mailto:test@localhost',
@@ -77,6 +79,11 @@ describe('GET /api/v1/settings', () => {
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
expect(body.settings.scanRoots).toEqual([]);
expect(body.settings.scanIntervalMin).toBe(5);
// Claude CLI : aucun override par défaut + diagnostic via PATH (execFileSync mocké → /usr/bin/claude).
expect(body.settings.claudeBinPath).toBeNull();
expect(body.settings.claudeHome).toBeNull();
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
});
it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => {
@@ -137,3 +144,46 @@ describe('PATCH /api/v1/settings — découverte des dépôts', () => {
expect(neg.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/settings — Claude CLI', () => {
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
const realBin = process.execPath;
it('enregistre un chemin de binaire exécutable et reflète le diagnostic (source=configured)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.settings.claudeBinPath).toBe(realBin);
expect(body.server.claudeBin).toEqual({ path: realBin, source: 'configured', ok: true });
});
it('réinitialise le chemin avec "" (retour à lauto-détection via PATH)', async () => {
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: '' } });
const body = res.json() as SettingsResponse;
expect(body.settings.claudeBinPath).toBeNull();
expect(body.server.claudeBin.source).toBe('path'); // de nouveau via PATH (mock → /usr/bin/claude)
});
it('rejette un chemin non absolu ou non exécutable (400)', async () => {
for (const claudeBinPath of ['relative/claude', '/a/../b', dir /* répertoire, pas un fichier */]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath } });
expect(res.statusCode).toBe(400);
}
});
it('enregistre un override claude_home (répertoire existant) et le réinitialise avec ""', async () => {
const set = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: dir } });
expect(set.statusCode).toBe(200);
expect((set.json() as SettingsResponse).settings.claudeHome).toBe(dir);
const reset = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: '' } });
expect((reset.json() as SettingsResponse).settings.claudeHome).toBeNull();
});
it('rejette un claude_home non absolu ou inexistant/fichier (400)', async () => {
for (const claudeHome of ['relative/.claude', join(dir, 'settings.db') /* fichier */, join(dir, 'nope')]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome } });
expect(res.statusCode).toBe(400);
}
});
});