diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index b7b0e9e..0482cdc 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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); diff --git a/packages/server/src/core/git.ts b/packages/server/src/core/git.ts index 823fb7d..a4ca427 100644 --- a/packages/server/src/core/git.ts +++ b/packages/server/src/core/git.ts @@ -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 { + 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 { try { diff --git a/packages/server/src/core/project.ts b/packages/server/src/core/project.ts new file mode 100644 index 0000000..3f0fe4b --- /dev/null +++ b/packages/server/src/core/project.ts @@ -0,0 +1,33 @@ +// Création d'un nouveau projet : un dossier `/` 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 `/`. 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; +} diff --git a/packages/server/src/routes/projects.ts b/packages/server/src/routes/projects.ts new file mode 100644 index 0000000..3837a41 --- /dev/null +++ b/packages/server/src/routes/projects.ts @@ -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 `/` (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 | 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 } }); + } + }); +} diff --git a/packages/server/test/project.test.ts b/packages/server/test/project.test.ts new file mode 100644 index 0000000..60958db --- /dev/null +++ b/packages/server/test/project.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { isSafeProjectName, resolveProjectDir } from '../src/core/project.js'; + +describe('isSafeProjectName', () => { + it('accepte un segment simple', () => { + expect(isSafeProjectName('mon-projet')).toBe(true); + expect(isSafeProjectName('Projet_42.v2')).toBe(true); + expect(isSafeProjectName(' espaces-trim ')).toBe(true); // trim interne + }); + + it('refuse vide / espaces seuls', () => { + expect(isSafeProjectName('')).toBe(false); + expect(isSafeProjectName(' ')).toBe(false); + }); + + it('refuse `.` et `..`', () => { + expect(isSafeProjectName('.')).toBe(false); + expect(isSafeProjectName('..')).toBe(false); + }); + + it('refuse les séparateurs et le NUL (anti-traversal)', () => { + expect(isSafeProjectName('a/b')).toBe(false); + expect(isSafeProjectName('../evil')).toBe(false); + expect(isSafeProjectName('a\\b')).toBe(false); + expect(isSafeProjectName('a\0b')).toBe(false); + }); + + it('refuse un nom trop long (> 255)', () => { + expect(isSafeProjectName('x'.repeat(256))).toBe(false); + expect(isSafeProjectName('x'.repeat(255))).toBe(true); + }); +}); + +describe('resolveProjectDir', () => { + it('joint root + name (trim) en chemin absolu', () => { + expect(resolveProjectDir('/home/johan/dev', 'mon-projet')).toBe('/home/johan/dev/mon-projet'); + expect(resolveProjectDir('/home/johan/dev', ' mon-projet ')).toBe('/home/johan/dev/mon-projet'); + }); + + it('lève (400) sur un nom invalide', () => { + expect(() => resolveProjectDir('/home/johan/dev', '..')).toThrow(); + expect(() => resolveProjectDir('/home/johan/dev', 'a/b')).toThrow(); + expect(() => resolveProjectDir('/home/johan/dev', '')).toThrow(); + }); +}); diff --git a/packages/server/test/projects-routes.test.ts b/packages/server/test/projects-routes.test.ts new file mode 100644 index 0000000..c5c6a2f --- /dev/null +++ b/packages/server/test/projects-routes.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildApp, type AppBundle } from '../src/app.js'; +import { openDb, type Db } from '../src/db/index.js'; +import type { Config } from '../src/config.js'; +import type { CreateProjectResponse } from '@arboretum/shared'; + +// On NE mocke PAS node:child_process : on veut un vrai `git init` (testé via command:'bash', qui +// n'a pas besoin de résoudre le binaire claude). Seul le PTY est simulé (pas de vrai process en CI). +vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { + class FakePty { + pid = 424242; + write = vi.fn(); + resize = vi.fn(); + pause = vi.fn(); + resume = vi.fn(); + kill = vi.fn(); + onData(): { dispose: () => void } { + return { dispose: () => {} }; + } + onExit(): { dispose: () => void } { + return { dispose: () => {} }; + } + } + return { default: { spawn: (): FakePty => new FakePty() } }; +}); + +process.env.ARBORETUM_LOG = 'silent'; + +let dir: string; +let root: string; +let bundle: AppBundle; +let db: Db; +let token: string; + +const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'arboretum-projects-')); + root = dir; // la racine existe déjà + + const dbPath = join(dir, 'projects.db'); + db = openDb(dbPath); + const config: Config = { + port: 7317, + bind: '127.0.0.1', + dbPath, + dataDir: dir, + allowedOrigins: [], + printToken: false, + claudeProjectsDir: join(dir, 'claude', 'projects'), + claudeSessionsDir: join(dir, 'claude', 'sessions'), + vapidContact: 'mailto:test@localhost', + }; + bundle = buildApp(config, db, '0.0.0-test'); + const t = bundle.auth.ensureBootstrapToken(); + if (!t) throw new Error('bootstrap token attendu sur une base vierge'); + token = t; +}); + +afterAll(async () => { + await bundle.app.close(); + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('POST /api/v1/projects', () => { + it('crée le dossier et lance une session (sans git)', async () => { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } }); + expect(res.statusCode).toBe(201); + const body = res.json() as CreateProjectResponse; + expect(body.path).toBe(join(root, 'plain')); + expect(body.gitInitialized).toBe(false); + expect(existsSync(join(root, 'plain'))).toBe(true); + expect(existsSync(join(root, 'plain', '.git'))).toBe(false); + expect(body.session.cwd).toBe(join(root, 'plain')); + }); + + it('gitInit:true crée un dépôt git', async () => { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'withgit', gitInit: true, command: 'bash' } }); + expect(res.statusCode).toBe(201); + const body = res.json() as CreateProjectResponse; + expect(body.gitInitialized).toBe(true); + expect(existsSync(join(root, 'withgit', '.git'))).toBe(true); + }); + + it('nom avec `..` ou séparateur → 400 (anti-traversal)', async () => { + for (const name of ['..', 'a/b', '../evil']) { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name, command: 'bash' } }); + expect(res.statusCode).toBe(400); + } + }); + + it('dossier déjà existant → 409 PROJECT_EXISTS', async () => { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ error: { code: 'PROJECT_EXISTS' } }); + }); + + it('racine inexistante → 404', async () => { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: join(root, 'nope-xyz'), name: 'x', command: 'bash' } }); + expect(res.statusCode).toBe(404); + }); + + it('root relatif → 400', async () => { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: 'relatif/x', name: 'x', command: 'bash' } }); + expect(res.statusCode).toBe(400); + }); + + it('sans authentification → 401', async () => { + const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', payload: { root, name: 'noauth', command: 'bash' } }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index abbc07f..48ba4a4 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -47,6 +47,29 @@ export interface CreateSessionRequest { /** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */ command?: 'claude' | 'bash'; } + +/** + * POST /api/v1/projects — crée un NOUVEAU dossier de projet `/` (le serveur ne crée + * jamais de dossier ailleurs) puis y lance une session. Le `git init` est optionnel (case à cocher). + * Réponse : `CreateProjectResponse`. + */ +export interface CreateProjectRequest { + /** racine absolue où créer le projet (doit déjà exister). */ + root: string; + /** nom du dossier de projet : un seul segment, sans séparateur ni `..`. */ + name: string; + /** true → `git init` dans le nouveau dossier avant la session (défaut false). */ + gitInit?: boolean; + /** binaire à lancer — défaut "claude" ; "bash" sert aux tests sans quota. */ + command?: 'claude' | 'bash'; +} +export interface CreateProjectResponse { + session: SessionSummary; + /** chemin absolu du dossier de projet créé. */ + path: string; + /** true si `git init` a réellement été exécuté. */ + gitInitialized: boolean; +} export interface SessionsListResponse { sessions: SessionSummary[]; } diff --git a/packages/web/src/components/NewProjectModal.vue b/packages/web/src/components/NewProjectModal.vue new file mode 100644 index 0000000..1708183 --- /dev/null +++ b/packages/web/src/components/NewProjectModal.vue @@ -0,0 +1,119 @@ + + + diff --git a/packages/web/src/components/SessionContextBar.vue b/packages/web/src/components/SessionContextBar.vue new file mode 100644 index 0000000..ac5e00d --- /dev/null +++ b/packages/web/src/components/SessionContextBar.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index da1cdbb..3b2d3e0 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -184,6 +184,19 @@ export default { externalSessions: 'external', showExternalSessions: 'Show external sessions ({n})', }, + project: { + new: 'New project', + title: 'New project', + intro: 'Creates a new folder under the chosen root, then starts a session in it.', + nameLabel: 'Project name', + namePlaceholder: 'my-project', + rootLabel: 'Root folder', + rootPlaceholder: '/absolute/path/root', + gitInit: 'Initialize a git repository (git init)', + commandLabel: 'Command', + create: 'Create', + creating: 'Creating…', + }, terminal: { observer: 'observer (read-only)', sessionEnded: 'Session ended', @@ -194,6 +207,8 @@ export default { maximize: 'Maximize', restore: 'Restore', openFullscreen: 'Open fullscreen', + group: 'Group', + covers: 'covers', }, push: { enable: 'Enable notifications', @@ -337,6 +352,7 @@ export default { groupDeleted: 'Group deleted', groupSessionLaunched: 'Group session started', groupSessionPartial: 'Group session started ({n} repo(s) skipped)', + projectCreated: 'Project created', genericError: 'Something went wrong', dismiss: 'Dismiss', }, diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 36206b9..3e7b58e 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -186,6 +186,19 @@ const fr: typeof en = { externalSessions: 'externes', showExternalSessions: 'Afficher les sessions externes ({n})', }, + project: { + new: 'Nouveau projet', + title: 'Nouveau projet', + intro: 'Crée un nouveau dossier sous la racine choisie, puis y lance une session.', + nameLabel: 'Nom du projet', + namePlaceholder: 'mon-projet', + rootLabel: 'Dossier racine', + rootPlaceholder: '/chemin/absolu/racine', + gitInit: 'Initialiser un dépôt git (git init)', + commandLabel: 'Commande', + create: 'Créer', + creating: 'Création…', + }, terminal: { observer: 'observateur (lecture seule)', sessionEnded: 'Session terminée', @@ -197,6 +210,8 @@ const fr: typeof en = { maximize: 'Agrandir', restore: 'Réduire', openFullscreen: 'Ouvrir en plein écran', + group: 'Groupe', + covers: 'couvre', }, push: { enable: 'Activer les notifications', @@ -340,6 +355,7 @@ const fr: typeof en = { groupDeleted: 'Groupe supprimé', groupSessionLaunched: 'Session de groupe démarrée', groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))', + projectCreated: 'Projet créé', genericError: 'Une erreur est survenue', dismiss: 'Fermer', }, diff --git a/packages/web/src/stores/sessions.ts b/packages/web/src/stores/sessions.ts index 0d43ee1..38ae48c 100644 --- a/packages/web/src/stores/sessions.ts +++ b/packages/web/src/stores/sessions.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; -import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared'; +import type { CreateProjectRequest, CreateProjectResponse, CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared'; import { api } from '../lib/api'; import { wsClient, type SessionEvent } from '../lib/ws-client'; @@ -106,6 +106,13 @@ export const useSessionsStore = defineStore('sessions', () => { return res.session; } + // Crée un nouveau dossier de projet `/` côté serveur puis y lance une session. + async function createProject(req: CreateProjectRequest): Promise { + const res = await api.post('/api/v1/projects', req); + upsert(res.session); + return res; + } + async function killSession(id: string): Promise { await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`); } @@ -134,6 +141,7 @@ export const useSessionsStore = defineStore('sessions', () => { startRealtime, stopRealtime, createSession, + createProject, killSession, resumeSession, forkSession, diff --git a/packages/web/src/views/SessionView.vue b/packages/web/src/views/SessionView.vue index 68a564a..40a1ca8 100644 --- a/packages/web/src/views/SessionView.vue +++ b/packages/web/src/views/SessionView.vue @@ -18,6 +18,7 @@ + @@ -33,12 +34,17 @@ import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { Maximize, Minimize } from '@lucide/vue'; import { useSessionsStore } from '../stores/sessions'; +import { useGroupsStore } from '../stores/groups'; +import { useWorktreesStore } from '../stores/worktrees'; import TerminalView from '../components/TerminalView.vue'; import DialogPrompt from '../components/DialogPrompt.vue'; +import SessionContextBar from '../components/SessionContextBar.vue'; const { t } = useI18n(); const route = useRoute(); const store = useSessionsStore(); +const groups = useGroupsStore(); +const worktrees = useWorktreesStore(); const sessionId = computed(() => String(route.params.id)); const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive')); @@ -61,6 +67,9 @@ function toggleFullscreen(): void { onMounted(() => { // navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé. if (store.sessions.length === 0) void store.fetchSessions(); + // best-effort : la barre de contexte a besoin des groupes + worktrees pour résoudre repos/branches. + if (groups.groups.length === 0) void groups.fetchGroups(); + if (worktrees.repos.length === 0) void worktrees.fetchAll(); document.addEventListener('fullscreenchange', syncFullscreen); }); onBeforeUnmount(() => { diff --git a/packages/web/src/views/SessionsListView.vue b/packages/web/src/views/SessionsListView.vue index 0c1ded9..922b902 100644 --- a/packages/web/src/views/SessionsListView.vue +++ b/packages/web/src/views/SessionsListView.vue @@ -1,5 +1,8 @@