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:
@@ -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é n’est 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 });
|
||||
});
|
||||
});
|
||||
|
||||
89
packages/server/test/claude-settings.test.ts
Normal file
89
packages/server/test/claude-settings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('n’expose 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 à l’auto-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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user