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:
@@ -17,6 +17,7 @@ import { PushService } from './core/push-service.js';
|
||||
import { loadSecretBox } from './core/secret-box.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerGroupRoutes } from './routes/groups.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
@@ -170,6 +171,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||
registerSessionRoutes(app, manager, discovery, db);
|
||||
registerProjectRoutes(app, manager, db);
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||
registerWorktreeRoutes(app, worktrees, db);
|
||||
|
||||
@@ -122,6 +122,11 @@ export function isSafeAbsolutePath(p: string): boolean {
|
||||
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
||||
}
|
||||
|
||||
/** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */
|
||||
export async function gitInit(dir: string): Promise<void> {
|
||||
await git(dir, ['init']);
|
||||
}
|
||||
|
||||
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
|
||||
export async function isRepo(path: string): Promise<boolean> {
|
||||
try {
|
||||
|
||||
33
packages/server/src/core/project.ts
Normal file
33
packages/server/src/core/project.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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
|
||||
// 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
|
||||
* 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}.
|
||||
*/
|
||||
export function isSafeProjectName(name: string): boolean {
|
||||
const n = name.trim();
|
||||
if (n.length === 0 || n.length > 255) return false;
|
||||
if (n === '.' || n === '..') return false;
|
||||
return !/[/\\\0]/.test(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le dossier de projet `<root>/<name>`. Suppose `root` déjà validé absolu et existant par
|
||||
* l'appelant ; re-valide le chemin final via {@link isSafeAbsolutePath} (défense en profondeur).
|
||||
* Lève si `name` est invalide ou si le chemin résultant échappe l'arborescence.
|
||||
*/
|
||||
export function resolveProjectDir(root: string, name: string): string {
|
||||
if (!isSafeProjectName(name)) {
|
||||
throw Object.assign(new Error(`Invalid project name: ${name}`), { statusCode: 400 });
|
||||
}
|
||||
const dir = join(root, name.trim());
|
||||
if (!isSafeAbsolutePath(dir)) {
|
||||
throw Object.assign(new Error(`Unsafe project path: ${dir}`), { statusCode: 400 });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
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