Fondations de la refonte « vrai IDE IA worktree » (chantier P7→P12).
- git.ts : listChanges (status --porcelain=v2 -z + --numstat fusionnés),
fileDiff (diff unifié borné, refus binaire, untracked via --no-index),
stage/unstage/restore/clean, commitStaged, amendCommit (refus si poussé),
fetchRemote, pull (ff-only|rebase), lastCommit, resolveGitDir,
isSafeRelativePath ; worktreeStatus enrichi (compteurs staged/unstaged/
conflict + dernier commit), champs additifs WorktreeGitStatus.
- core/fs-watcher.ts : FsWatcherService (chokidar, refcount + pool LRU borné,
ignore .git sauf HEAD/index, débounce) → invalide factsCache, rediffuse
worktree_update et émet worktree_changes.
- WorktreeManager : getWorktreeChanges/getFileDiff/stage/unstage/discard/
fetch/pull/watch/unwatch/assertPathInWorktree ; commitWorktree { mode, amend }.
- routes/git.ts (changes/diff/stage/unstage/discard/fetch/pull) et
routes/files.ts (GET/PUT contenu fichier, bornage strict au worktree) ;
fs/list?includeFiles=1 (flag isFile).
- protocole ADDITIF (PROTOCOL_VERSION inchangé) : messages WS watch/unwatch
(validés dans parseClientMessage) + worktree_changes poussé ciblé par la
gateway ; front wsClient.watchWorktree + lib/git-api.ts + api.put.
- tests git/fs-watcher/protocole + acceptance-p7.mjs (ALL GREEN).
- dépendance : chokidar (serveur).
95 lines
4.0 KiB
TypeScript
95 lines
4.0 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { readdir, stat } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join, dirname, resolve } from 'node:path';
|
|
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
|
|
|
/**
|
|
* Navigation du système de fichiers pour le sélecteur de dossier côté web.
|
|
* Ne renvoie QUE des noms de sous-dossiers (jamais de contenu de fichier) ; authentifié
|
|
* par le hook preValidation global comme tout /api/**. Un utilisateur authentifié dispose
|
|
* déjà d'un terminal (RCE par conception) : lister des dossiers n'élargit pas le modèle de menace.
|
|
*
|
|
* GET /api/v1/fs/list?path=<abs>&markRepos=1&showHidden=1
|
|
* - path absent → home de l'utilisateur
|
|
* - markRepos → annote les sous-dossiers qui sont des dépôts git (présence d'un `.git`)
|
|
* - showHidden → inclut les dotfiles (masqués par défaut)
|
|
*/
|
|
export function registerFsRoutes(app: FastifyInstance): void {
|
|
app.get('/api/v1/fs/list', async (req, reply) => {
|
|
const q = req.query as { path?: string; markRepos?: string; showHidden?: string; includeFiles?: string };
|
|
const raw = q.path && q.path.length > 0 ? q.path : homedir();
|
|
if (!raw.startsWith('/')) {
|
|
return reply.status(400).send({ error: { code: 'BAD_PATH', message: 'path must be absolute' } });
|
|
}
|
|
// resolve() normalise et clampe à la racine : aucun échappement d'arborescence via `..`.
|
|
const abs = resolve(raw);
|
|
|
|
let st;
|
|
try {
|
|
st = await stat(abs);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${abs}` } });
|
|
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } });
|
|
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
|
}
|
|
if (!st.isDirectory()) {
|
|
return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${abs}` } });
|
|
}
|
|
|
|
const markRepos = q.markRepos === '1' || q.markRepos === 'true';
|
|
const showHidden = q.showHidden === '1' || q.showHidden === 'true';
|
|
const includeFiles = q.includeFiles === '1' || q.includeFiles === 'true';
|
|
|
|
let dirents;
|
|
try {
|
|
dirents = await readdir(abs, { withFileTypes: true });
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } });
|
|
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
|
}
|
|
|
|
const entries: FsEntry[] = [];
|
|
for (const d of dirents) {
|
|
if (!showHidden && d.name.startsWith('.')) continue;
|
|
let isDir = d.isDirectory();
|
|
// symlink éventuel vers un dossier : résolu par stat (tolérant aux liens cassés).
|
|
if (!isDir && d.isSymbolicLink()) {
|
|
try {
|
|
isDir = (await stat(join(abs, d.name))).isDirectory();
|
|
} catch {
|
|
isDir = false;
|
|
}
|
|
}
|
|
const full = join(abs, d.name);
|
|
if (!isDir) {
|
|
// En mode includeFiles, on remonte aussi les fichiers (arbre de l'IDE worktree).
|
|
if (!includeFiles) continue;
|
|
entries.push({ name: d.name, path: full, isFile: true });
|
|
continue;
|
|
}
|
|
const entry: FsEntry = { name: d.name, path: full };
|
|
if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true;
|
|
entries.push(entry);
|
|
}
|
|
// Dossiers d'abord, puis fichiers ; tri insensible à la casse dans chaque groupe.
|
|
entries.sort((a, b) => {
|
|
const af = a.isFile ? 1 : 0;
|
|
const bf = b.isFile ? 1 : 0;
|
|
if (af !== bf) return af - bf;
|
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
});
|
|
|
|
const res: FsListResponse = {
|
|
path: abs,
|
|
parent: abs === '/' ? null : dirname(abs),
|
|
home: homedir(),
|
|
entries,
|
|
};
|
|
return reply.send(res);
|
|
});
|
|
}
|