Compare commits
11 Commits
a1fd00b046
...
vscode-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
| 062bb64d41 | |||
| a67871b9b9 | |||
| 69123eaf0e | |||
| 0f126cf911 | |||
| b5236b41c8 | |||
| 2506dfb1f3 | |||
| 7d618c30d5 | |||
| 529c136199 | |||
| 7fc1f6f747 | |||
| 8b9060e0c0 | |||
| 06a400acc7 |
@@ -4,7 +4,9 @@ name: Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
# `v[0-9]*` (et non `v*`) : sinon ce workflow capture aussi les tags `vscode-v*` de l'extension
|
||||||
|
# — il stripperait alors `v` (→ `scode-v0.1.0`) et échouerait contre la version du daemon.
|
||||||
|
tags: ['v[0-9]*']
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -46,15 +46,16 @@ jobs:
|
|||||||
name: vsix
|
name: vsix
|
||||||
path: packages/vscode/*.vsix
|
path: packages/vscode/*.vsix
|
||||||
# Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente).
|
# Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente).
|
||||||
# Nécessite un secret RELEASE_TOKEN (token Gitea avec write:repository) ; sans lui, l'étape est
|
# Réutilise le secret NPM_TOKEN (même token Gitea que la publication du daemon — droits
|
||||||
# ignorée sans faire échouer le job (continue-on-error).
|
# repository suffisants pour l'API release). Sans lui, l'étape est ignorée sans faire échouer
|
||||||
|
# le job (continue-on-error) ; le VSIX reste disponible en artefact.
|
||||||
- name: Attach VSIX to Gitea release
|
- name: Attach VSIX to Gitea release
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$RELEASE_TOKEN" ]; then
|
if [ -z "$RELEASE_TOKEN" ]; then
|
||||||
echo "::notice::RELEASE_TOKEN absent — VSIX disponible en artefact uniquement."
|
echo "::notice::NPM_TOKEN absent — VSIX disponible en artefact uniquement."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -7864,7 +7864,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.9.0",
|
"version": "1.11.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
@@ -7992,7 +7992,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "git-arboretum",
|
"name": "git-arboretum",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
"@arboretum/shared": "0.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.9.0",
|
"version": "1.11.0",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PushService } from './core/push-service.js';
|
|||||||
import { loadSecretBox } from './core/secret-box.js';
|
import { loadSecretBox } from './core/secret-box.js';
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
|
import { registerProjectRoutes } from './routes/projects.js';
|
||||||
import { registerRepoRoutes } from './routes/repos.js';
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
import { registerGroupRoutes } from './routes/groups.js';
|
import { registerGroupRoutes } from './routes/groups.js';
|
||||||
import { registerWorktreeRoutes } from './routes/worktrees.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);
|
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||||
registerSessionRoutes(app, manager, discovery, db);
|
registerSessionRoutes(app, manager, discovery, db);
|
||||||
|
registerProjectRoutes(app, manager, db);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees, db);
|
registerWorktreeRoutes(app, worktrees, db);
|
||||||
|
|||||||
@@ -122,8 +122,13 @@ function quoteIfNeeded(token: string): string {
|
|||||||
return /\s/.test(token) ? `"${token}"` : token;
|
return /\s/.test(token) ? `"${token}"` : token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string {
|
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[]; pathEnv?: string | undefined }): string {
|
||||||
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
|
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
|
||||||
|
// Un service systemd user démarre avec un PATH minimal (typiquement /usr/bin:/bin…) qui n'inclut
|
||||||
|
// PAS ~/.local/bin ni le bin npm/nvm où vit le CLI `claude` → resolveClaudeBin() (`which claude`)
|
||||||
|
// échouerait. On fige donc le PATH de l'environnement d'installation, où `claude` est résolvable.
|
||||||
|
// Doubles quotes systemd : tolèrent un chemin du PATH contenant des espaces.
|
||||||
|
const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : '';
|
||||||
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
|
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
|
||||||
return `[Unit]
|
return `[Unit]
|
||||||
Description=Arboretum — git worktree & Claude Code dashboard
|
Description=Arboretum — git worktree & Claude Code dashboard
|
||||||
@@ -136,7 +141,7 @@ Restart=on-failure
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=10
|
TimeoutStopSec=10
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production${pathLine}
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -148,8 +153,14 @@ export function renderLaunchAgentPlist(input: {
|
|||||||
programArguments: string[];
|
programArguments: string[];
|
||||||
stdoutPath: string;
|
stdoutPath: string;
|
||||||
stderrPath: string;
|
stderrPath: string;
|
||||||
|
pathEnv?: string | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
||||||
|
// launchd démarre un LaunchAgent avec un PATH minimal qui n'inclut pas ~/.local/bin ni le bin
|
||||||
|
// npm/nvm où vit le CLI `claude` → resolveClaudeBin() échouerait. On fige le PATH d'installation.
|
||||||
|
const pathEntry = input.pathEnv
|
||||||
|
? `\n <key>PATH</key>\n <string>${xmlEscape(input.pathEnv)}</string>`
|
||||||
|
: '';
|
||||||
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -175,7 +186,7 @@ ${args}
|
|||||||
<key>EnvironmentVariables</key>
|
<key>EnvironmentVariables</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NODE_ENV</key>
|
<key>NODE_ENV</key>
|
||||||
<string>production</string>
|
<string>production</string>${pathEntry}
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -258,9 +269,12 @@ export async function runInstall(argv: string[]): Promise<void> {
|
|||||||
const serviceArgs = buildServiceArgs(flags);
|
const serviceArgs = buildServiceArgs(flags);
|
||||||
const { exec, args: binArgs } = resolveBin(flags);
|
const { exec, args: binArgs } = resolveBin(flags);
|
||||||
const scriptArgs = [...binArgs, ...serviceArgs];
|
const scriptArgs = [...binArgs, ...serviceArgs];
|
||||||
|
// PATH de l'environnement d'installation (shell interactif où `claude` est résolvable) : on le
|
||||||
|
// fige dans l'unit/plist car systemd/launchd démarrent le service avec un PATH minimal.
|
||||||
|
const pathEnv = process.env.PATH;
|
||||||
|
|
||||||
if (platform === 'linux') {
|
if (platform === 'linux') {
|
||||||
const unit = renderSystemdUnit({ exec, scriptArgs });
|
const unit = renderSystemdUnit({ exec, scriptArgs, pathEnv });
|
||||||
const unitPath = systemdUnitPath();
|
const unitPath = systemdUnitPath();
|
||||||
if (flags.dryRun) {
|
if (flags.dryRun) {
|
||||||
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
||||||
@@ -295,6 +309,7 @@ export async function runInstall(argv: string[]): Promise<void> {
|
|||||||
programArguments,
|
programArguments,
|
||||||
stdoutPath: logs.out,
|
stdoutPath: logs.out,
|
||||||
stderrPath: logs.err,
|
stderrPath: logs.err,
|
||||||
|
pathEnv,
|
||||||
});
|
});
|
||||||
const plistPath = launchAgentPlistPath(flags.label);
|
const plistPath = launchAgentPlistPath(flags.label);
|
||||||
const uid = process.getuid?.() ?? 0;
|
const uid = process.getuid?.() ?? 0;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface Config {
|
|||||||
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
||||||
allowedOrigins: string[];
|
allowedOrigins: string[];
|
||||||
printToken: boolean;
|
printToken: boolean;
|
||||||
|
/** racine effective de l'install Claude (~/.claude par défaut, --claude-home, ou réglage claude_home). */
|
||||||
|
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;
|
claudeProjectsDir: string;
|
||||||
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
||||||
@@ -61,7 +65,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
} catch {
|
} catch {
|
||||||
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
|
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
|
||||||
}
|
}
|
||||||
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
const claudeHomeFlag = values['claude-home'];
|
||||||
|
const claudeHome = claudeHomeFlag ?? join(homedir(), '.claude');
|
||||||
return {
|
return {
|
||||||
port: Number(values.port),
|
port: Number(values.port),
|
||||||
bind,
|
bind,
|
||||||
@@ -69,6 +74,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
dataDir,
|
dataDir,
|
||||||
allowedOrigins: values['allow-origin'] ?? [],
|
allowedOrigins: values['allow-origin'] ?? [],
|
||||||
printToken: values['print-token'] ?? false,
|
printToken: values['print-token'] ?? false,
|
||||||
|
claudeHome,
|
||||||
|
claudeHomeFromFlag: claudeHomeFlag !== undefined,
|
||||||
claudeProjectsDir: join(claudeHome, 'projects'),
|
claudeProjectsDir: join(claudeHome, 'projects'),
|
||||||
claudeSessionsDir: join(claudeHome, 'sessions'),
|
claudeSessionsDir: join(claudeHome, 'sessions'),
|
||||||
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
|
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { accessSync, constants } from 'node:fs';
|
||||||
|
|
||||||
export interface SpawnSpec {
|
export interface SpawnSpec {
|
||||||
file: string;
|
file: string;
|
||||||
@@ -12,22 +13,74 @@ export interface SpawnOptions {
|
|||||||
resume?: { claudeSessionId: string; fork?: boolean };
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
||||||
addDirs?: string[];
|
addDirs?: string[];
|
||||||
|
/** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */
|
||||||
|
claudeBinPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
|
||||||
|
export interface ClaudeBinDiagnostic {
|
||||||
|
/** chemin résolu du binaire, ou null si introuvable. */
|
||||||
|
path: string | null;
|
||||||
|
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
|
||||||
|
source: 'configured' | 'path' | null;
|
||||||
|
/** true si le binaire est présent et exécutable. */
|
||||||
|
ok: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedClaudeBin: string | null = null;
|
let cachedClaudeBin: string | null = null;
|
||||||
|
|
||||||
export function resolveClaudeBin(): string {
|
/** Recherche `claude` dans le PATH (sans throw). null si absent. */
|
||||||
if (cachedClaudeBin) return cachedClaudeBin;
|
function findClaudeOnPath(): string | null {
|
||||||
try {
|
try {
|
||||||
cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
|
return execFileSync('which', ['claude'], { encoding: 'utf8' }).trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutable(path: string): boolean {
|
||||||
|
try {
|
||||||
|
accessSync(path, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 :
|
||||||
|
* `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`).
|
||||||
|
*/
|
||||||
|
export function resolveClaudeBin(configuredPath?: string | null): string {
|
||||||
|
if (configuredPath) {
|
||||||
|
if (!isExecutable(configuredPath)) {
|
||||||
|
throw new Error(`Configured Claude CLI path is not executable: ${configuredPath}`);
|
||||||
|
}
|
||||||
|
return configuredPath;
|
||||||
|
}
|
||||||
|
if (cachedClaudeBin) return cachedClaudeBin;
|
||||||
|
const found = findClaudeOnPath();
|
||||||
|
if (!found) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
|
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
cachedClaudeBin = found;
|
||||||
return cachedClaudeBin;
|
return cachedClaudeBin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Diagnostic non-throwing pour l'UI (Réglages) : recalculé à chaque appel, jamais caché. */
|
||||||
|
export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiagnostic {
|
||||||
|
if (configuredPath) {
|
||||||
|
return { path: configuredPath, source: 'configured', ok: isExecutable(configuredPath) };
|
||||||
|
}
|
||||||
|
const found = findClaudeOnPath();
|
||||||
|
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||||
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
@@ -46,5 +99,5 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
|||||||
}
|
}
|
||||||
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
||||||
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
||||||
return { file: resolveClaudeBin(), args, env };
|
return { file: resolveClaudeBin(opts.claudeBinPath), args, env };
|
||||||
}
|
}
|
||||||
|
|||||||
63
packages/server/src/core/claude-settings.ts
Normal file
63
packages/server/src/core/claude-settings.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Réglages liés au CLI Claude, persistés dans la table `settings` (clé/valeur), modifiables via
|
||||||
|
// l'onglet Réglages. Frontière de sécurité identique à scan-settings : clés NON sensibles, écrites
|
||||||
|
// uniquement via l'allow-list du PATCH /api/v1/settings et les validateurs ci-dessous.
|
||||||
|
//
|
||||||
|
// Motivation : un service systemd/launchd démarre avec un PATH minimal qui n'inclut pas ~/.local/bin
|
||||||
|
// (où vit le binaire `claude`) → `which claude` échoue. Plutôt que de bricoler le PATH du service,
|
||||||
|
// l'utilisateur peut pointer explicitement le binaire ici (effet : prochaine session, sans redémarrage).
|
||||||
|
import { accessSync, constants, statSync } from 'node:fs';
|
||||||
|
import { getSetting } from '../db/index.js';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import { isSafeAbsolutePath } from './git.js';
|
||||||
|
|
||||||
|
/** Chemin explicite du binaire `claude` (override de la résolution via PATH). */
|
||||||
|
export const CLAUDE_BIN_PATH_KEY = 'claude_bin_path';
|
||||||
|
/** Override de la racine ~/.claude (transcripts + registre des sessions). */
|
||||||
|
export const CLAUDE_HOME_KEY = 'claude_home';
|
||||||
|
|
||||||
|
/** Chemin configuré du binaire `claude`, ou null si non défini (⇒ auto-détection via PATH). */
|
||||||
|
export function readClaudeBinPath(db: Db): string | null {
|
||||||
|
const raw = getSetting(db, CLAUDE_BIN_PATH_KEY)?.trim();
|
||||||
|
return raw ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Racine ~/.claude configurée, ou null si non défini (⇒ défaut/flag). */
|
||||||
|
export function readClaudeHome(db: Db): string | null {
|
||||||
|
const raw = getSetting(db, CLAUDE_HOME_KEY)?.trim();
|
||||||
|
return raw ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide un chemin de binaire `claude`. Retourne `''` pour réinitialiser (auto-détection), le chemin
|
||||||
|
* normalisé s'il est absolu ET pointe vers un fichier exécutable, ou `null` si invalide (⇒ 400).
|
||||||
|
*/
|
||||||
|
export function normalizeClaudeBinPath(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const p = raw.trim();
|
||||||
|
if (p === '') return '';
|
||||||
|
if (!isSafeAbsolutePath(p)) return null;
|
||||||
|
try {
|
||||||
|
if (!statSync(p).isFile()) return null;
|
||||||
|
accessSync(p, constants.X_OK);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide une racine ~/.claude. Retourne `''` pour réinitialiser (défaut), le chemin normalisé s'il
|
||||||
|
* est absolu ET pointe vers un répertoire existant, ou `null` si invalide (⇒ 400).
|
||||||
|
*/
|
||||||
|
export function normalizeClaudeHome(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const p = raw.trim();
|
||||||
|
if (p === '') return '';
|
||||||
|
if (!isSafeAbsolutePath(p)) return null;
|
||||||
|
try {
|
||||||
|
if (!statSync(p).isDirectory()) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
@@ -122,6 +122,11 @@ export function isSafeAbsolutePath(p: string): boolean {
|
|||||||
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
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. */
|
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
|
||||||
export async function isRepo(path: string): Promise<boolean> {
|
export async function isRepo(path: string): Promise<boolean> {
|
||||||
try {
|
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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
|||||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
|
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
|
||||||
import { RingBuffer } from './ring-buffer.js';
|
import { RingBuffer } from './ring-buffer.js';
|
||||||
import { buildSpawnSpec } from './claude-launcher.js';
|
import { buildSpawnSpec } from './claude-launcher.js';
|
||||||
|
import { readClaudeBinPath } from './claude-settings.js';
|
||||||
import { findByPid } from './session-registry.js';
|
import { findByPid } from './session-registry.js';
|
||||||
import { SessionActivityTracker } from './claude-adapter.js';
|
import { SessionActivityTracker } from './claude-adapter.js';
|
||||||
import type { PushService } from './push-service.js';
|
import type { PushService } from './push-service.js';
|
||||||
@@ -111,8 +112,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
}
|
}
|
||||||
// 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');
|
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;
|
||||||
const spec = buildSpawnSpec({
|
const spec = buildSpawnSpec({
|
||||||
command,
|
command,
|
||||||
|
...(claudeBinPath ? { claudeBinPath } : {}),
|
||||||
...(opts.resume ? { resume: opts.resume } : {}),
|
...(opts.resume ? { resume: opts.resume } : {}),
|
||||||
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||||
});
|
});
|
||||||
@@ -403,30 +407,35 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
// ---- interne ----
|
// ---- interne ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
|
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention — `waiting`
|
||||||
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
|
* (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session
|
||||||
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
|
* 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 —
|
||||||
|
* à 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 {
|
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
|
||||||
const prev = s.prevActivity;
|
const prev = s.prevActivity;
|
||||||
s.prevActivity = next;
|
s.prevActivity = next;
|
||||||
if (!this.push) return;
|
if (!this.push) return;
|
||||||
if (next === 'waiting' && prev !== 'waiting') {
|
const notifiable =
|
||||||
|
(next === 'waiting' && prev !== 'waiting') || (next === 'idle' && prev === 'busy');
|
||||||
|
if (notifiable) {
|
||||||
|
const target = next; // 'waiting' | 'idle'
|
||||||
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
||||||
s.notifyTimer = setTimeout(() => {
|
s.notifyTimer = setTimeout(() => {
|
||||||
s.notifyTimer = null;
|
s.notifyTimer = null;
|
||||||
const act = s.tracker?.snapshot();
|
const act = s.tracker?.snapshot();
|
||||||
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
|
if (s.exited || act?.activity !== target) return; // faux positif : l'état a déjà changé
|
||||||
void this.push?.notify({
|
const base = { sessionId: s.id, title: basename(s.cwd) || s.cwd, url: `/sessions/${s.id}` };
|
||||||
sessionId: s.id,
|
void this.push?.notify(
|
||||||
title: basename(s.cwd) || s.cwd,
|
target === 'idle'
|
||||||
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
|
? { ...base, body: 'available for new instructions', kind: null }
|
||||||
kind: act.dialog?.kind ?? null,
|
: { ...base, body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input', kind: act.dialog?.kind ?? null },
|
||||||
url: `/sessions/${s.id}`,
|
);
|
||||||
});
|
|
||||||
}, NOTIFY_DEBOUNCE_MS);
|
}, NOTIFY_DEBOUNCE_MS);
|
||||||
s.notifyTimer.unref();
|
s.notifyTimer.unref();
|
||||||
} else if (next !== 'waiting' && s.notifyTimer) {
|
} else if (s.notifyTimer && next !== 'waiting' && next !== 'idle') {
|
||||||
|
// On quitte un état notifiable avant l'échéance (ex. Claude repart en busy) → annule.
|
||||||
clearTimeout(s.notifyTimer);
|
clearTimeout(s.notifyTimer);
|
||||||
s.notifyTimer = null;
|
s.notifyTimer = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,33 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { loadConfig, type Config } from './config.js';
|
import { loadConfig, type Config } from './config.js';
|
||||||
import { openDb } from './db/index.js';
|
import { openDb, type Db } from './db/index.js';
|
||||||
import { buildApp } from './app.js';
|
import { buildApp } from './app.js';
|
||||||
|
import { readClaudeHome } from './core/claude-settings.js';
|
||||||
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
|
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
|
||||||
|
|
||||||
const pkg = JSON.parse(
|
const pkg = JSON.parse(
|
||||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||||
) as { version: string };
|
) as { version: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique l'override `claude_home` (réglage UI) sur la config runtime, sauf si --claude-home a été
|
||||||
|
* passé explicitement (le flag gagne). Lu une seule fois au démarrage → un changement via l'UI prend
|
||||||
|
* effet au redémarrage du daemon (comme l'intervalle de scan). Mute `config`.
|
||||||
|
*/
|
||||||
|
function applyClaudeHomeOverride(config: Config, db: Db): void {
|
||||||
|
if (config.claudeHomeFromFlag) return;
|
||||||
|
const override = readClaudeHome(db);
|
||||||
|
if (!override) return;
|
||||||
|
config.claudeHome = override;
|
||||||
|
config.claudeProjectsDir = join(override, 'projects');
|
||||||
|
config.claudeSessionsDir = join(override, 'sessions');
|
||||||
|
}
|
||||||
|
|
||||||
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
||||||
export async function runDaemon(config: Config): Promise<void> {
|
export async function runDaemon(config: Config): Promise<void> {
|
||||||
const db = openDb(config.dbPath);
|
const db = openDb(config.dbPath);
|
||||||
|
applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services
|
||||||
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
|
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
|
||||||
|
|
||||||
const bootstrapToken = auth.ensureBootstrapToken();
|
const bootstrapToken = auth.ensureBootstrapToken();
|
||||||
|
|||||||
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 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { Config } from '../config.js';
|
|||||||
import { type Db, setSetting } from '../db/index.js';
|
import { type Db, setSetting } from '../db/index.js';
|
||||||
import type { PushService } from '../core/push-service.js';
|
import type { PushService } from '../core/push-service.js';
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
import { diagnoseClaudeBin } from '../core/claude-launcher.js';
|
||||||
import {
|
import {
|
||||||
SCAN_INTERVAL_KEY,
|
SCAN_INTERVAL_KEY,
|
||||||
SCAN_ROOTS_KEY,
|
SCAN_ROOTS_KEY,
|
||||||
@@ -15,6 +16,14 @@ import {
|
|||||||
readScanIntervalMin,
|
readScanIntervalMin,
|
||||||
readScanRoots,
|
readScanRoots,
|
||||||
} from '../core/scan-settings.js';
|
} from '../core/scan-settings.js';
|
||||||
|
import {
|
||||||
|
CLAUDE_BIN_PATH_KEY,
|
||||||
|
CLAUDE_HOME_KEY,
|
||||||
|
normalizeClaudeBinPath,
|
||||||
|
normalizeClaudeHome,
|
||||||
|
readClaudeBinPath,
|
||||||
|
readClaudeHome,
|
||||||
|
} from '../core/claude-settings.js';
|
||||||
|
|
||||||
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||||
export function registerSettingsRoutes(
|
export function registerSettingsRoutes(
|
||||||
@@ -32,11 +41,16 @@ export function registerSettingsRoutes(
|
|||||||
dataDir: config.dataDir,
|
dataDir: config.dataDir,
|
||||||
vapidPublicKey: push.publicKey() || null,
|
vapidPublicKey: push.publicKey() || null,
|
||||||
vapidContact: config.vapidContact,
|
vapidContact: config.vapidContact,
|
||||||
|
claudeHome: config.claudeHome,
|
||||||
|
// Diagnostic recalculé à chaque GET : reflète l'état réel (override exécutable ? `which claude` ?).
|
||||||
|
claudeBin: diagnoseClaudeBin(readClaudeBinPath(db)),
|
||||||
});
|
});
|
||||||
const snapshot = (): SettingsResponse => ({
|
const snapshot = (): SettingsResponse => ({
|
||||||
settings: {
|
settings: {
|
||||||
scanRoots: readScanRoots(db),
|
scanRoots: readScanRoots(db),
|
||||||
scanIntervalMin: readScanIntervalMin(db),
|
scanIntervalMin: readScanIntervalMin(db),
|
||||||
|
claudeBinPath: readClaudeBinPath(db),
|
||||||
|
claudeHome: readClaudeHome(db),
|
||||||
},
|
},
|
||||||
server: serverInfo(),
|
server: serverInfo(),
|
||||||
});
|
});
|
||||||
@@ -59,6 +73,20 @@ export function registerSettingsRoutes(
|
|||||||
}
|
}
|
||||||
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
||||||
}
|
}
|
||||||
|
if ('claudeBinPath' in body) {
|
||||||
|
const value = normalizeClaudeBinPath(body.claudeBinPath);
|
||||||
|
if (value === null) {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeBinPath must be an absolute path to an executable file (or "" to reset)' } });
|
||||||
|
}
|
||||||
|
setSetting(db, CLAUDE_BIN_PATH_KEY, value);
|
||||||
|
}
|
||||||
|
if ('claudeHome' in body) {
|
||||||
|
const value = normalizeClaudeHome(body.claudeHome);
|
||||||
|
if (value === null) {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeHome must be an absolute path to an existing directory (or "" to reset)' } });
|
||||||
|
}
|
||||||
|
setSetting(db, CLAUDE_HOME_KEY, value);
|
||||||
|
}
|
||||||
recordAudit(db, {
|
recordAudit(db, {
|
||||||
actor: req.authContext?.tokenId ?? 'unknown',
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
action: 'settings.update',
|
action: 'settings.update',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
|
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
|
||||||
|
|
||||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
// 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' }));
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
@@ -27,3 +27,28 @@ describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
|
|||||||
expect(spec.args).toEqual(['--norc']);
|
expect(spec.args).toEqual(['--norc']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
it('buildSpawnSpec utilise le chemin configuré tel quel quand fourni', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'claude', claudeBinPath: realBin });
|
||||||
|
expect(spec.file).toBe(realBin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveClaudeBin retombe sur le PATH (which) sans override', () => {
|
||||||
|
expect(resolveClaudeBin()).toBe('/usr/bin/claude');
|
||||||
|
expect(resolveClaudeBin(null)).toBe('/usr/bin/claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveClaudeBin throw si le chemin configuré n’est pas exécutable', () => {
|
||||||
|
expect(() => resolveClaudeBin('/no/such/claude')).toThrow(/not executable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diagnoseClaudeBin : configuré+exécutable → ok ; configuré absent → !ok ; sinon PATH', () => {
|
||||||
|
expect(diagnoseClaudeBin(realBin)).toEqual({ path: realBin, source: 'configured', ok: true });
|
||||||
|
expect(diagnoseClaudeBin('/no/such/claude')).toEqual({ path: '/no/such/claude', source: 'configured', ok: false });
|
||||||
|
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
89
packages/server/test/claude-settings.test.ts
Normal file
89
packages/server/test/claude-settings.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Réglages CLI Claude (chemin du binaire + override ~/.claude) : validateurs + lecture/écriture DB.
|
||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { openDb, setSetting, type Db } from '../src/db/index.js';
|
||||||
|
import {
|
||||||
|
CLAUDE_BIN_PATH_KEY,
|
||||||
|
CLAUDE_HOME_KEY,
|
||||||
|
normalizeClaudeBinPath,
|
||||||
|
normalizeClaudeHome,
|
||||||
|
readClaudeBinPath,
|
||||||
|
readClaudeHome,
|
||||||
|
} from '../src/core/claude-settings.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let execFile: string;
|
||||||
|
let plainFile: string;
|
||||||
|
let subDir: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arboretum-claude-settings-'));
|
||||||
|
execFile = join(dir, 'claude');
|
||||||
|
writeFileSync(execFile, '#!/bin/sh\n');
|
||||||
|
chmodSync(execFile, 0o755); // exécutable
|
||||||
|
plainFile = join(dir, 'notexec');
|
||||||
|
writeFileSync(plainFile, 'x');
|
||||||
|
chmodSync(plainFile, 0o644); // non exécutable
|
||||||
|
subDir = join(dir, 'home');
|
||||||
|
mkdirSync(subDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('normalizeClaudeBinPath', () => {
|
||||||
|
it('accepte un chemin absolu vers un fichier exécutable', () => {
|
||||||
|
expect(normalizeClaudeBinPath(execFile)).toBe(execFile);
|
||||||
|
});
|
||||||
|
it('"" = réinitialisation (auto-détection)', () => {
|
||||||
|
expect(normalizeClaudeBinPath('')).toBe('');
|
||||||
|
expect(normalizeClaudeBinPath(' ')).toBe('');
|
||||||
|
});
|
||||||
|
it('rejette un fichier non exécutable, un répertoire, un chemin relatif ou avec ".." (null)', () => {
|
||||||
|
expect(normalizeClaudeBinPath(plainFile)).toBeNull();
|
||||||
|
expect(normalizeClaudeBinPath(subDir)).toBeNull(); // répertoire, pas un fichier
|
||||||
|
expect(normalizeClaudeBinPath('relative/claude')).toBeNull();
|
||||||
|
expect(normalizeClaudeBinPath(join(dir, '..', 'x'))).toBeNull();
|
||||||
|
expect(normalizeClaudeBinPath(join(dir, 'missing'))).toBeNull();
|
||||||
|
expect(normalizeClaudeBinPath(42)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeClaudeHome', () => {
|
||||||
|
it('accepte un chemin absolu vers un répertoire existant', () => {
|
||||||
|
expect(normalizeClaudeHome(subDir)).toBe(subDir);
|
||||||
|
});
|
||||||
|
it('"" = réinitialisation (défaut)', () => {
|
||||||
|
expect(normalizeClaudeHome('')).toBe('');
|
||||||
|
});
|
||||||
|
it('rejette un fichier, un chemin relatif/inexistant (null)', () => {
|
||||||
|
expect(normalizeClaudeHome(execFile)).toBeNull(); // fichier, pas un répertoire
|
||||||
|
expect(normalizeClaudeHome('relative/.claude')).toBeNull();
|
||||||
|
expect(normalizeClaudeHome(join(dir, 'missing'))).toBeNull();
|
||||||
|
expect(normalizeClaudeHome(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readClaudeBinPath / readClaudeHome', () => {
|
||||||
|
let db: Db;
|
||||||
|
beforeAll(() => {
|
||||||
|
db = openDb(join(dir, 'settings.db'));
|
||||||
|
});
|
||||||
|
afterAll(() => db.close());
|
||||||
|
|
||||||
|
it('null quand non défini', () => {
|
||||||
|
expect(readClaudeBinPath(db)).toBeNull();
|
||||||
|
expect(readClaudeHome(db)).toBeNull();
|
||||||
|
});
|
||||||
|
it('lit la valeur stockée et traite "" stockée comme null', () => {
|
||||||
|
setSetting(db, CLAUDE_BIN_PATH_KEY, execFile);
|
||||||
|
setSetting(db, CLAUDE_HOME_KEY, subDir);
|
||||||
|
expect(readClaudeBinPath(db)).toBe(execFile);
|
||||||
|
expect(readClaudeHome(db)).toBe(subDir);
|
||||||
|
setSetting(db, CLAUDE_BIN_PATH_KEY, ''); // réinitialisé
|
||||||
|
setSetting(db, CLAUDE_HOME_KEY, '');
|
||||||
|
expect(readClaudeBinPath(db)).toBeNull();
|
||||||
|
expect(readClaudeHome(db)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,21 @@ describe('cli install — renderSystemdUnit', () => {
|
|||||||
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
|
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fige le PATH d\'installation quand fourni (le service systemd a un PATH minimal)', () => {
|
||||||
|
const unit = renderSystemdUnit({
|
||||||
|
exec: '/usr/bin/node',
|
||||||
|
scriptArgs: ['/s.js'],
|
||||||
|
pathEnv: '/home/me/.local/bin:/usr/bin',
|
||||||
|
});
|
||||||
|
// Doubles quotes systemd : un chemin du PATH peut contenir un espace.
|
||||||
|
expect(unit).toContain('Environment="PATH=/home/me/.local/bin:/usr/bin"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'émet aucune ligne PATH sans pathEnv (rétrocompat)", () => {
|
||||||
|
const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/s.js'] });
|
||||||
|
expect(unit).not.toContain('PATH=');
|
||||||
|
});
|
||||||
|
|
||||||
it('snapshot du unit pour un jeu de flags fixe', () => {
|
it('snapshot du unit pour un jeu de flags fixe', () => {
|
||||||
const unit = renderSystemdUnit({
|
const unit = renderSystemdUnit({
|
||||||
exec: '/usr/bin/node',
|
exec: '/usr/bin/node',
|
||||||
@@ -133,6 +148,19 @@ describe('cli install — renderLaunchAgentPlist', () => {
|
|||||||
expect(plist).toContain('https://a?b&c=d');
|
expect(plist).toContain('https://a?b&c=d');
|
||||||
expect(plist).not.toContain('b&c=d');
|
expect(plist).not.toContain('b&c=d');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ajoute la clé PATH dans EnvironmentVariables quand fournie (launchd a un PATH minimal)', () => {
|
||||||
|
const plist = renderLaunchAgentPlist({ ...base, pathEnv: '/Users/me/.local/bin:/usr/bin' });
|
||||||
|
expect(plist).toContain('<key>PATH</key>');
|
||||||
|
expect(plist).toContain('<string>/Users/me/.local/bin:/usr/bin</string>');
|
||||||
|
// Reste dans le dict EnvironmentVariables, juste après NODE_ENV.
|
||||||
|
expect(plist.indexOf('<key>PATH</key>')).toBeGreaterThan(plist.indexOf('<key>NODE_ENV</key>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'ajoute aucune clé PATH sans pathEnv (rétrocompat)", () => {
|
||||||
|
const plist = renderLaunchAgentPlist(base);
|
||||||
|
expect(plist).not.toContain('<key>PATH</key>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cli install — xmlEscape', () => {
|
describe('cli install — xmlEscape', () => {
|
||||||
|
|||||||
45
packages/server/test/project.test.ts
Normal file
45
packages/server/test/project.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
packages/server/test/projects-routes.test.ts
Normal file
116
packages/server/test/projects-routes.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -414,6 +414,44 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
rmSync(sessDir, { recursive: true, force: true });
|
rmSync(sessDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('une notif sur le front montant busy→idle (Claude a terminé → disponible), après le debounce', async () => {
|
||||||
|
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-idle-'));
|
||||||
|
const notifies: PushPayload[] = [];
|
||||||
|
const fakePush = {
|
||||||
|
notify: async (p: PushPayload) => {
|
||||||
|
notifies.push(p);
|
||||||
|
},
|
||||||
|
} as unknown as PushService;
|
||||||
|
const m = new PtyManager(db, sessDir, fakePush);
|
||||||
|
try {
|
||||||
|
const summary = m.spawn({ cwd, command: 'claude' });
|
||||||
|
const p = lastPty();
|
||||||
|
const regFile = join(sessDir, `${p.pid}.json`);
|
||||||
|
const writeReg = (status: string): void =>
|
||||||
|
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status }));
|
||||||
|
|
||||||
|
// busy d'abord : front montant vers busy, pas de notif
|
||||||
|
writeReg('busy');
|
||||||
|
p.emitData('working…');
|
||||||
|
await sleep(260);
|
||||||
|
expect(notifies).toHaveLength(0);
|
||||||
|
|
||||||
|
// idle : Claude a terminé → front busy→idle → planifie la notif (debounce 1500ms)
|
||||||
|
writeReg('idle');
|
||||||
|
p.emitData('\x1b[2J\x1b[HDone.\r\n');
|
||||||
|
await sleep(260);
|
||||||
|
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
|
||||||
|
|
||||||
|
await sleep(1500); // dépasse le debounce
|
||||||
|
expect(notifies).toHaveLength(1);
|
||||||
|
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: null, url: `/sessions/${summary.id}` });
|
||||||
|
expect(notifies[0].body).toMatch(/available/i);
|
||||||
|
} finally {
|
||||||
|
m.shutdown();
|
||||||
|
rmSync(sessDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('flow control', () => {
|
describe('flow control', () => {
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ beforeAll(() => {
|
|||||||
dataDir: dir,
|
dataDir: dir,
|
||||||
allowedOrigins: ['https://host.tailnet.ts.net'],
|
allowedOrigins: ['https://host.tailnet.ts.net'],
|
||||||
printToken: false,
|
printToken: false,
|
||||||
|
claudeHome: join(dir, 'claude'),
|
||||||
|
claudeHomeFromFlag: false,
|
||||||
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
||||||
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
||||||
vapidContact: 'mailto:test@localhost',
|
vapidContact: 'mailto:test@localhost',
|
||||||
@@ -77,6 +79,11 @@ describe('GET /api/v1/settings', () => {
|
|||||||
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
|
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
|
||||||
expect(body.settings.scanRoots).toEqual([]);
|
expect(body.settings.scanRoots).toEqual([]);
|
||||||
expect(body.settings.scanIntervalMin).toBe(5);
|
expect(body.settings.scanIntervalMin).toBe(5);
|
||||||
|
// Claude CLI : aucun override par défaut + diagnostic via PATH (execFileSync mocké → /usr/bin/claude).
|
||||||
|
expect(body.settings.claudeBinPath).toBeNull();
|
||||||
|
expect(body.settings.claudeHome).toBeNull();
|
||||||
|
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
|
||||||
|
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => {
|
it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => {
|
||||||
@@ -137,3 +144,46 @@ describe('PATCH /api/v1/settings — découverte des dépôts', () => {
|
|||||||
expect(neg.statusCode).toBe(400);
|
expect(neg.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
it('enregistre un chemin de binaire exécutable et reflète le diagnostic (source=configured)', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as SettingsResponse;
|
||||||
|
expect(body.settings.claudeBinPath).toBe(realBin);
|
||||||
|
expect(body.server.claudeBin).toEqual({ path: realBin, source: 'configured', ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('réinitialise le chemin avec "" (retour à l’auto-détection via PATH)', async () => {
|
||||||
|
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: '' } });
|
||||||
|
const body = res.json() as SettingsResponse;
|
||||||
|
expect(body.settings.claudeBinPath).toBeNull();
|
||||||
|
expect(body.server.claudeBin.source).toBe('path'); // de nouveau via PATH (mock → /usr/bin/claude)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un chemin non absolu ou non exécutable (400)', async () => {
|
||||||
|
for (const claudeBinPath of ['relative/claude', '/a/../b', dir /* répertoire, pas un fichier */]) {
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath } });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enregistre un override claude_home (répertoire existant) et le réinitialise avec ""', async () => {
|
||||||
|
const set = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: dir } });
|
||||||
|
expect(set.statusCode).toBe(200);
|
||||||
|
expect((set.json() as SettingsResponse).settings.claudeHome).toBe(dir);
|
||||||
|
const reset = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: '' } });
|
||||||
|
expect((reset.json() as SettingsResponse).settings.claudeHome).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un claude_home non absolu ou inexistant/fichier (400)', async () => {
|
||||||
|
for (const claudeHome of ['relative/.claude', join(dir, 'settings.db') /* fichier */, join(dir, 'nope')]) {
|
||||||
|
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome } });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ export interface CreateSessionRequest {
|
|||||||
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */
|
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */
|
||||||
command?: 'claude' | 'bash';
|
command?: 'claude' | 'bash';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/projects — crée un NOUVEAU dossier de projet `<root>/<name>` (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 {
|
export interface SessionsListResponse {
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
}
|
}
|
||||||
@@ -273,6 +296,19 @@ export interface ServerInfo {
|
|||||||
/** clé publique VAPID (sûre à exposer) ; null si push indisponible. */
|
/** clé publique VAPID (sûre à exposer) ; null si push indisponible. */
|
||||||
vapidPublicKey: string | null;
|
vapidPublicKey: string | null;
|
||||||
vapidContact: string;
|
vapidContact: string;
|
||||||
|
/** racine effective de l'install Claude (~/.claude, --claude-home, ou réglage claude_home). */
|
||||||
|
claudeHome: string;
|
||||||
|
/** diagnostic de résolution du binaire `claude` (présence + exécutabilité). */
|
||||||
|
claudeBin: ClaudeBinDiagnostic;
|
||||||
|
}
|
||||||
|
/** Diagnostic de résolution du CLI `claude` exposé en lecture dans les Réglages. */
|
||||||
|
export interface ClaudeBinDiagnostic {
|
||||||
|
/** chemin résolu du binaire, ou null si introuvable. */
|
||||||
|
path: string | null;
|
||||||
|
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
|
||||||
|
source: 'configured' | 'path' | null;
|
||||||
|
/** true si le binaire est présent et exécutable. */
|
||||||
|
ok: boolean;
|
||||||
}
|
}
|
||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
|
||||||
@@ -281,6 +317,10 @@ export interface SettingsResponse {
|
|||||||
scanRoots: string[];
|
scanRoots: string[];
|
||||||
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
|
||||||
scanIntervalMin: number;
|
scanIntervalMin: number;
|
||||||
|
/** chemin explicite du binaire `claude` ; null = auto-détection via PATH. Effet : prochaine session. */
|
||||||
|
claudeBinPath: string | null;
|
||||||
|
/** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */
|
||||||
|
claudeHome: string | null;
|
||||||
};
|
};
|
||||||
server: ServerInfo;
|
server: ServerInfo;
|
||||||
}
|
}
|
||||||
@@ -289,6 +329,10 @@ export interface UpdateSettingsRequest {
|
|||||||
scanRoots?: string[];
|
scanRoots?: string[];
|
||||||
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
/** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */
|
||||||
scanIntervalMin?: number;
|
scanIntervalMin?: number;
|
||||||
|
/** chemin absolu du binaire `claude` (fichier exécutable) ; '' pour réinitialiser à l'auto-détection. */
|
||||||
|
claudeBinPath?: string;
|
||||||
|
/** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */
|
||||||
|
claudeHome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Journal d'audit (conformité entreprise) ----
|
// ---- Journal d'audit (conformité entreprise) ----
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ const { t } = useI18n();
|
|||||||
</template>
|
</template>
|
||||||
{{ t('feat9Desc') }}
|
{{ t('feat9Desc') }}
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat10Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 10v6" /><path d="M9 13h6" /><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat10Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const FAQS: Record<AppLocale, FaqItem[]> = {
|
|||||||
en: [
|
en: [
|
||||||
{
|
{
|
||||||
q: 'How is this different from running Claude Code in a terminal?',
|
q: 'How is this different from running Claude Code in a terminal?',
|
||||||
a: 'It runs the very same interactive claude CLI — Arboretum just wraps it in a managed terminal you reach from a browser. What you gain on top: a worktree-first dashboard across all your repos, live busy / waiting / idle states, a multi-terminal grid, push notifications, and the ability to answer a prompt from your phone. It supervises your sessions; it does not change how Claude Code works.',
|
a: 'It runs the very same interactive claude CLI — Arboretum just wraps it in a managed terminal you reach from a browser. What you gain on top: a worktree-first dashboard across all your repos, live busy / waiting / available states, a multi-terminal grid, push notifications, and the ability to answer a prompt from your phone. It supervises your sessions; it does not change how Claude Code works.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Does it need the cloud?',
|
q: 'Does it need the cloud?',
|
||||||
@@ -33,7 +33,7 @@ export const FAQS: Record<AppLocale, FaqItem[]> = {
|
|||||||
fr: [
|
fr: [
|
||||||
{
|
{
|
||||||
q: 'En quoi est-ce différent de lancer Claude Code dans un terminal ?',
|
q: 'En quoi est-ce différent de lancer Claude Code dans un terminal ?',
|
||||||
a: "C'est exactement le même CLI claude interactif — Arboretum l'enveloppe simplement dans un terminal managé accessible depuis un navigateur. Ce que vous gagnez en plus : un dashboard worktree-first sur tous vos repos, les états occupée / en attente / inactive en direct, une grille multi-terminaux, des notifications push, et la possibilité de répondre à une question depuis votre téléphone. Il supervise vos sessions ; il ne change rien au fonctionnement de Claude Code.",
|
a: "C'est exactement le même CLI claude interactif — Arboretum l'enveloppe simplement dans un terminal managé accessible depuis un navigateur. Ce que vous gagnez en plus : un dashboard worktree-first sur tous vos repos, les états occupée / en attente / disponible en direct, une grille multi-terminaux, des notifications push, et la possibilité de répondre à une question depuis votre téléphone. Il supervise vos sessions ; il ne change rien au fonctionnement de Claude Code.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'A-t-il besoin du cloud ?',
|
q: 'A-t-il besoin du cloud ?',
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default {
|
|||||||
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
||||||
feat9Title: 'Native VS Code extension',
|
feat9Title: 'Native VS Code extension',
|
||||||
feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.',
|
feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.',
|
||||||
|
feat10Title: 'New project in one click',
|
||||||
|
feat10Desc: 'Create a project folder anywhere (optional git init) and start a Claude session in it, from any device.',
|
||||||
scAKicker: 'Worktree-first dashboard',
|
scAKicker: 'Worktree-first dashboard',
|
||||||
scATitle: 'See what needs you — before anything stalls',
|
scATitle: 'See what needs you — before anything stalls',
|
||||||
scABody:
|
scABody:
|
||||||
@@ -88,7 +90,7 @@ export default {
|
|||||||
mAttn: 'Needs attention',
|
mAttn: 'Needs attention',
|
||||||
waiting: 'waiting',
|
waiting: 'waiting',
|
||||||
busy: 'busy',
|
busy: 'busy',
|
||||||
idle: 'idle',
|
idle: 'available',
|
||||||
mPermission: 'Permission',
|
mPermission: 'Permission',
|
||||||
mDeny: 'Deny (Esc)',
|
mDeny: 'Deny (Esc)',
|
||||||
mMain: 'main',
|
mMain: 'main',
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default {
|
|||||||
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
||||||
feat9Title: 'Extension VS Code native',
|
feat9Title: 'Extension VS Code native',
|
||||||
feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.',
|
feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.',
|
||||||
|
feat10Title: 'Nouveau projet en un clic',
|
||||||
|
feat10Desc: "Créez un dossier de projet où vous voulez (git init optionnel) et lancez-y une session Claude, depuis n'importe quel appareil.",
|
||||||
scAKicker: 'Dashboard worktree-first',
|
scAKicker: 'Dashboard worktree-first',
|
||||||
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
||||||
scABody:
|
scABody:
|
||||||
@@ -88,7 +90,7 @@ export default {
|
|||||||
mAttn: 'À traiter',
|
mAttn: 'À traiter',
|
||||||
waiting: 'en attente',
|
waiting: 'en attente',
|
||||||
busy: 'occupée',
|
busy: 'occupée',
|
||||||
idle: 'inactive',
|
idle: 'disponible',
|
||||||
mPermission: 'Permission',
|
mPermission: 'Permission',
|
||||||
mDeny: 'Refuser (Échap)',
|
mDeny: 'Refuser (Échap)',
|
||||||
mMain: 'principal',
|
mMain: 'principal',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "git-arboretum",
|
"name": "git-arboretum",
|
||||||
"displayName": "Arboretum",
|
"displayName": "Arboretum",
|
||||||
"description": "Pilot your git worktrees and Claude Code sessions from VS Code — native terminals, live tree, waiting alerts.",
|
"description": "Pilot your git worktrees and Claude Code sessions from VS Code — native terminals, live tree, waiting alerts.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"publisher": "johanleroy",
|
"publisher": "johanleroy",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ function sessionIcon(s: SessionSummary): vscode.ThemeIcon {
|
|||||||
|
|
||||||
function sessionDescription(s: SessionSummary): string {
|
function sessionDescription(s: SessionSummary): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (s.live && s.activity) parts.push(s.activity);
|
// Affiche 'available' pour idle (cohérent avec le libellé web) ; l'enum reste 'idle' côté protocole.
|
||||||
|
if (s.live && s.activity) parts.push(s.activity === 'idle' ? 'available' : s.activity);
|
||||||
else if (!s.live) parts.push('exited');
|
else if (!s.live) parts.push('exited');
|
||||||
if (s.source === 'discovered') parts.push('external');
|
if (s.source === 'discovered') parts.push('external');
|
||||||
if (s.groupId) parts.push('group');
|
if (s.groupId) parts.push('group');
|
||||||
|
|||||||
119
packages/web/src/components/NewProjectModal.vue
Normal file
119
packages/web/src/components/NewProjectModal.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="font-semibold text-zinc-100">{{ t('project.title') }}</h2>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
|
</header>
|
||||||
|
<p class="text-xs text-zinc-500">{{ t('project.intro') }}</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('project.nameLabel') }}
|
||||||
|
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('project.rootLabel') }}
|
||||||
|
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
|
||||||
|
</div>
|
||||||
|
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-xs text-zinc-300">
|
||||||
|
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('project.commandLabel') }}
|
||||||
|
<select v-model="command" class="input" :disabled="creating">
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</p>
|
||||||
|
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
|
||||||
|
{{ creating ? t('project.creating') : t('project.create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useSettingsStore } from '../stores/settings';
|
||||||
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import DirectoryPicker from './DirectoryPicker.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
const name = ref('');
|
||||||
|
// Racine par défaut : la 1ère racine de scan (réglage Découverte) ; modifiable via le picker.
|
||||||
|
const root = ref('');
|
||||||
|
const gitInit = ref(false);
|
||||||
|
const command = ref<'claude' | 'bash'>('claude');
|
||||||
|
const showPicker = ref(false);
|
||||||
|
const creating = ref(false);
|
||||||
|
const errorMsg = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Aperçu du chemin final (indicatif ; jointure naïve suffisante pour l'affichage).
|
||||||
|
const previewPath = computed(() => {
|
||||||
|
const r = root.value.trim().replace(/\/+$/, '');
|
||||||
|
const n = name.value.trim();
|
||||||
|
return r && n ? `${r}/${n}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!settings.loaded) {
|
||||||
|
try {
|
||||||
|
await settings.fetch();
|
||||||
|
} catch {
|
||||||
|
/* le préremplissage de la racine est facultatif */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.value = settings.scanRoots[0] ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function onPickRoot(path: string): void {
|
||||||
|
root.value = path;
|
||||||
|
showPicker.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
if (creating.value || name.value.trim() === '' || root.value.trim() === '') return;
|
||||||
|
creating.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
const res = await sessions.createProject({
|
||||||
|
root: root.value.trim(),
|
||||||
|
name: name.value.trim(),
|
||||||
|
gitInit: gitInit.value,
|
||||||
|
command: command.value,
|
||||||
|
});
|
||||||
|
toasts.success(t('toast.projectCreated'));
|
||||||
|
emit('close');
|
||||||
|
await router.push({ name: 'session', params: { id: res.session.id } });
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
82
packages/web/src/components/SessionContextBar.vue
Normal file
82
packages/web/src/components/SessionContextBar.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
|
||||||
|
rien d'utile à montrer (session simple dans un dossier non reconnu → le cwd du header suffit). -->
|
||||||
|
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
|
||||||
|
<RouterLink
|
||||||
|
v-if="group"
|
||||||
|
:to="{ name: 'group', params: { id: group.id } }"
|
||||||
|
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
|
||||||
|
:title="t('terminal.group')"
|
||||||
|
>
|
||||||
|
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||||
|
<span class="font-medium">{{ group.label }}</span>
|
||||||
|
<span class="text-zinc-500">→</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-for="e in entries"
|
||||||
|
:key="e.dir"
|
||||||
|
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
|
||||||
|
:title="e.dir"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-zinc-300">{{ e.label }}</span>
|
||||||
|
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
|
||||||
|
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
|
||||||
|
|
||||||
|
function basename(dir: string): string {
|
||||||
|
return dir.split('/').filter(Boolean).pop() ?? dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoverEntry {
|
||||||
|
dir: string;
|
||||||
|
label: string;
|
||||||
|
branch: string | null;
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
|
||||||
|
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
|
||||||
|
function resolve(dir: string): CoverEntry {
|
||||||
|
const wt = worktrees.worktrees.find((w) => w.path === dir);
|
||||||
|
if (wt) {
|
||||||
|
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
||||||
|
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
|
||||||
|
}
|
||||||
|
const repo = worktrees.repos.find((r) => r.path === dir);
|
||||||
|
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
|
||||||
|
return { dir, label: basename(dir), branch: null, resolved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = computed<CoverEntry[]>(() => {
|
||||||
|
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: CoverEntry[] = [];
|
||||||
|
for (const d of dirs) {
|
||||||
|
if (seen.has(d)) continue;
|
||||||
|
seen.add(d);
|
||||||
|
out.push(resolve(d));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
|
||||||
|
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
|
||||||
|
</script>
|
||||||
@@ -38,6 +38,7 @@ const ended = ref(false);
|
|||||||
let term: Terminal | null = null;
|
let term: Terminal | null = null;
|
||||||
let attachment: Attachment | null = null;
|
let attachment: Attachment | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let intersectionObserver: IntersectionObserver | null = null;
|
||||||
let onVisible: (() => void) | null = null;
|
let onVisible: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
@@ -130,6 +131,20 @@ onMounted(async () => {
|
|||||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||||
resizeObserver = new ResizeObserver(refit);
|
resizeObserver = new ResizeObserver(refit);
|
||||||
resizeObserver.observe(container.value);
|
resizeObserver.observe(container.value);
|
||||||
|
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
||||||
|
// hors/dans le viewport, ou navigation SPA — autant de cas que `visibilitychange` (onglet only) ne
|
||||||
|
// couvre PAS. Au retour visible, on repeint (le rendu était gelé hors-écran) ET on flushe l'ACK pour
|
||||||
|
// sortir d'une éventuelle pause serveur (le callback de xterm.write était throttlé tant que caché).
|
||||||
|
intersectionObserver = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
|
||||||
|
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
|
||||||
|
refit();
|
||||||
|
attachment?.flushAck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
intersectionObserver.observe(container.value);
|
||||||
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
|
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
|
||||||
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
|
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
|
||||||
onVisible = (): void => {
|
onVisible = (): void => {
|
||||||
@@ -142,6 +157,7 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
|
intersectionObserver?.disconnect();
|
||||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||||
attachment?.detach();
|
attachment?.detach();
|
||||||
term?.dispose();
|
term?.dispose();
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default {
|
|||||||
},
|
},
|
||||||
registryStatus: {
|
registryStatus: {
|
||||||
busy: 'busy',
|
busy: 'busy',
|
||||||
idle: 'idle',
|
idle: 'available',
|
||||||
waiting: 'waiting',
|
waiting: 'waiting',
|
||||||
},
|
},
|
||||||
dialog: {
|
dialog: {
|
||||||
@@ -184,6 +184,19 @@ export default {
|
|||||||
externalSessions: 'external',
|
externalSessions: 'external',
|
||||||
showExternalSessions: 'Show external sessions ({n})',
|
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: {
|
terminal: {
|
||||||
observer: 'observer (read-only)',
|
observer: 'observer (read-only)',
|
||||||
sessionEnded: 'Session ended',
|
sessionEnded: 'Session ended',
|
||||||
@@ -194,6 +207,8 @@ export default {
|
|||||||
maximize: 'Maximize',
|
maximize: 'Maximize',
|
||||||
restore: 'Restore',
|
restore: 'Restore',
|
||||||
openFullscreen: 'Open fullscreen',
|
openFullscreen: 'Open fullscreen',
|
||||||
|
group: 'Group',
|
||||||
|
covers: 'covers',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Enable notifications',
|
enable: 'Enable notifications',
|
||||||
@@ -268,7 +283,7 @@ export default {
|
|||||||
live: 'Live',
|
live: 'Live',
|
||||||
waiting: 'Waiting',
|
waiting: 'Waiting',
|
||||||
busy: 'Busy',
|
busy: 'Busy',
|
||||||
idle: 'Idle',
|
idle: 'Available',
|
||||||
exited: 'Exited',
|
exited: 'Exited',
|
||||||
resumable: 'Resumable',
|
resumable: 'Resumable',
|
||||||
},
|
},
|
||||||
@@ -337,6 +352,7 @@ export default {
|
|||||||
groupDeleted: 'Group deleted',
|
groupDeleted: 'Group deleted',
|
||||||
groupSessionLaunched: 'Group session started',
|
groupSessionLaunched: 'Group session started',
|
||||||
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
|
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
|
||||||
|
projectCreated: 'Project created',
|
||||||
genericError: 'Something went wrong',
|
genericError: 'Something went wrong',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
},
|
},
|
||||||
@@ -397,6 +413,20 @@ export default {
|
|||||||
removeRoot: 'Remove',
|
removeRoot: 'Remove',
|
||||||
scanInterval: 'Re-scan interval (minutes)',
|
scanInterval: 'Re-scan interval (minutes)',
|
||||||
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
|
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
|
||||||
|
// Claude CLI
|
||||||
|
claudeCli: 'Claude CLI',
|
||||||
|
claudeCliHint: 'Where Arboretum finds the `claude` binary and its data. Handy when the daemon runs as a service with a minimal PATH that does not include ~/.local/bin.',
|
||||||
|
claudeBinStatusOk: 'Detected',
|
||||||
|
claudeBinStatusNotFound: 'Not found — sessions cannot start until this is resolved.',
|
||||||
|
claudeBinStatusBadPath: 'The configured path is not an executable file.',
|
||||||
|
claudeBinSourceConfigured: 'configured',
|
||||||
|
claudeBinSourcePath: 'PATH',
|
||||||
|
claudeBinPathLabel: 'Binary path override',
|
||||||
|
claudeBinPathHint: 'Absolute path to the `claude` executable. Leave empty to auto-detect via PATH. Takes effect on the next session.',
|
||||||
|
claudeBinPathPlaceholder: 'e.g. /home/you/.local/bin/claude',
|
||||||
|
claudeHomeLabel: 'Claude home directory',
|
||||||
|
claudeHomeHint: 'Override the ~/.claude location (transcripts & session registry). Applies on the next daemon restart.',
|
||||||
|
claudeHomePlaceholder: 'e.g. /home/you/.claude',
|
||||||
// Serveur (lecture seule)
|
// Serveur (lecture seule)
|
||||||
server: 'Server',
|
server: 'Server',
|
||||||
serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.',
|
serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.',
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const fr: typeof en = {
|
|||||||
},
|
},
|
||||||
registryStatus: {
|
registryStatus: {
|
||||||
busy: 'occupée',
|
busy: 'occupée',
|
||||||
idle: 'inactive',
|
idle: 'disponible',
|
||||||
waiting: 'en attente',
|
waiting: 'en attente',
|
||||||
},
|
},
|
||||||
dialog: {
|
dialog: {
|
||||||
@@ -186,6 +186,19 @@ const fr: typeof en = {
|
|||||||
externalSessions: 'externes',
|
externalSessions: 'externes',
|
||||||
showExternalSessions: 'Afficher les sessions externes ({n})',
|
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: {
|
terminal: {
|
||||||
observer: 'observateur (lecture seule)',
|
observer: 'observateur (lecture seule)',
|
||||||
sessionEnded: 'Session terminée',
|
sessionEnded: 'Session terminée',
|
||||||
@@ -197,6 +210,8 @@ const fr: typeof en = {
|
|||||||
maximize: 'Agrandir',
|
maximize: 'Agrandir',
|
||||||
restore: 'Réduire',
|
restore: 'Réduire',
|
||||||
openFullscreen: 'Ouvrir en plein écran',
|
openFullscreen: 'Ouvrir en plein écran',
|
||||||
|
group: 'Groupe',
|
||||||
|
covers: 'couvre',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Activer les notifications',
|
enable: 'Activer les notifications',
|
||||||
@@ -271,7 +286,7 @@ const fr: typeof en = {
|
|||||||
live: 'Active',
|
live: 'Active',
|
||||||
waiting: 'En attente',
|
waiting: 'En attente',
|
||||||
busy: 'Occupée',
|
busy: 'Occupée',
|
||||||
idle: 'Au repos',
|
idle: 'Disponible',
|
||||||
exited: 'Terminée',
|
exited: 'Terminée',
|
||||||
resumable: 'Reprenable',
|
resumable: 'Reprenable',
|
||||||
},
|
},
|
||||||
@@ -340,6 +355,7 @@ const fr: typeof en = {
|
|||||||
groupDeleted: 'Groupe supprimé',
|
groupDeleted: 'Groupe supprimé',
|
||||||
groupSessionLaunched: 'Session de groupe démarrée',
|
groupSessionLaunched: 'Session de groupe démarrée',
|
||||||
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
|
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
|
||||||
|
projectCreated: 'Projet créé',
|
||||||
genericError: 'Une erreur est survenue',
|
genericError: 'Une erreur est survenue',
|
||||||
dismiss: 'Fermer',
|
dismiss: 'Fermer',
|
||||||
},
|
},
|
||||||
@@ -400,6 +416,20 @@ const fr: typeof en = {
|
|||||||
removeRoot: 'Retirer',
|
removeRoot: 'Retirer',
|
||||||
scanInterval: 'Intervalle de re-scan (minutes)',
|
scanInterval: 'Intervalle de re-scan (minutes)',
|
||||||
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
|
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
|
||||||
|
// Claude CLI
|
||||||
|
claudeCli: 'CLI Claude',
|
||||||
|
claudeCliHint: 'Où Arboretum trouve le binaire `claude` et ses données. Utile quand le daemon tourne en service avec un PATH minimal qui n’inclut pas ~/.local/bin.',
|
||||||
|
claudeBinStatusOk: 'Détecté',
|
||||||
|
claudeBinStatusNotFound: 'Introuvable — impossible de lancer une session tant que ce n’est pas résolu.',
|
||||||
|
claudeBinStatusBadPath: 'Le chemin configuré n’est pas un fichier exécutable.',
|
||||||
|
claudeBinSourceConfigured: 'configuré',
|
||||||
|
claudeBinSourcePath: 'PATH',
|
||||||
|
claudeBinPathLabel: 'Chemin du binaire (override)',
|
||||||
|
claudeBinPathHint: 'Chemin absolu vers l’exécutable `claude`. Laisser vide pour l’auto-détection via le PATH. Prend effet à la prochaine session.',
|
||||||
|
claudeBinPathPlaceholder: 'ex. /home/vous/.local/bin/claude',
|
||||||
|
claudeHomeLabel: 'Répertoire ~/.claude',
|
||||||
|
claudeHomeHint: 'Surcharge l’emplacement de ~/.claude (transcripts & registre des sessions). Prend effet au redémarrage du daemon.',
|
||||||
|
claudeHomePlaceholder: 'ex. /home/vous/.claude',
|
||||||
// Serveur (lecture seule)
|
// Serveur (lecture seule)
|
||||||
server: 'Serveur',
|
server: 'Serveur',
|
||||||
serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.',
|
serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.',
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export class Attachment {
|
|||||||
this.pending = null;
|
this.pending = null;
|
||||||
this.client.releaseAttachment(this);
|
this.client.releaseAttachment(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flush ACK ciblé : relance immédiatement le flux si le serveur avait mis ce PTY en pause
|
||||||
|
* (ex. retour-visible de la cellule, dont le rendu était gelé hors-écran). N'ACK que du traité. */
|
||||||
|
flushAck(): void {
|
||||||
|
this.client.flushAck(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WsClient {
|
export class WsClient {
|
||||||
@@ -357,15 +363,25 @@ export class WsClient {
|
|||||||
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
|
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
|
||||||
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
|
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
|
||||||
private flushAcks(): void {
|
private flushAcks(): void {
|
||||||
for (const att of this.attachments) {
|
for (const att of this.attachments) this.flushAttachmentAck(att);
|
||||||
if (att.closed || att.channel < 0) continue;
|
}
|
||||||
if (att.processedBytes > att.lastAckBytes) {
|
|
||||||
att.lastAckBytes = att.processedBytes;
|
/** ACK immédiat du reliquat traité d'UN attachment — n'ACK que du réellement traité (invariant
|
||||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
* préservé). Factorisé : utilisé par flushAcks() et par le flush ciblé flushAck(). */
|
||||||
}
|
private flushAttachmentAck(att: Attachment): void {
|
||||||
|
if (att.closed || att.channel < 0) return;
|
||||||
|
if (att.processedBytes > att.lastAckBytes) {
|
||||||
|
att.lastAckBytes = att.processedBytes;
|
||||||
|
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flush ACK ciblé d'un seul attachment : relance un PTY mis en pause par le serveur quand la
|
||||||
|
* cellule était cachée (cf. Attachment.flushAck, appelé au retour-visible). */
|
||||||
|
flushAck(att: Attachment): void {
|
||||||
|
this.flushAttachmentAck(att);
|
||||||
|
}
|
||||||
|
|
||||||
private handleServerMessage(msg: ServerMessage): void {
|
private handleServerMessage(msg: ServerMessage): void {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'hello_ok': {
|
case 'hello_ok': {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
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 { api } from '../lib/api';
|
||||||
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
||||||
|
|
||||||
@@ -106,6 +106,13 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
return res.session;
|
return res.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crée un nouveau dossier de projet `<root>/<name>` côté serveur puis y lance une session.
|
||||||
|
async function createProject(req: CreateProjectRequest): Promise<CreateProjectResponse> {
|
||||||
|
const res = await api.post<CreateProjectResponse>('/api/v1/projects', req);
|
||||||
|
upsert(res.session);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
async function killSession(id: string): Promise<void> {
|
async function killSession(id: string): Promise<void> {
|
||||||
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
||||||
}
|
}
|
||||||
@@ -134,6 +141,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
startRealtime,
|
startRealtime,
|
||||||
stopRealtime,
|
stopRealtime,
|
||||||
createSession,
|
createSession,
|
||||||
|
createProject,
|
||||||
killSession,
|
killSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
forkSession,
|
forkSession,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const server = ref<ServerInfo | null>(null);
|
const server = ref<ServerInfo | null>(null);
|
||||||
const scanRoots = ref<string[]>([]);
|
const scanRoots = ref<string[]>([]);
|
||||||
const scanIntervalMin = ref(0);
|
const scanIntervalMin = ref(0);
|
||||||
|
const claudeBinPath = ref<string | null>(null);
|
||||||
|
const claudeHome = ref<string | null>(null);
|
||||||
const loaded = ref(false);
|
const loaded = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
server.value = res.server;
|
server.value = res.server;
|
||||||
scanRoots.value = res.settings.scanRoots;
|
scanRoots.value = res.settings.scanRoots;
|
||||||
scanIntervalMin.value = res.settings.scanIntervalMin;
|
scanIntervalMin.value = res.settings.scanIntervalMin;
|
||||||
|
claudeBinPath.value = res.settings.claudeBinPath;
|
||||||
|
claudeHome.value = res.settings.claudeHome;
|
||||||
loaded.value = true;
|
loaded.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,5 +36,5 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { server, scanRoots, scanIntervalMin, loaded, saving, fetch, save };
|
return { server, scanRoots, scanIntervalMin, claudeBinPath, claudeHome, loaded, saving, fetch, save };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
|
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
<SessionContextBar v-if="session" :session="session" />
|
||||||
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
||||||
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||||
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
||||||
@@ -33,12 +34,17 @@ import { useRoute } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Maximize, Minimize } from '@lucide/vue';
|
import { Maximize, Minimize } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import TerminalView from '../components/TerminalView.vue';
|
import TerminalView from '../components/TerminalView.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
|
import SessionContextBar from '../components/SessionContextBar.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const store = useSessionsStore();
|
const store = useSessionsStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
|
||||||
const sessionId = computed(() => String(route.params.id));
|
const sessionId = computed(() => String(route.params.id));
|
||||||
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
||||||
@@ -61,6 +67,9 @@ function toggleFullscreen(): void {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
|
// 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();
|
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);
|
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader :title="t('nav.sessions')">
|
<PageHeader :title="t('nav.sessions')">
|
||||||
|
<BaseButton variant="primary" size="sm" :icon="FolderPlus" @click="showNewProject = true">
|
||||||
|
{{ t('project.new') }}
|
||||||
|
</BaseButton>
|
||||||
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
|
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
|
||||||
{{ t('sessions.hideDiscovered') }}
|
{{ t('sessions.hideDiscovered') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
@@ -37,6 +40,7 @@
|
|||||||
@select="onPickCwd"
|
@select="onPickCwd"
|
||||||
@close="showPicker = false"
|
@close="showPicker = false"
|
||||||
/>
|
/>
|
||||||
|
<NewProjectModal v-if="showNewProject" @close="showNewProject = false" />
|
||||||
|
|
||||||
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
|
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
|
||||||
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
|
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
|
||||||
@@ -102,7 +106,7 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
@@ -118,6 +122,7 @@ import Pagination from '../components/Pagination.vue';
|
|||||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||||
|
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -136,6 +141,7 @@ const controls = useListControls<SessionSummary>({
|
|||||||
const newCwd = ref('');
|
const newCwd = ref('');
|
||||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||||
const showPicker = ref(false);
|
const showPicker = ref(false);
|
||||||
|
const showNewProject = ref(false);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
const killArmedId = ref<string | null>(null);
|
const killArmedId = ref<string | null>(null);
|
||||||
const killing = ref(false);
|
const killing = ref(false);
|
||||||
|
|||||||
@@ -137,6 +137,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Claude CLI -->
|
||||||
|
<section class="card flex flex-col gap-3">
|
||||||
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
|
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-zinc-500">{{ t('settings.claudeCliHint') }}</p>
|
||||||
|
|
||||||
|
<!-- Diagnostic de détection -->
|
||||||
|
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
|
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-emerald-400' : 'text-rose-400'" />
|
||||||
|
<span :class="claudeBin.ok ? 'text-zinc-300' : 'text-rose-300'">
|
||||||
|
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
|
||||||
|
</span>
|
||||||
|
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-zinc-200" :title="claudeBin.path">{{ claudeBin.path }}</code>
|
||||||
|
<span v-if="claudeBin.source" class="badge bg-zinc-700/40 text-zinc-300">
|
||||||
|
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-zinc-400">{{ t('settings.claudeBinPathLabel') }}</span>
|
||||||
|
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
|
||||||
|
<span class="text-xs text-zinc-500">{{ t('settings.claudeBinPathHint') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-zinc-400">{{ t('settings.claudeHomeLabel') }}</span>
|
||||||
|
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
|
||||||
|
<span class="text-xs text-zinc-500">{{ t('settings.claudeHomeHint') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<BaseButton variant="primary" :loading="settings.saving" :disabled="!claudeDirty" @click="saveClaude">
|
||||||
|
{{ t('settings.save') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Sécurité & conformité -->
|
<!-- Sécurité & conformité -->
|
||||||
<section class="card flex flex-col gap-3">
|
<section class="card flex flex-col gap-3">
|
||||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
@@ -202,7 +240,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Bell, BellOff, Check, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
|
import { AlertCircle, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
|
||||||
import type {
|
import type {
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
AuditLogsResponse,
|
AuditLogsResponse,
|
||||||
@@ -328,6 +366,27 @@ async function saveDiscovery(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Claude CLI ----
|
||||||
|
const claudeBinDraft = ref('');
|
||||||
|
const claudeHomeDraft = ref('');
|
||||||
|
const claudeBin = computed(() => settings.server?.claudeBin ?? { path: null, source: null, ok: false });
|
||||||
|
const claudeDirty = computed(
|
||||||
|
() => claudeBinDraft.value !== (settings.claudeBinPath ?? '') || claudeHomeDraft.value !== (settings.claudeHome ?? ''),
|
||||||
|
);
|
||||||
|
function syncClaudeDraft(): void {
|
||||||
|
claudeBinDraft.value = settings.claudeBinPath ?? '';
|
||||||
|
claudeHomeDraft.value = settings.claudeHome ?? '';
|
||||||
|
}
|
||||||
|
async function saveClaude(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await settings.save({ claudeBinPath: claudeBinDraft.value, claudeHome: claudeHomeDraft.value });
|
||||||
|
syncClaudeDraft();
|
||||||
|
toasts.success(t('settings.saved'));
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Sécurité & conformité (audit / RGPD) ----
|
// ---- Sécurité & conformité (audit / RGPD) ----
|
||||||
const audit = ref<AuditLogEntry[]>([]);
|
const audit = ref<AuditLogEntry[]>([]);
|
||||||
const auditing = ref(false);
|
const auditing = ref(false);
|
||||||
@@ -378,6 +437,7 @@ async function deleteMyData(): Promise<void> {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
||||||
syncDiscoveryDraft();
|
syncDiscoveryDraft();
|
||||||
|
syncClaudeDraft();
|
||||||
void push.refresh();
|
void push.refresh();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
53
packages/web/test/ws-client.test.ts
Normal file
53
packages/web/test/ws-client.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Flush ACK ciblé (Attachment.flushAck) : relance un PTY mis en pause au retour-visible d'une cellule.
|
||||||
|
// On teste la logique d'ACK sans WebSocket réel (sendControl est public → stubbable).
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { WsClient, Attachment, type TerminalSink } from '../src/lib/ws-client';
|
||||||
|
|
||||||
|
const sink: TerminalSink = {
|
||||||
|
write: () => {},
|
||||||
|
reset: () => {},
|
||||||
|
onDetached: () => {},
|
||||||
|
onControlChanged: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ws-client — flush ACK ciblé (Attachment.flushAck)', () => {
|
||||||
|
it('envoie un unique ACK cumulatif du reliquat traité, puis est idempotent', () => {
|
||||||
|
const client = new WsClient();
|
||||||
|
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
|
||||||
|
const att = new Attachment(client, 'sid', 'interactive', sink, 80, 24);
|
||||||
|
att.channel = 2;
|
||||||
|
att.processedBytes = 1000;
|
||||||
|
|
||||||
|
att.flushAck();
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(send).toHaveBeenCalledWith({ type: 'ack', channel: 2, bytes: 1000 });
|
||||||
|
expect(att.lastAckBytes).toBe(1000);
|
||||||
|
|
||||||
|
// rien de neuf traité → aucun ACK supplémentaire (n'ACK QUE du réellement traité)
|
||||||
|
att.flushAck();
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// nouveau reliquat traité → ACK cumulatif mis à jour
|
||||||
|
att.processedBytes = 1500;
|
||||||
|
att.flushAck();
|
||||||
|
expect(send).toHaveBeenCalledTimes(2);
|
||||||
|
expect(send).toHaveBeenLastCalledWith({ type: 'ack', channel: 2, bytes: 1500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-op si le canal n’est pas attaché (-1) ou si l’attachment est fermé', () => {
|
||||||
|
const client = new WsClient();
|
||||||
|
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const notAttached = new Attachment(client, 'a', 'interactive', sink, 80, 24); // channel = -1
|
||||||
|
notAttached.processedBytes = 500;
|
||||||
|
notAttached.flushAck();
|
||||||
|
|
||||||
|
const closed = new Attachment(client, 'b', 'interactive', sink, 80, 24);
|
||||||
|
closed.channel = 3;
|
||||||
|
closed.processedBytes = 500;
|
||||||
|
closed.closed = true;
|
||||||
|
closed.flushAck();
|
||||||
|
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user