feat: création de projet (nouveau dossier + session) & en-tête de session enrichi
- POST /api/v1/projects : crée <root>/<name> (mkdir non récursif, 409 si existant), git init optionnel, puis lance une session dedans. Anti-traversal sur le nom (core/project.ts). - Web : NewProjectModal (racine préremplie scanRoots[0], case git init) + bouton dans SessionsListView. - SessionContextBar : badge du groupe (cliquable) + repos couverts avec leur branche dans l'en-tête de session ; session simple = repo+branche déduits du cwd. - 14 tests (unitaires + route).
This commit is contained in:
89
packages/server/src/routes/projects.ts
Normal file
89
packages/server/src/routes/projects.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import type { CreateProjectRequest, CreateProjectResponse } from '@arboretum/shared';
|
||||
import type { PtyManager } from '../core/pty-manager.js';
|
||||
import { gitInit, isSafeAbsolutePath } from '../core/git.js';
|
||||
import { resolveProjectDir } from '../core/project.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Création d'un nouveau projet : crée le dossier `<root>/<name>` (la SEULE écriture fs autorisée
|
||||
* côté serveur en dehors du dataDir) puis lance une session dedans. `git init` optionnel.
|
||||
* Cohérent avec le modèle de sécurité : un client authentifié dispose déjà d'un terminal (RCE par
|
||||
* conception). On valide tout de même strictement `name` (anti-traversal) et on exige une racine
|
||||
* EXISTANTE (mkdir non récursif → pas de création d'arborescence arbitraire).
|
||||
*
|
||||
* POST /api/v1/projects { root, name, gitInit?, command? } → 201 { session, path, gitInitialized }
|
||||
*/
|
||||
export function registerProjectRoutes(app: FastifyInstance, manager: PtyManager, db: Db): void {
|
||||
app.post('/api/v1/projects', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateProjectRequest> | null;
|
||||
if (!body || typeof body.root !== 'string' || !isSafeAbsolutePath(body.root)) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'root (absolute path) is required' } });
|
||||
}
|
||||
if (typeof body.name !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'name is required' } });
|
||||
}
|
||||
if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||
}
|
||||
|
||||
// La racine doit exister et être un répertoire (mkdir non récursif derrière).
|
||||
try {
|
||||
const st = await stat(body.root);
|
||||
if (!st.isDirectory()) {
|
||||
return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${body.root}` } });
|
||||
}
|
||||
} 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: ${body.root}` } });
|
||||
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${body.root}` } });
|
||||
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
try {
|
||||
dir = resolveProjectDir(body.root, body.name);
|
||||
} catch (err) {
|
||||
return reply.status((err as { statusCode?: number }).statusCode ?? 400).send({ error: { code: 'BAD_REQUEST', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
// mkdir non récursif : EEXIST → 409 (on n'écrase jamais un dossier existant).
|
||||
try {
|
||||
await mkdir(dir);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') return reply.status(409).send({ error: { code: 'PROJECT_EXISTS', message: `Already exists: ${dir}` } });
|
||||
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${dir}` } });
|
||||
return reply.status(400).send({ error: { code: 'MKDIR_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
const wantsGitInit = body.gitInit === true;
|
||||
let gitInitialized = false;
|
||||
if (wantsGitInit) {
|
||||
try {
|
||||
await gitInit(dir);
|
||||
gitInitialized = true;
|
||||
} catch {
|
||||
// Le dossier est créé et la session démarrera quand même : un échec git n'annule pas le projet.
|
||||
gitInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const session = manager.spawn({ cwd: dir, ...(body.command ? { command: body.command } : {}) });
|
||||
recordAudit(db, {
|
||||
actor: req.authContext?.tokenId ?? 'unknown',
|
||||
action: 'project.create',
|
||||
resourceId: dir,
|
||||
details: { gitInit: gitInitialized },
|
||||
});
|
||||
const res: CreateProjectResponse = { session, path: dir, gitInitialized };
|
||||
return reply.status(201).send(res);
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user