chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s

Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
This commit is contained in:
2026-07-17 16:44:00 +02:00
parent 985531a986
commit 65ef616867
122 changed files with 538 additions and 521 deletions

View File

@@ -19,7 +19,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-'));

View File

@@ -3,7 +3,7 @@
// Vrai daemon + faux binaire `claude` (echo+sleep, comme p2) + 2e connexion sqlite (WAL) pour fabriquer
// des sessions managées terminées avec un ended_at ancien. Couvre : sweep archive-now (archived_at posé,
// event WS session_archived), exclusion par défaut + inclusion via ?includeArchived, archive/unarchive
// manuels, rétention=0 (no-op), et preuve clé « pas de perte » resume d'une session ARCHIVÉE → 201.
// manuels, rétention=0 (no-op), et, preuve clé « pas de perte », resume d'une session ARCHIVÉE → 201.
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -23,7 +23,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p10-'));

View File

@@ -20,7 +20,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p11-'));

View File

@@ -22,7 +22,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p12-'));

View File

@@ -22,7 +22,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p2-'));

View File

@@ -19,7 +19,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p3-'));

View File

@@ -2,7 +2,7 @@
// Acceptation P4 (sans navigateur, sans quota Claude) : Web Push + commande WS `answer`.
// Vrai daemon. Couvre : garde auth + Origin sur les routes push, clé VAPID exposée, subscribe
// idempotent / malformé / unsubscribe, et la commande `answer` (rejets INVALID_ANSWER /
// NOT_CONTROLLING la validation fine `select` vit dans les tests vitest sur fixtures).
// NOT_CONTROLLING : la validation fine `select` vit dans les tests vitest sur fixtures).
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -20,7 +20,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p4-'));

View File

@@ -20,7 +20,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));

View File

@@ -20,7 +20,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p7-'));

View File

@@ -16,7 +16,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p8-'));

View File

@@ -16,7 +16,7 @@ const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` ${detail}` : ''}`);
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p9-'));

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Embarque la SPA buildée (packages/web/dist) dans public/ du package server,
// pour que le tarball npm soit autonome. Branché sur le hook "prepack".
// ARBORETUM_PACK_NO_WEB=1 : mode tolérant (CI/smoke) placeholder à la place du front.
// ARBORETUM_PACK_NO_WEB=1 : mode tolérant (CI/smoke), placeholder à la place du front.
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -19,10 +19,10 @@ if (!existsSync(webDist)) {
'<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>Arboretum</title></head>\n' +
'<body><p>Arboretum server is running, but this package was built without the web UI.</p></body>\n</html>\n',
);
console.log('copy-web: ARBORETUM_PACK_NO_WEB=1 wrote placeholder public/index.html');
console.log('copy-web: ARBORETUM_PACK_NO_WEB=1: wrote placeholder public/index.html');
process.exit(0);
}
console.error(`copy-web: ${webDist} not found run npm run build -w @arboretum/web first`);
console.error(`copy-web: ${webDist} not found: run npm run build -w @arboretum/web first`);
process.exit(1);
}

View File

@@ -4,7 +4,7 @@
//
// Pourquoi PAS bundleDependencies : embarquer une dépendance qui est aussi un *workspace* via
// bundleDependencies est instable selon l'environnement npm (mode -w, exécution en root sur un
// runner CI, version d'arborist) npm voit le nœud comme un lien workspace et n'embarque parfois
// runner CI, version d'arborist) : npm voit le nœud comme un lien workspace et n'embarque parfois
// AUCUN fichier ("bundled files: 0"), produisant un paquet cassé chez le consommateur. On élimine
// donc toute magie de bundling : on copie le JS compilé de shared dans dist/_shared et on réécrit
// l'import bare '@arboretum/shared' du serveur vers ce chemin relatif. Zéro node_modules embarqué,
@@ -20,7 +20,7 @@ const inlineDir = join(serverDist, '_shared');
for (const [label, p] of [['dist serveur', serverDist], ['dist shared', sharedDist]]) {
if (!existsSync(p)) {
console.error(`inline-shared: ${label} introuvable (${p}) lance "npm run build" avant le pack.`);
console.error(`inline-shared: ${label} introuvable (${p}) : lance "npm run build" avant le pack.`);
process.exit(1);
}
}
@@ -38,7 +38,7 @@ for (const name of readdirSync(sharedDist)) {
}
}
if (copied === 0) {
console.error(`inline-shared: aucun .js dans ${sharedDist} shared n'est pas compilé.`);
console.error(`inline-shared: aucun .js dans ${sharedDist} : shared n'est pas compilé.`);
process.exit(1);
}
@@ -64,7 +64,7 @@ for (const file of walk(serverDist)) {
}
}
if (rewritten === 0) {
console.error(`inline-shared: aucun import '@arboretum/shared' réécrit dans ${serverDist} build manquant ?`);
console.error(`inline-shared: aucun import '@arboretum/shared' réécrit dans ${serverDist} : build manquant ?`);
process.exit(1);
}

View File

@@ -103,7 +103,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
projectsDir: config.claudeProjectsDir,
sessionsDir: config.claudeSessionsDir,
});
// Archivage auto des sessions terminées (P10). Démarré dans runDaemon() jamais ici (tests).
// Archivage auto des sessions terminées (P10). Démarré dans runDaemon() : jamais ici (tests).
const sessionArchive = new SessionArchiveService({ db });
// Watcher FS temps réel (P7) : armé à la demande sur les worktrees regardés via les commandes
// WS watch/unwatch. closeAll() au drain (libération des descripteurs).
@@ -112,9 +112,9 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
// 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);
// P11 bus de diffusion des réglages : PATCH /settings émet, la gateway relaie au topic 'settings'.
// P11 · bus de diffusion des réglages : PATCH /settings émet, la gateway relaie au topic 'settings'.
const settingsBus = new SettingsBus();
// P12 services git distants : `box` (SecretBox) câblé ici pour chiffrer les secrets des credentials.
// P12 · services git distants : `box` (SecretBox) câblé ici pour chiffrer les secrets des credentials.
const gitCredentials = new GitCredentialsManager(db, box);
const clones = new CloneManager(db, worktrees, gitCredentials);
@@ -176,7 +176,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
};
// Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent
// (anti cross-site WebSocket hijacking le cookie SameSite=Strict ne suffit pas pour les upgrades).
// (anti cross-site WebSocket hijacking : le cookie SameSite=Strict ne suffit pas pour les upgrades).
app.addHook('preValidation', async (req, reply) => {
const isApi = req.url.startsWith('/api/');
const isWs = req.url.startsWith('/ws');
@@ -207,12 +207,12 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerAuditRoutes(app, db);
registerDataRoutes(app, db, auth);
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
// encapsulé) sinon le handler reçoit la signature REST (request, reply).
// encapsulé) : sinon le handler reçoit la signature REST (request, reply).
void app.register(async (scoped) => {
registerWsGateway(scoped, manager, discovery, sessionArchive, worktrees, groups, settingsBus, clones, serverVersion);
});
// SPA buildée embarquée dans le paquet npm (public/) absente en dev (vite dev sert le front)
// SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front)
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
if (existsSync(publicDir)) {
void app.register(fastifyStatic, { root: publicDir, wildcard: false });

View File

@@ -131,7 +131,7 @@ export function renderSystemdUnit(input: { exec: string; scriptArgs: string[]; p
const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : '';
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
return `[Unit]
Description=Arboretum git worktree & Claude Code dashboard
Description=Arboretum · git worktree & Claude Code dashboard
After=network-online.target
Wants=network-online.target
@@ -195,14 +195,14 @@ ${args}
export function printTokenBanner(token: string, url: string): void {
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
console.log('│ First start your access token (shown once, store it safely): │');
console.log('│ First start : your access token (shown once, store it safely): │');
console.log('└──────────────────────────────────────────────────────────────────┘');
console.log(`\n ${token}\n`);
console.log(` Login at: ${url}/\n`);
}
export function printUsage(version: string): void {
console.log(`Arboretum v${version} git worktree & Claude Code dashboard
console.log(`Arboretum v${version} · git worktree & Claude Code dashboard
Usage:
arboretum [flags] Start the daemon (default)
@@ -218,7 +218,7 @@ Daemon flags:
--allow-origin <url> Additional allowed Origin (repeatable)
--db <path> SQLite database path
--vapid-contact <mailto|url> VAPID contact subject for Web Push
--i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid prefer Tailscale Serve)
--i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid, prefer Tailscale Serve)
Install flags (daemon flags above are propagated to the service):
--bin-path <path> Use this binary in the service instead of node + script
@@ -257,7 +257,7 @@ function bootstrapToken(serviceArgs: string[]): void {
try {
const token = new AuthService(db).ensureBootstrapToken();
if (token) printTokenBanner(token, url);
else console.log('\nAn access token already exists in this database — manage tokens from Settings.\n');
else console.log('\nAn access token already exists in this database. Manage tokens from Settings.\n');
} finally {
db.close();
}
@@ -294,7 +294,7 @@ export async function runInstall(argv: string[]): Promise<void> {
run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME], { check: true });
// enable-linger best-effort : absent en CI / sans session loginctl, non bloquant.
if (run('loginctl', ['enable-linger', process.env.USER ?? '']) !== 0) {
console.warn('Warning: could not enable linger — the service may not start at boot.');
console.warn('Warning: could not enable linger. The service may not start at boot.');
}
}
console.log(`\nArboretum service installed. Logs: journalctl --user -u ${SERVICE_NAME} -f`);

View File

@@ -15,7 +15,7 @@ export interface Config {
claudeHome: string;
/** true si --claude-home a été passé explicitement → a priorité sur le réglage claude_home. */
claudeHomeFromFlag: boolean;
/** ~/.claude/projects (transcripts JSONL) surchargeable via --claude-home (tests). */
/** ~/.claude/projects (transcripts JSONL) : surchargeable via --claude-home (tests). */
claudeProjectsDir: string;
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
claudeSessionsDir: string;
@@ -35,11 +35,11 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
'allow-origin': { type: 'string', multiple: true },
'print-token': { type: 'boolean', default: false },
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
// racine de l'install Claude (~/.claude par défaut) surchargée par les tests d'acceptation.
// racine de l'install Claude (~/.claude par défaut) : surchargée par les tests d'acceptation.
'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.
// 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,

View File

@@ -1,7 +1,7 @@
// Journal d'audit : trace persistante des opérations sensibles (création/révocation de tokens,
// changements de réglages, génération de secrets, abonnements push, CRUD groupes). Exigence de
// conformité entreprise (GDPR/SOX/ISO 27001). Règle ABSOLUE : ne JAMAIS journaliser un secret en
// clair `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
// clair : `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs).
import { randomUUID } from 'node:crypto';
import type { AuditLogEntry } from '@arboretum/shared';
import type { Db } from '../db/index.js';

View File

@@ -49,7 +49,7 @@ function isExecutable(path: string): boolean {
/**
* Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel
* validé exécutable, message clair sinon et JAMAIS mis en cache (modifiable à chaud). Sinon :
* (validé exécutable, message clair sinon) et JAMAIS mis en cache (modifiable à chaud). Sinon :
* `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans
* ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par
* `arboretum install`).
@@ -93,7 +93,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
}
const args: string[] = [];
if (opts.resume) {
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant spike S1).
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant, spike S1).
args.push('--resume', opts.resume.claudeSessionId);
if (opts.resume.fork) args.push('--fork-session');
}

View File

@@ -1,4 +1,4 @@
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) fonctions PURES.
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) : fonctions PURES.
// L'écran sert à TYPER le dialogue et extraire ses options ; l'état (waiting vrai/faux) vient du
// registre (source primaire). Tolérant aux variations de rendu inter-versions (texte aplati + regex).
import type { DialogKind, DialogOption } from '@arboretum/shared';
@@ -23,7 +23,7 @@ export function parseOptions(lines: string[]): DialogOption[] {
/**
* Retourne le dialogue typé visible à l'écran, ou null si aucun. `trust` et `question` priment sur
* `permission` (un écran AskUserQuestion contient aussi « Esc to cancel »). Un écran numéroté non
* typé est traité en `permission` générique (best-effort le fallback reste le terminal web).
* typé est traité en `permission` générique (best-effort, le fallback reste le terminal web).
*/
export function classifyDialog(lines: string[]): ClassifiedDialog | null {
const text = lines.join('\n');

View File

@@ -64,13 +64,13 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
return this.cache;
}
/** Session découverte (avec son cwd d'origine lu sur disque) pour resume/fork. null si absente. */
/** Session découverte (avec son cwd d'origine lu sur disque) : pour resume/fork. null si absente. */
getDiscovered(claudeSessionId: string): DiscoveredJsonl | null {
return this.byId.get(claudeSessionId) ?? null;
}
/**
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) garde-fou
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) : garde-fou
* anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan.
*/
isClaudeSessionLive(claudeSessionId: string): boolean {

View File

@@ -23,7 +23,7 @@ interface WatchEntry {
refCount: number;
/** nombre de sessions vivantes épinglant ce worktree. */
sessionPins: number;
/** épingle « permanente » (checkout principal d'un repo enregistré) jamais évincée (P11). */
/** épingle « permanente » (checkout principal d'un repo enregistré) : jamais évincée (P11). */
repoPins: number;
lastUsed: number;
debounce: NodeJS.Timeout | null;

View File

@@ -1,4 +1,4 @@
// Clients des services git distants (Gitea / GitLab / GitHub) P12. Uniquement `fetch` global
// Clients des services git distants (Gitea / GitLab / GitHub) : P12. Uniquement `fetch` global
// (Node ≥ 22), AUCUNE dépendance (pas d'octokit/gitbeaker). Chaque client expose verify() (test de
// connectivité/auth) et listRepos() (paginé). Erreurs typées : AUTH_FAILED / RATE_LIMITED / UNREACHABLE.
import type { GitAuthType, GitService, RemoteRepoSummary } from '@arboretum/shared';

View File

@@ -1,6 +1,6 @@
// Gestion des credentials des services git distants (P12). Les secrets (PAT/app password) sont
// chiffrés par SecretBox AVANT insertion et ne ressortent JAMAIS via l'API (résumés sans secret).
// getSecret()/authFor() sont INTERNES (clone, listRepos, test) jamais routés.
// getSecret()/authFor() sont INTERNES (clone, listRepos, test) : jamais routés.
import { randomUUID } from 'node:crypto';
import type {
CreateGitCredentialRequest,
@@ -137,7 +137,7 @@ export class GitCredentialsManager {
return true;
}
/** Secret déchiffré INTERNE (clone/listRepos/test). Jamais exposé par une route. */
/** Secret déchiffré : INTERNE (clone/listRepos/test). Jamais exposé par une route. */
getSecret(id: string): string | null {
const row = this.getRow(id);
if (!row?.secret_encrypted) return null;

View File

@@ -199,7 +199,7 @@ export async function branchExists(repoPath: string, branch: string): Promise<{
return { local, remote };
}
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut pour un sélecteur de base. */
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut : pour un sélecteur de base. */
export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
const local: string[] = [];
const remote: string[] = [];
@@ -476,7 +476,7 @@ export async function fetchRemote(worktreePath: string): Promise<void> {
await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS);
}
/** `git pull` `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
/** `git pull` : `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<void> {
const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only'];
await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS);
@@ -532,7 +532,7 @@ async function resolveStartPoint(repoPath: string, baseRef?: string): Promise<st
/**
* Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action
* effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de
* la branche indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
* la branche : indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
*/
export async function addWorktree(
repoPath: string,
@@ -583,7 +583,7 @@ export async function push(repoPath: string): Promise<void> {
}
/**
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` utilisé pour démarrer une
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` : utilisé pour démarrer une
* session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
* si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
* est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
@@ -610,13 +610,13 @@ const GIT_CLONE_TIMEOUT_MS = 10 * 60_000; // 10 min : un clone réseau peut êtr
export interface CloneProgress {
phase: string;
/** pourcentage 0100 si git le rapporte, sinon null. */
/** pourcentage 0-100 si git le rapporte, sinon null. */
percent: number | null;
}
/**
* Clone un dépôt via `git clone --progress` (P12). `spawn` (et non execFile) pour streamer la
* progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth JAMAIS dans l'URL.
* progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth : JAMAIS dans l'URL.
* `--` sépare l'URL/dest des options. L'appelant valide `dest` (sous scanRoots, non existant).
*/
export function cloneRepo(opts: {

View File

@@ -1,5 +1,5 @@
// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many).
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici :
// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId.
// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone.
import { EventEmitter } from 'node:events';

View File

@@ -2,7 +2,7 @@ import { parse, resolve, sep } from 'node:path';
/**
* Plus long ancêtre commun d'un ensemble de chemins absolus (par segments, jamais par préfixe
* de chaîne `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
* de chaîne : `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
*/
export function commonAncestorDir(paths: string[]): string {
const first = paths[0];

View File

@@ -10,7 +10,7 @@ const HEAD_BYTES = 256 * 1024;
const TAIL_BYTES = 64 * 1024;
const TITLE_MAX = 120;
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 % spike S4). */
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 %, spike S4). */
export function munge(cwd: string): string {
return cwd.replace(/[^A-Za-z0-9]/g, '-');
}
@@ -82,7 +82,7 @@ function extractMeta(objs: Array<Record<string, unknown>>, meta: Meta): void {
setOnce('cwd', o.cwd);
setOnce('gitBranch', o.gitBranch);
setOnce('version', o.version);
// Titre : on retient la dernière valeur vue (la plus récente) head puis tail → la queue gagne.
// Titre : on retient la dernière valeur vue (la plus récente) : head puis tail → la queue gagne.
const ai = asString(o.aiTitle);
if (ai) meta.aiTitle = ai;
const sum = asString(o.summary);

View File

@@ -1,11 +1,11 @@
// Création d'un nouveau projet : un dossier `<root>/<name>` créé sous une racine existante, dans
// lequel on lance ensuite une session. Couche PURE (testable sans fs) la création réelle du
// lequel on lance ensuite une session. Couche PURE (testable sans fs) : la création réelle du
// dossier, le `git init` et le spawn vivent dans `routes/projects.ts`.
import { join } from 'node:path';
import { isSafeAbsolutePath } from './git.js';
/**
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base refuse les noms
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base : refuse les noms
* vides, trop longs, `.`/`..`, et tout caractère de séparation (`/`, `\`) ou NUL. La résolution du
* chemin complet (et sa re-validation) est faite par {@link resolveProjectDir}.
*/

View File

@@ -129,7 +129,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
}
}
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant S1).
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant, S1).
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
// Override de chemin du binaire claude (réglage UI) lu à chaque spawn → effet sans redémarrage.
const claudeBinPath = command === 'claude' ? readClaudeBinPath(this.db) : null;
@@ -197,7 +197,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return summary;
}
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady S1). */
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady, S1). */
private captureClaudeSessionId(s: ManagedSession): void {
const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
const tick = (): void => {
@@ -307,7 +307,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
/**
* Ré-émet un `session_update` pour une session historique (P10) utilisé au dés-archivage pour
* Ré-émet un `session_update` pour une session historique (P10) : utilisé au dés-archivage pour
* que tous les clients rafraîchissent le row (le champ `archived` repasse à false). No-op si la
* session est vivante (déjà couverte par le flux live) ou inconnue.
*/
@@ -345,7 +345,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return true;
}
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM spike S1). */
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM, spike S1). */
shutdown(): void {
for (const id of this.live.keys()) this.kill(id);
}
@@ -396,7 +396,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
/**
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) protocole acté spike S3.
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) : protocole acté spike S3.
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
* - 'deny' : refus universel (Esc).
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
@@ -417,7 +417,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
s.proc.write(`${optionN}\r`);
return 'ok';
}
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre S1).
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre, S1).
if (act?.activity !== 'waiting') return 'invalid';
s.proc.write(action === 'deny' ? '\x1b' : '\r');
return 'ok';
@@ -446,10 +446,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// ---- interne ----
/**
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention `waiting`
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention : `waiting`
* (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session
* redevient disponible). Le tracker réémet souvent le même état → on ne réagit qu'au changement.
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif :
* à l'échéance on revérifie l'état réel. Cible tous les abonnements (un seul utilisateur).
*/
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {

View File

@@ -8,7 +8,7 @@ import type { SecretBox } from './secret-box.js';
import { recordAudit } from './audit-log.js';
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
// typé par l'import type même pattern que @xterm/headless dans screen-reader.ts.
// typé par l'import type, même pattern que @xterm/headless dans screen-reader.ts.
const require = createRequire(import.meta.url);
const webpush = require('web-push') as typeof import('web-push');
@@ -33,7 +33,7 @@ interface SubRow {
auth: string;
}
/** Envoi d'une notif à un abonnement injectable pour les tests ; défaut = web-push réel. */
/** Envoi d'une notif à un abonnement : injectable pour les tests ; défaut = web-push réel. */
export type PushSender = (
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string,
@@ -70,7 +70,7 @@ export class PushService {
this.vapidPrivate = priv;
}
/** Clé publique VAPID sûre à exposer (applicationServerKey côté navigateur). */
/** Clé publique VAPID : sûre à exposer (applicationServerKey côté navigateur). */
publicKey(): string {
return this.vapidPublic;
}

View File

@@ -1,5 +1,5 @@
// 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
// 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';

View File

@@ -1,5 +1,5 @@
// 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.
// 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';
@@ -39,7 +39,7 @@ interface Frame {
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
* on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
* conteneur on l'enregistre ET on continue de descendre pour trouver les repos internes ;
* conteneur : on l'enregistre ET on continue de descendre pour trouver les repos internes ;
* - 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) ;

View File

@@ -50,7 +50,7 @@ export function normalizeScanRoots(raw: unknown): string[] | null {
return out;
}
/** Valide un intervalle (entier 0MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
/** 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;

View File

@@ -1,6 +1,6 @@
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
// la détection des dialogues verdict S1/S3). Aucune dépendance DOM (usage Node).
// la détection des dialogues, verdict S1/S3). Aucune dépendance DOM (usage Node).
import { createRequire } from 'node:module';
import type { Terminal as XtermTerminal } from '@xterm/headless';

View File

@@ -68,7 +68,7 @@ export class SessionArchiveService extends EventEmitter<SessionArchiveEvents> {
}
}
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant ex. route manuelle). */
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant, ex. route manuelle). */
emitArchived(id: string, archivedAt: string): void {
this.emit('session_archived', { id, archivedAt });
}

View File

@@ -78,7 +78,7 @@ export interface WorktreeManagerEvents {
repo_removed: [string];
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
worktree_removed: [{ repoId: string; path: string }];
/** P7 le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
/** P7 : le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
* ciblé `worktree_changes` aux seules connexions ayant `watch`é cette clé. */
worktree_changes: [{ repoId: string; path: string }];
}
@@ -136,7 +136,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
private readonly fsWatcher?: FsWatcherService,
) {
super();
// P7 un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
// P7 : un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
// (worktree_update : compteurs légers pour tout le dashboard) et signale aux clients qui le
// regardent de re-fetcher le détail (worktree_changes ciblé). On résout repoId → row à la volée.
this.fsWatcher?.on('worktree_fs_change', ({ repoId, path }) => {
@@ -207,7 +207,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
}
/**
* P11 arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
* P11 : arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
* `git checkout`/`switch` en CLI sur le principal est ainsi rediffusé en temps réel sans qu'un
* client ne l'ait « regardé ». Appelé depuis runDaemon (jamais buildApp → pas d'effet en tests purs).
*/
@@ -252,7 +252,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
/**
* 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
* 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).
@@ -302,7 +302,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
/**
* Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
* Les sessions explicitement masquées (`hidden`) sont exclues cohérent avec `/api/v1/sessions`
* Les sessions explicitement masquées (`hidden`) sont exclues, cohérent avec `/api/v1/sessions`
* (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
* au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
@@ -351,7 +351,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
// 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.
// 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();
}
@@ -420,7 +420,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
});
}
/** Branches locales/remote + branche par défaut d'un repo alimente le sélecteur de base côté UI. */
/** Branches locales/remote + branche par défaut d'un repo : alimente le sélecteur de base côté UI. */
async listRepoBranches(repoId: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
const row = this.getRepoRow(repoId);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
@@ -444,16 +444,16 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
const mode = opts.mode ?? 'all';
return this.withLock(repoId, async () => {
if (opts.amend && !(await isUnpushed(w.path))) {
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed amend would rewrite shared history');
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed: amend would rewrite shared history');
}
const st = await worktreeStatus(w.path);
// Garde-fou « rien à committer » (sauf amend, qui peut ne changer que le message).
if (!opts.amend) {
if (mode === 'all' && st.dirtyCount === 0) {
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit working tree is clean');
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit: working tree is clean');
}
if (mode === 'staged' && (st.stagedCount ?? 0) === 0) {
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged stage files first');
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged: stage files first');
}
}
try {
@@ -646,17 +646,17 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
return this.withLock(repoId, async () => {
if (!force) {
if ((await worktreeStatus(w.path)).dirtyCount > 0) {
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes commit or pass force');
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: commit or pass force');
}
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes commit/stash or pass force');
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit/stash or pass force');
}
}
try {
await removeWorktree(row.path, w.path, force);
} catch (err) {
if (!force && isDirtyWorktreeError(err)) {
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes pass force to promote anyway');
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to promote anyway');
}
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
}
@@ -672,7 +672,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
}
/**
* Lance UNE session dans le checkout principal du repo (`repo.path`) pour « bosser sur la branche
* Lance UNE session dans le checkout principal du repo (`repo.path`) : pour « bosser sur la branche
* principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
* dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
* Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
@@ -691,7 +691,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (branch !== undefined) {
// garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes commit or stash before switching branch');
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit or stash before switching branch');
}
try {
await switchBranch(row.path, { branch, create: req.newBranch ?? true });
@@ -731,14 +731,14 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree pass force to delete anyway');
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree: pass force to delete anyway');
}
return this.withLock(repoId, async () => {
try {
await removeWorktree(row.path, w.path, force);
} catch (err) {
if (!force && isDirtyWorktreeError(err)) {
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes pass force to delete anyway');
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to delete anyway');
}
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
}

View File

@@ -29,7 +29,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P2 découverte & reprise : corrélation avec les sessions Claude sur disque.
// P2 : découverte & reprise : corrélation avec les sessions Claude sur disque.
id: 2,
sql: `
ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;
@@ -38,7 +38,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P3 repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés).
// P3 : repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés).
id: 3,
sql: `
CREATE TABLE repos (
@@ -53,7 +53,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P4 abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings.
// P4 : abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings.
id: 4,
sql: `
CREATE TABLE push_subscriptions (
@@ -70,7 +70,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P5 groupes de travail. Un groupe = collection de repos (many-to-many).
// P5 : groupes de travail. Un groupe = collection de repos (many-to-many).
// Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager
// et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb).
id: 5,
@@ -103,7 +103,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
{
// Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations
// sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS
// de secret en clair `details` est un JSON de métadonnées non sensibles.
// de secret en clair, `details` est un JSON de métadonnées non sensibles.
id: 7,
sql: `
CREATE TABLE audit_logs (
@@ -119,7 +119,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P6 session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
// P6 : session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
// et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo).
// `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe.
id: 8,
@@ -143,7 +143,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P10 archivage automatique des sessions terminées (rétention configurable, défaut 30 j).
// P10 : archivage automatique des sessions terminées (rétention configurable, défaut 30 j).
// `archived_at` NULL = active. Soft-archive : JAMAIS de DELETE (resume/fork restent intacts).
// Sémantique distincte de hidden_sessions (#9) : `hidden` est manuel et indexé par
// claude_session_id ; `archived` est automatique par ancienneté, indexé par `id`, et ne
@@ -155,7 +155,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P12 connexions aux services git distants (Gitea/GitLab/GitHub). Les SECRETS (PAT, app
// P12 : connexions aux services git distants (Gitea/GitLab/GitHub). Les SECRETS (PAT, app
// password, tokens OAuth) sont chiffrés par SecretBox AVANT insertion (colonnes *_encrypted) :
// la base ne contient jamais de secret en clair. `ssh_key_path`/`oauth_*` sont posés dès
// maintenant (schéma stable) mais exploités en P12b/P12c. `test_result` = dernier diagnostic.
@@ -181,7 +181,7 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
`,
},
{
// P12 un repo enregistré peut provenir d'un clone : on retient sa source (remote_url),
// P12 : un repo enregistré peut provenir d'un clone : on retient sa source (remote_url),
// le service détecté et le credential utilisé. PAS de FK sur credential_id (cohérent avec
// sessions.group_id #8) : la suppression d'un credential nullifie ce champ côté manager.
id: 12,
@@ -205,7 +205,7 @@ export function openDb(path: string): Db {
}
/**
* Restreint la base (et ses fichiers WAL/SHM) à 0o600 proprio uniquement. La base contient des
* Restreint la base (et ses fichiers WAL/SHM) à 0o600, proprio uniquement. La base contient des
* secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par
* d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX.
*/
@@ -268,7 +268,7 @@ export function unhideSession(db: Db, claudeSessionId: string): void {
db.prepare('DELETE FROM hidden_sessions WHERE claude_session_id = ?').run(claudeSessionId);
}
// ---- Archivage des sessions terminées (par id, soft-archive P10) ----
// ---- Archivage des sessions terminées (par id, soft-archive, P10) ----
/** Archive une session managée (idempotent : ne touche pas une session déjà archivée). */
export function archiveSession(db: Db, id: string, archivedAt: string): void {

View File

@@ -40,7 +40,7 @@ export async function runDaemon(config: Config): Promise<void> {
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}`);
app.log.info(`Arboretum v${pkg.version} : ${url}`);
if (bootstrapToken) {
// Affiché une seule fois : le hash seul est stocké.
printTokenBanner(bootstrapToken, url);
@@ -52,7 +52,7 @@ export async function runDaemon(config: Config): Promise<void> {
const shutdown = (signal: string): void => {
if (shuttingDown) return;
shuttingDown = true;
app.log.info(`${signal} received draining sessions then exiting`);
app.log.info(`${signal} received: draining sessions then exiting`);
discovery.stop();
sessionArchive.stop();
repoDiscovery.stop();

View File

@@ -80,7 +80,7 @@ export function registerAuthRoutes(
const body = req.body as Partial<CreateTokenRequest> | null;
const label = typeof body?.label === 'string' ? body.label.trim() : '';
if (label.length < 1 || label.length > 64) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 164 characters' } });
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1-64 characters' } });
}
const { id, token } = auth.createTokenRecord(label);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'token.create', resourceId: id, details: { label } });

View File

@@ -101,7 +101,7 @@ export function registerGroupRoutes(
});
// Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir).
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe le client
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe : le client
// ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ;
// absent → le worktree principal de chaque repo.
app.post('/api/v1/groups/:id/session', async (req, reply) => {

View File

@@ -63,7 +63,7 @@ export function registerSessionRoutes(
const claudeSessionId = managed ? managed.claudeSessionId : id;
// Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE).
if (discovery.isClaudeSessionLive(claudeSessionId) || manager.findLiveByClaudeSessionId(claudeSessionId)) {
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live fork it instead' } });
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live, fork it instead' } });
}
try {
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
@@ -104,7 +104,7 @@ export function registerSessionRoutes(
});
// Masque une session découverte (ou managée morte) : exclue de la liste sauf ?includeHidden=true.
// Reste reprenable/forkable c'est un filtre d'affichage, pas une suppression.
// Reste reprenable/forkable : c'est un filtre d'affichage, pas une suppression.
app.post('/api/v1/sessions/:id/hide', async (req, reply) => {
const { id } = req.params as { id: string };
const sid = resolveClaudeSid(id);

View File

@@ -1,6 +1,6 @@
// Réglages exposés à l'UI (onglet Réglages). Frontière de sécurité CENTRALE : la table `settings`
// contient aussi des SECRETS (server_secret, vapid_private). Ces routes n'exposent QUE des champs
// non sensibles et n'écrivent QUE des clés explicitement allow-listées jamais les secrets.
// non sensibles et n'écrivent QUE des clés explicitement allow-listées : jamais les secrets.
import type { FastifyInstance } from 'fastify';
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
import type { Config } from '../config.js';
@@ -119,7 +119,7 @@ export function registerSettingsRoutes(
details: { keys: Object.keys(body) },
});
const snap = snapshot();
// P11 diffuse le nouvel état non sensible à tous les clients abonnés au topic 'settings'.
// P11 · diffuse le nouvel état non sensible à tous les clients abonnés au topic 'settings'.
settingsBus.emit('settings_update', snap.settings);
return reply.send(snap);
});

View File

@@ -60,7 +60,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
}
});
// Branches du repo (locales + suivies de origin + défaut) alimente le sélecteur de base côté UI.
// Branches du repo (locales + suivies de origin + défaut) : alimente le sélecteur de base côté UI.
app.get('/api/v1/repos/:id/branches', async (req, reply) => {
const { id } = req.params as { id: string };
try {

View File

@@ -67,7 +67,7 @@ export function registerWsGateway(
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
if (subscribedSessions) send({ type: 'session_exit', ...e });
};
// P10 une session managée vient d'être archivée (auto par ancienneté ou manuellement).
// P10 · une session managée vient d'être archivée (auto par ancienneté ou manuellement).
const onSessionArchived = (e: { id: string; archivedAt: string }): void => {
if (subscribedSessions) send({ type: 'session_archived', sessionId: e.id });
};
@@ -93,15 +93,15 @@ export function registerWsGateway(
const onGroupRemoved = (groupId: string): void => {
if (subscribedGroups) send({ type: 'group_removed', groupId });
};
// P11 un réglage a changé : relayé aux connexions abonnées au topic 'settings'.
// P11 · un réglage a changé : relayé aux connexions abonnées au topic 'settings'.
const onSettingsUpdate = (settings: SettingsBroadcast): void => {
if (subscribedSettings) send({ type: 'settings_update', settings });
};
// P12 progression d'un clone : relayée aux abonnés du topic 'clones'.
// P12 · progression d'un clone : relayée aux abonnés du topic 'clones'.
const onCloneUpdate = (operation: CloneOperation): void => {
if (subscribedClones) send({ type: 'clone_update', operation });
};
// P7 détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
// P7 · détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
const onWorktreeChanges = (e: { repoId: string; path: string }): void => {
if (watched.has(watchKey(e.repoId, e.path))) send({ type: 'worktree_changes', ...e });
};

View File

@@ -77,7 +77,7 @@ afterAll(async () => {
rmSync(dir, { recursive: true, force: true });
});
describe('app e2e auth, origin et sessions', () => {
describe('app e2e · auth, origin et sessions', () => {
let t: TestApp;
let cookieValue: string;
@@ -277,7 +277,7 @@ describe('app e2e — auth, origin et sessions', () => {
});
});
describe('app e2e rate limit du login', () => {
describe('app e2e · rate limit du login', () => {
it('mauvais token et bodies invalides → 401, puis 429 dès le 4e essai (backoff après 3 échecs consécutifs)', async () => {
const t = makeApp('ratelimit-failures');
const bad = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
@@ -309,7 +309,7 @@ describe('app e2e — rate limit du login', () => {
});
});
describe('app e2e découverte, resume & fork (P2)', () => {
describe('app e2e · découverte, resume & fork (P2)', () => {
let t: TestApp;
let projectsDir: string;
let sessionsDir: string;
@@ -370,7 +370,7 @@ describe('app e2e — découverte, resume & fork (P2)', () => {
});
});
describe('app e2e découverte auto des repos & masquage', () => {
describe('app e2e · découverte auto des repos & masquage', () => {
let t: TestApp;
let scanRoot: string;
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });

View File

@@ -1,4 +1,4 @@
// Lot 5 sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression),
// Lot 5 · sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression),
// garde Content-Type. Vérifie le câblage de bout en bout via buildApp + token bootstrap.
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';

View File

@@ -4,7 +4,7 @@ import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core
// 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' }));
describe('buildSpawnSpec session de groupe multi-repo (P6)', () => {
describe('buildSpawnSpec · session de groupe multi-repo (P6)', () => {
it('émet un --add-dir par répertoire supplémentaire (claude)', () => {
const spec = buildSpawnSpec({ command: 'claude', addDirs: ['/a', '/b', '/c'] });
expect(spec.file).toBe('/usr/bin/claude');
@@ -28,7 +28,7 @@ describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
});
});
describe('resolveClaudeBin / diagnoseClaudeBin override de chemin (réglage UI)', () => {
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;

View File

@@ -14,7 +14,7 @@ import {
launchAgentPlistPath,
} from '../src/cli/install.js';
describe('cli install detectPlatform', () => {
describe('cli install · detectPlatform', () => {
it('accepte linux et darwin', () => {
expect(detectPlatform('linux')).toBe('linux');
expect(detectPlatform('darwin')).toBe('darwin');
@@ -26,7 +26,7 @@ describe('cli install — detectPlatform', () => {
});
});
describe('cli install buildServiceArgs', () => {
describe('cli install · buildServiceArgs', () => {
it('aucun flag → aucun argument (le service garde les défauts loopback)', () => {
expect(buildServiceArgs(parseInstallArgs([]))).toEqual([]);
});
@@ -47,7 +47,7 @@ describe('cli install — buildServiceArgs', () => {
});
});
describe('cli install resolveBin', () => {
describe('cli install · resolveBin', () => {
it('par défaut : node (process.execPath) + dist/index.js', () => {
const { exec, args } = resolveBin({});
expect(exec).toBe(process.execPath);
@@ -64,7 +64,7 @@ describe('cli install — resolveBin', () => {
});
});
describe('cli install renderSystemdUnit', () => {
describe('cli install · renderSystemdUnit', () => {
it('contient le ExecStart calculé et les directives clés', () => {
const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/opt/arboretum/index.js', '--port', '7317'] });
expect(unit).toContain('ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317');
@@ -101,7 +101,7 @@ describe('cli install — renderSystemdUnit', () => {
});
expect(unit).toMatchInlineSnapshot(`
"[Unit]
Description=Arboretum git worktree & Claude Code dashboard
Description=Arboretum · git worktree & Claude Code dashboard
After=network-online.target
Wants=network-online.target
@@ -120,7 +120,7 @@ describe('cli install — renderSystemdUnit', () => {
});
});
describe('cli install renderLaunchAgentPlist', () => {
describe('cli install · renderLaunchAgentPlist', () => {
const base = {
label: 'fr.lidge.arboretum',
programArguments: ['/usr/bin/node', '/opt/index.js', '--port', '7317'],
@@ -163,13 +163,13 @@ describe('cli install — renderLaunchAgentPlist', () => {
});
});
describe('cli install xmlEscape', () => {
describe('cli install · xmlEscape', () => {
it('échappe &, < et >', () => {
expect(xmlEscape('a & b < c > d')).toBe('a &amp; b &lt; c &gt; d');
});
});
describe('cli install chemins', () => {
describe('cli install · chemins', () => {
const savedXdg = process.env.XDG_CONFIG_HOME;
afterEach(() => {
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;

View File

@@ -24,7 +24,7 @@ async function replay(fixture: string): Promise<Array<{ dialog: ClassifiedDialog
return seen;
}
describe('ScreenReader (@xterm/headless) anti strip-ANSI naïf', () => {
describe('ScreenReader (@xterm/headless) · anti strip-ANSI naïf', () => {
it('préserve les espaces du dialogue (« Do you want to create », pas « Doyouwant »)', async () => {
const seen = await replay('perm-write2.raw');
expect(seen.length).toBeGreaterThan(0);
@@ -32,7 +32,7 @@ describe('ScreenReader (@xterm/headless) — anti strip-ANSI naïf', () => {
});
});
describe('classifyDialog unitaire (lignes synthétiques)', () => {
describe('classifyDialog · unitaire (lignes synthétiques)', () => {
it('trust', () => {
const d = classifyDialog(['Do you trust the files in this folder?', ' 1. Yes, I trust this folder', '2. No, exit']);
expect(d?.kind).toBe('trust');
@@ -81,7 +81,7 @@ describe('détection sur fixtures réelles S3 (replay headless)', () => {
// Campagne de fiabilité P4-C : chaque type de dialogue ciblé par la supervision mobile doit être
// classifié de façon fiable (cf. « reste à faire P4 » du verdict S3 : couvrir le refus Esc et le plan).
describe('campagne de fiabilité P4-C tous les types de dialogue', () => {
describe('campagne de fiabilité P4-C · tous les types de dialogue', () => {
const realCaptures: Array<{ fixture: string; kind: DialogKind }> = [
{ fixture: 'trust.raw', kind: 'trust' },
{ fixture: 'perm-write2.raw', kind: 'permission' },
@@ -101,7 +101,7 @@ describe('campagne de fiabilité P4-C — tous les types de dialogue', () => {
expect(seen.length).toBeGreaterThan(0);
});
it('plan (synthétique pas de capture réelle) : « Would you like to proceed? »', () => {
it('plan (synthétique, pas de capture réelle) : « Would you like to proceed? »', () => {
const d = classifyDialog([
'Here is my implementation plan:',
' - step one',

View File

@@ -184,7 +184,7 @@ describe('branche : existence, liste, courante, commit', () => {
});
});
describe('P7 parseurs purs (status v2 / numstat)', () => {
describe('P7 · parseurs purs (status v2 / numstat)', () => {
it('parsePorcelainV2 : modifié, untracked, renommé', () => {
const z =
'1 .M N... 100644 100644 100644 h1 h2 file.txt\x00' +
@@ -217,7 +217,7 @@ describe('P7 — parseurs purs (status v2 / numstat)', () => {
});
});
describe('P7 changes / diff / staging / commit sélectif (repo tmp)', () => {
describe('P7 · changes / diff / staging / commit sélectif (repo tmp)', () => {
it('listChanges : untracked + modifié non indexé + indexé', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'untracked.txt'), 'x\n');

View File

@@ -463,7 +463,7 @@ describe('PtyManager (pty mocké)', () => {
manager.attach(summary.id, b, 80, 24);
const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
pty.emitData(chunk); // a=200K, b=200K sous HIGH (384K)
pty.emitData(chunk); // a=200K, b=200K : sous HIGH (384K)
expect(pty.pause).not.toHaveBeenCalled();
manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
@@ -516,7 +516,7 @@ describe('PtyManager (pty mocké)', () => {
const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));
pty.emitData(chunks[0]!); // outstanding 1 Mio
pty.emitData(chunks[1]!); // outstanding 2 Mio pas strictement > LAGGING_BYTES
pty.emitData(chunks[1]!); // outstanding 2 Mio : pas strictement > LAGGING_BYTES
expect(a.lagging).toBe(false);
expect(a.sendOutput).toHaveBeenCalledTimes(2);

View File

@@ -39,7 +39,7 @@ function mulberry32(seed: number): () => number {
const buf = (s: string): Buffer => Buffer.from(s, 'ascii');
describe('RingBuffer écriture simple', () => {
describe('RingBuffer · écriture simple', () => {
it('écrit puis relit sans wrap', () => {
const ring = new RingBuffer(16);
ring.write(buf('hello'));
@@ -76,7 +76,7 @@ describe('RingBuffer — écriture simple', () => {
});
});
describe('RingBuffer wrap-around', () => {
describe('RingBuffer · wrap-around', () => {
it('wrap simple : le chunk chevauche la frontière du buffer', () => {
const ring = new RingBuffer(8);
ring.write(buf('01234')); // pos 0..5
@@ -120,7 +120,7 @@ describe('RingBuffer — wrap-around', () => {
});
});
describe('RingBuffer chunk >= capacité', () => {
describe('RingBuffer · chunk >= capacité', () => {
it('chunk == capacité sur un ring vierge', () => {
const ring = new RingBuffer(8);
ring.write(buf('abcdefgh'));
@@ -156,7 +156,7 @@ describe('RingBuffer — chunk >= capacité', () => {
});
});
describe('RingBuffer readFrom', () => {
describe('RingBuffer · readFrom', () => {
function filled(): RingBuffer {
// total = 10, capacité 8 → fenêtre = offsets [2, 10)
const ring = new RingBuffer(8);
@@ -192,7 +192,7 @@ describe('RingBuffer — readFrom', () => {
});
});
describe('RingBuffer totalOffset monotone', () => {
describe('RingBuffer · totalOffset monotone', () => {
it('croît exactement de la taille de chaque chunk, jamais ne décroît', () => {
const ring = new RingBuffer(8);
const rand = mulberry32(0x5eed);
@@ -207,7 +207,7 @@ describe('RingBuffer — totalOffset monotone', () => {
});
});
describe('RingBuffer équivalence avec le modèle de référence (property test)', () => {
describe('RingBuffer · équivalence avec le modèle de référence (property test)', () => {
it('tail et readFrom identiques au modèle pour 500 écritures aléatoires (chunks < capacité)', () => {
const capacity = 64;
const ring = new RingBuffer(capacity);

View File

@@ -101,7 +101,7 @@ describe('GET /api/v1/settings', () => {
});
});
describe('PATCH /api/v1/settings sécurité', () => {
describe('PATCH /api/v1/settings · sécurité', () => {
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
const before = getSetting(db, 'server_secret');
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } });
@@ -115,7 +115,7 @@ describe('PATCH /api/v1/settings — sécurité', () => {
});
});
describe('PATCH /api/v1/settings découverte des dépôts', () => {
describe('PATCH /api/v1/settings · découverte des dépôts', () => {
it('enregistre des scanRoots et un intervalle valides et les renvoie', async () => {
const res = await bundle.app.inject({
method: 'PATCH',
@@ -148,7 +148,7 @@ describe('PATCH /api/v1/settings — découverte des dépôts', () => {
});
});
describe('PATCH /api/v1/settings Claude CLI', () => {
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;
@@ -191,7 +191,7 @@ describe('PATCH /api/v1/settings — Claude CLI', () => {
});
});
describe('PATCH /api/v1/settings rétention des sessions (P10)', () => {
describe('PATCH /api/v1/settings · rétention des sessions (P10)', () => {
it('enregistre une rétention valide et lexpose', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 7 } });
expect(res.statusCode).toBe(200);