diff --git a/README.md b/README.md index a558dc93..a076271b 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,31 @@ claude mcp remove apra-fleet --scope user +
+Running multiple fleet instances on the same machine + +Use `--instance` (recommended) or `--data-dir` to keep fleet data isolated per project. + +**Named instance** — registers the MCP server as `apra-fleet-` with data under `~/.apra-fleet/workspaces/`: +```bash +apra-fleet install --instance my-project +``` + +**Custom data directory:** +```bash +apra-fleet install --data-dir ~/fleet-data/my-project +``` + +**Manage instances with the workspace command:** +```bash +apra-fleet workspace list # show all instances +apra-fleet workspace status # current instance info +``` + +Credentials stored in one instance are not readable by another — each instance has its own encryption salt. + +
+ ## Register your first member A "member" is any machine (or workspace) that fleet manages. There are two types: diff --git a/feedback.md b/feedback.md new file mode 100644 index 00000000..cd936742 --- /dev/null +++ b/feedback.md @@ -0,0 +1,51 @@ +# PR #231 — feat/per-instance-data-dir — Code Review + +## Verdict: APPROVED + +Build: ✅ pass | Tests: ✅ 1138 passed, 6 skipped, 0 failures + +--- + +## Findings + +### MEDIUM — `mcp remove` still uses shell-interpolated `execSync` (inconsistency) + +**File:** `src/cli/install.ts:595` + +The security fix correctly switches `claude mcp add` to `execFileSync` with an argv array, eliminating shell injection. However, `claude mcp remove` still goes through the `run()` helper which uses `execSync` with string interpolation: + +```ts +run(`claude mcp remove ${serverName} --scope user`, { stdio: 'ignore' }); +``` + +This is **not exploitable** because `serverName` is derived from `instanceName` which is validated against `/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/` — no shell metacharacters can pass. But for consistency and defense-in-depth, consider switching this to `execFileSync` as well. + +### LOW — `workspace.ts` duplicates path constants from `paths.ts` + +**File:** `src/cli/workspace.ts:5-9` + +`APRA_BASE`, `WORKSPACES_DIR`, and `WORKSPACES_INDEX` are defined identically in both `src/paths.ts` and `src/cli/workspace.ts`. Should import from `../paths.ts` to avoid drift. + +### LOW — `cmdUse` eval suggestion could confuse users + +**File:** `src/cli/workspace.ts:151` + +The output suggests `eval "$(apra-fleet workspace use )"` but the command also prints a comment line (`# To activate...`) which would be harmless but noisy in eval context. Consider emitting the export-only line when stdout is not a TTY, or documenting that eval will work despite the comment. + +--- + +## Security Assessment + +- **Shell injection:** Fully eliminated for the `mcp add` path (the attack vector described in #193). The `mcp remove` path is safe due to input validation but could be hardened for consistency. +- **Path traversal:** `--instance` name is alphanumeric-only (regex validated), `--data-dir` resolves `~` safely. No directory traversal possible via instance names. +- **No user input flows unvalidated into file system paths** — workspace names are validated, data-dir is used as-is (user controls their own filesystem). + +## Backward Compatibility + +- No `--data-dir` / `--instance` → old behavior unchanged: server name remains `apra-fleet`, data dir defaults to `~/.apra-fleet/data`. +- `FLEET_DIR` in `paths.ts` still falls back to the old default when `APRA_FLEET_DATA_DIR` is unset. +- Existing MCP registrations are unaffected. + +## Summary + +Clean implementation. The --instance/--data-dir design is intuitive, input validation is solid, the security fix addresses the reported shell injection, tests cover flag parsing + multi-provider + permissions correctly. The two MEDIUM/LOW items are non-blocking improvements. diff --git a/llms-full.txt b/llms-full.txt index 039125f3..e7f53c50 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -174,6 +174,31 @@ claude mcp remove apra-fleet --scope user +
+Running multiple fleet instances on the same machine + +Use `--instance` (recommended) or `--data-dir` to keep fleet data isolated per project. + +**Named instance** — registers the MCP server as `apra-fleet-` with data under `~/.apra-fleet/workspaces/`: +```bash +apra-fleet install --instance my-project +``` + +**Custom data directory:** +```bash +apra-fleet install --data-dir ~/fleet-data/my-project +``` + +**Manage instances with the workspace command:** +```bash +apra-fleet workspace list # show all instances +apra-fleet workspace status # current instance info +``` + +Credentials stored in one instance are not readable by another — each instance has its own encryption salt. + +
+ ## Register your first member A "member" is any machine (or workspace) that fleet manages. There are two types: diff --git a/skills/fleet/SKILL.md b/skills/fleet/SKILL.md index a994da02..77b335fe 100644 --- a/skills/fleet/SKILL.md +++ b/skills/fleet/SKILL.md @@ -37,6 +37,8 @@ This skill defines how to interact with fleet infrastructure: registering and on | `credential_store_update` | Update credential metadata (members, TTL, network policy) without re-entering the secret | | `stop_prompt` | Kill the active LLM process on a member. **Always call `TaskStop` after calling `stop_prompt`**.

**Use when:** a member is hung, working on the wrong thing, or needs to be cancelled. | +**Multiple instances on the same machine:** Use `--instance ` when installing to isolate fleet data per project (registers the MCP server as `apra-fleet-`, data under `~/.apra-fleet/workspaces/`). Example: `apra-fleet install --instance my-project`. Each instance has its own credential store and member registry. + See sub-documents for detailed usage: - `onboarding.md` — full 8-step member onboarding sequence - `permissions.md` — permission composition and denial handling diff --git a/src/cli/install.ts b/src/cli/install.ts index 1fed4127..552708a6 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { execSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import { parse, stringify } from 'smol-toml'; import { serverVersion } from '../version.js'; import type { LlmProvider } from '../types.js'; @@ -240,11 +240,11 @@ function mergeHooksConfig(paths: ProviderInstallConfig, hooksConfig: any): void writeConfig(paths, settings); } -function mergePermissions(paths: ProviderInstallConfig): void { +function mergePermissions(paths: ProviderInstallConfig, serverName: string = 'apra-fleet'): void { const settings = readConfig(paths); const requiredPerms = [ - 'mcp__apra-fleet__*', + `mcp__${serverName}__*`, 'Agent(*)', `Read(${paths.skillsDir.replace(/\\/g, '/')}/**)`, `Read(${paths.fleetSkillsDir.replace(/\\/g, '/')}/**)`, @@ -273,11 +273,12 @@ function configureStatusline(paths: ProviderInstallConfig, scriptPath: string): writeConfig(paths, settings); } -function mergeGeminiConfig(paths: ProviderInstallConfig, mcpConfig: any): void { +function mergeGeminiConfig(paths: ProviderInstallConfig, mcpConfig: any, serverName: string, envVars?: Record): void { const settings = readConfig(paths); settings.mcpServers = settings.mcpServers || {}; - settings.mcpServers['apra-fleet'] = { + settings.mcpServers[serverName] = { ...mcpConfig, + ...(envVars && Object.keys(envVars).length > 0 ? { env: envVars } : {}), trust: true, }; @@ -297,20 +298,24 @@ function writeDefaultModel(paths: ProviderInstallConfig, standardModel: string): writeConfig(paths, settings); } -function mergeCopilotConfig(paths: ProviderInstallConfig, mcpConfig: any): void { +function mergeCopilotConfig(paths: ProviderInstallConfig, mcpConfig: any, serverName: string, envVars?: Record): void { const settings = readConfig(paths); settings.mcpServers = settings.mcpServers || {}; - settings.mcpServers['apra-fleet'] = mcpConfig; + settings.mcpServers[serverName] = { + ...mcpConfig, + ...(envVars && Object.keys(envVars).length > 0 ? { env: envVars } : {}), + }; writeConfig(paths, settings); } -function mergeCodexConfig(paths: ProviderInstallConfig, mcpConfig: any): void { +function mergeCodexConfig(paths: ProviderInstallConfig, mcpConfig: any, serverName: string, envVars?: Record): void { const settings = readConfig(paths); settings.mcp_servers = settings.mcp_servers || {}; - settings.mcp_servers['apra-fleet'] = { + settings.mcp_servers[serverName] = { command: mcpConfig.command.replace(/\\/g, '/'), args: mcpConfig.args.map((a: string) => a.replace(/\\/g, '/')), + ...(envVars && Object.keys(envVars).length > 0 ? { env: envVars } : {}), }; writeConfig(paths, settings); @@ -361,15 +366,17 @@ export async function runInstall(args: string[]): Promise { Install the apra-fleet binary, hooks, MCP server registration, and skills. Usage: - apra-fleet install Install binary + hooks + statusline + MCP + fleet & PM skills (default) - apra-fleet install --skill all Same as bare install (all skills) - apra-fleet install --skill fleet Install fleet skill only - apra-fleet install --skill pm Install PM skill (also installs fleet — PM depends on fleet) - apra-fleet install --skill none Skip skill installation - apra-fleet install --no-skill Same as --skill none - apra-fleet install --force Stop a running server before installing - apra-fleet install --llm Target LLM provider: claude (default), gemini, codex, copilot - apra-fleet install --help Show this help + apra-fleet install Install binary + hooks + statusline + MCP + fleet & PM skills (default) + apra-fleet install --skill all Same as bare install (all skills) + apra-fleet install --skill fleet Install fleet skill only + apra-fleet install --skill pm Install PM skill (also installs fleet — PM depends on fleet) + apra-fleet install --skill none Skip skill installation + apra-fleet install --no-skill Same as --skill none + apra-fleet install --force Stop a running server before installing + apra-fleet install --llm Target LLM provider: claude (default), gemini, codex, copilot + apra-fleet install --data-dir Use a custom data directory (isolates registry, statusline, etc.) + apra-fleet install --instance Shorthand: --data-dir ~/.apra-fleet/workspaces/, registers as apra-fleet- + apra-fleet install --help Show this help Options: --llm LLM provider to configure. Supported: claude, gemini, codex, copilot. @@ -378,7 +385,9 @@ Options: run sequentially rather than in parallel. --skill Which skills to install: all (default), fleet, pm, or none. --no-skill Alias for --skill none. - --force Stop a running apra-fleet server before installing (SEA mode only).`); + --force Stop a running apra-fleet server before installing (SEA mode only). + --data-dir Use a custom data directory (isolates registry, statusline, etc.). + --instance Shorthand for --data-dir ~/.apra-fleet/workspaces/.`); process.exit(0); return; } @@ -401,6 +410,48 @@ Options: process.exit(1); } + // Parse --instance flag (shorthand: sets data-dir to workspaces/ + server name to apra-fleet-) + let instanceName: string | undefined; + const instanceEqualArg = args.find(a => a.startsWith('--instance=')); + if (instanceEqualArg) { + instanceName = instanceEqualArg.split('=').slice(1).join('='); + } else { + const idx = args.indexOf('--instance'); + if (idx >= 0 && idx < args.length - 1 && !args[idx + 1].startsWith('--')) { + instanceName = args[idx + 1]; + } + } + if (instanceName !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(instanceName)) { + console.error(`Error: --instance name must be alphanumeric (with optional - or _), max 64 chars.`); + process.exit(1); + } + + // Parse --data-dir flag + let dataDir: string | undefined; + const dataDirEqualArg = args.find(a => a.startsWith('--data-dir=')); + if (dataDirEqualArg) { + dataDir = dataDirEqualArg.split('=').slice(1).join('='); + } else { + const idx = args.indexOf('--data-dir'); + if (idx >= 0 && idx < args.length - 1 && !args[idx + 1].startsWith('--')) { + dataDir = args[idx + 1]; + } + } + + // --instance expands to --data-dir ~/.apra-fleet/workspaces/ if --data-dir not also set + if (instanceName && !dataDir) { + dataDir = path.join(home, '.apra-fleet', 'workspaces', instanceName); + } + + // Resolve ~ in --data-dir + if (dataDir) { + dataDir = dataDir.replace(/^~(?=$|\/)/, home); + } + + // Derive MCP server name: apra-fleet- for --instance, otherwise apra-fleet + const serverName = instanceName ? `apra-fleet-${instanceName}` : 'apra-fleet'; + const envVars: Record = dataDir ? { APRA_FLEET_DATA_DIR: dataDir } : {}; + const paths = getProviderInstallConfig(llm); // Parse --skill flag: default (no flag) = all; accepts all|fleet|pm|none; --no-skill = synonym for none @@ -437,8 +488,8 @@ Options: const force = args.includes('--force'); // Reject unknown flags to catch typos early - const knownFlagPrefixes = ['--llm=', '--skill=']; - const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--help', '-h']); + const knownFlagPrefixes = ['--llm=', '--skill=', '--data-dir=', '--instance=']; + const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--help', '-h', '--data-dir', '--instance']); for (const a of args) { if (knownFlagExact.has(a)) continue; if (knownFlagPrefixes.some(p => a.startsWith(p))) continue; @@ -541,19 +592,42 @@ ${killHint} if (llm === 'claude') { try { - run('claude mcp remove apra-fleet --scope user', { stdio: 'ignore' }); + run(`claude mcp remove ${serverName} --scope user`, { stdio: 'ignore' }); } catch { /* not registered */ } - - const cmd = mcpConfig.command === 'node' - ? `claude mcp add --scope user apra-fleet -- node "${mcpConfig.args[0]}"` - : `claude mcp add --scope user apra-fleet -- "${mcpConfig.command}"`; - run(cmd); + + const envArgs = Object.entries(envVars).flatMap(([k, v]) => ['-e', `${k}=${v}`]); + const serverArgs = mcpConfig.command === 'node' + ? ['node', mcpConfig.args[0]] + : [mcpConfig.command]; + const addArgs = ['mcp', 'add', '--scope', 'user', ...envArgs, serverName, '--', ...serverArgs]; + const shellOpt = process.platform === 'win32' ? { shell: 'cmd.exe' as const } : {}; + execFileSync('claude', addArgs, { stdio: 'inherit', ...shellOpt }); } else if (llm === 'gemini') { - mergeGeminiConfig(paths, mcpConfig); + mergeGeminiConfig(paths, mcpConfig, serverName, envVars); } else if (llm === 'codex') { - mergeCodexConfig(paths, mcpConfig); + mergeCodexConfig(paths, mcpConfig, serverName, envVars); } else if (llm === 'copilot') { - mergeCopilotConfig(paths, mcpConfig); + mergeCopilotConfig(paths, mcpConfig, serverName, envVars); + } + + // Register named workspace in the workspaces index when --instance is used + if (instanceName && dataDir) { + const workspacesIndexPath = path.join(home, '.apra-fleet', 'workspaces.json'); + let index: { workspaces: Array<{ name: string; path: string; created: string }> } = { workspaces: [] }; + if (fs.existsSync(workspacesIndexPath)) { + try { index = JSON.parse(fs.readFileSync(workspacesIndexPath, 'utf-8')); } catch { /* ignore */ } + } + index.workspaces = index.workspaces || []; + const existing = index.workspaces.findIndex(w => w.name === instanceName); + const entry = { name: instanceName, path: dataDir, created: new Date().toISOString() }; + if (existing >= 0) { + index.workspaces[existing] = entry; + } else { + index.workspaces.push(entry); + } + fs.mkdirSync(path.dirname(workspacesIndexPath), { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(workspacesIndexPath, JSON.stringify(index, null, 2) + '\n'); } // --- Step 6: Install fleet skill (optional) --- @@ -598,7 +672,7 @@ ${killHint} } // Finalize permissions - mergePermissions(paths); + mergePermissions(paths, serverName); // Write install-config.json const installConfig = { llm, skill: skillMode }; @@ -607,14 +681,16 @@ ${killHint} fs.writeFileSync(path.join(configDir, 'install-config.json'), JSON.stringify(installConfig, null, 2), { mode: 0o600 }); // --- Done --- - const instructions = llm === 'claude' ? 'Run /mcp in Claude Code to load the server.' : `Restart ${paths.name} to load the server.`; + const instructions = llm === 'claude' ? `Run /mcp in Claude Code to load the server (server name: ${serverName}).` : `Restart ${paths.name} to load the server.`; const forceNote = force ? '\nRestart Claude Code to reload the MCP server.' : ''; + const dataDirNote = dataDir ? `\n Data Dir: ${dataDir}` : ''; + const instanceNote = serverName !== 'apra-fleet' ? `\n MCP Server: ${serverName}` : ''; console.log(` Apra Fleet ${serverVersion} installed successfully for ${paths.name}. Binary: ${BIN_DIR} Hooks: ${HOOKS_DIR} Scripts: ${SCRIPTS_DIR} - Settings: ${paths.settingsFile}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''} + Settings: ${paths.settingsFile}${instanceNote}${dataDirNote}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''} ${instructions}${forceNote} `); diff --git a/src/cli/workspace.ts b/src/cli/workspace.ts new file mode 100644 index 00000000..6b889afd --- /dev/null +++ b/src/cli/workspace.ts @@ -0,0 +1,281 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const home = os.homedir(); +const APRA_BASE = path.join(home, '.apra-fleet'); +const WORKSPACES_DIR = path.join(APRA_BASE, 'workspaces'); +const WORKSPACES_INDEX = path.join(APRA_BASE, 'workspaces.json'); +const DEFAULT_DATA_DIR = path.join(APRA_BASE, 'data'); + +interface WorkspaceEntry { + name: string; + path: string; + created: string; +} + +interface WorkspacesIndex { + workspaces: WorkspaceEntry[]; +} + +function readIndex(): WorkspacesIndex { + if (!fs.existsSync(WORKSPACES_INDEX)) return { workspaces: [] }; + try { + return JSON.parse(fs.readFileSync(WORKSPACES_INDEX, 'utf-8')); + } catch { + return { workspaces: [] }; + } +} + +function writeIndex(index: WorkspacesIndex): void { + fs.mkdirSync(path.dirname(WORKSPACES_INDEX), { recursive: true }); + fs.writeFileSync(WORKSPACES_INDEX, JSON.stringify(index, null, 2) + '\n'); +} + +function getDefaultWorkspace(): WorkspaceEntry { + return { name: 'default', path: DEFAULT_DATA_DIR, created: '' }; +} + +function allWorkspaces(index: WorkspacesIndex): WorkspaceEntry[] { + const hasDefault = index.workspaces.some(w => w.name === 'default'); + const base: WorkspaceEntry[] = hasDefault ? [] : [getDefaultWorkspace()]; + return [...base, ...index.workspaces]; +} + +function activePath(): string { + return process.env.APRA_FLEET_DATA_DIR ?? DEFAULT_DATA_DIR; +} + +function memberCount(dataDir: string): number { + const registryPath = path.join(dataDir, 'registry.json'); + if (!fs.existsSync(registryPath)) return 0; + try { + const reg = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + return Array.isArray(reg) ? reg.length : 0; + } catch { + return 0; + } +} + +function statuslineAge(dataDir: string): string { + const slPath = path.join(dataDir, 'statusline.txt'); + if (!fs.existsSync(slPath)) return '—'; + try { + const stat = fs.statSync(slPath); + const ageMs = Date.now() - stat.mtimeMs; + const ageSec = Math.floor(ageMs / 1000); + if (ageSec < 60) return `${ageSec}s ago`; + if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`; + return `${Math.floor(ageSec / 3600)}h ago`; + } catch { + return '—'; + } +} + +function isActive(ws: WorkspaceEntry): boolean { + const current = activePath(); + const resolved = ws.path.replace(/^~/, home); + return path.resolve(resolved) === path.resolve(current); +} + +// --- Commands --- + +function cmdList(): void { + const index = readIndex(); + const workspaces = allWorkspaces(index); + const current = activePath(); + + const col1 = Math.max(4, ...workspaces.map(w => w.name.length)); + const col2 = Math.max(4, ...workspaces.map(w => w.path.length)); + + const header = `${'NAME'.padEnd(col1)} ${'PATH'.padEnd(col2)} MEMBERS ACTIVE`; + console.log(header); + console.log('-'.repeat(header.length)); + + for (const ws of workspaces) { + const active = isActive(ws) ? '✅' : '—'; + const members = memberCount(ws.path.replace(/^~/, home)); + const resolved = ws.path.replace(/^~/, home); + const display = resolved.startsWith(home) ? ws.path.replace(home, '~') : ws.path; + console.log(`${ws.name.padEnd(col1)} ${display.padEnd(col2)} ${String(members).padStart(7)} ${active}`); + } + void current; +} + +function cmdAdd(args: string[]): void { + const withInstall = args.includes('--install'); + const nameArgs = args.filter(a => !a.startsWith('--')); + if (nameArgs.length === 0) { + console.error('Error: workspace add requires a name. Usage: apra-fleet workspace add [--install]'); + process.exit(1); + } + const name = nameArgs[0]; + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) { + console.error('Error: workspace name must be alphanumeric (with optional - or _), max 64 chars.'); + process.exit(1); + } + if (name === 'default') { + console.error('Error: "default" is reserved. Use "apra-fleet workspace status default" to inspect it.'); + process.exit(1); + } + + const wsPath = path.join(WORKSPACES_DIR, name); + const index = readIndex(); + + const existing = index.workspaces.findIndex(w => w.name === name); + const entry: WorkspaceEntry = { + name, + path: wsPath, + created: existing >= 0 ? index.workspaces[existing].created : new Date().toISOString(), + }; + + if (existing >= 0) { + index.workspaces[existing] = entry; + console.log(`Workspace "${name}" already registered — updated path.`); + } else { + index.workspaces.push(entry); + console.log(`Workspace "${name}" registered at ${wsPath}`); + } + + fs.mkdirSync(wsPath, { recursive: true }); + writeIndex(index); + + if (withInstall) { + console.log(`\nRunning install for workspace "${name}"...`); + import('./install.js') + .then(m => m.runInstall(['--instance', name])) + .catch(err => { console.error('Install failed:', err.message); process.exit(1); }); + } else { + console.log(`\nTo install MCP registration for this workspace:\n apra-fleet install --instance ${name}`); + console.log(`\nTo activate in current shell:\n export APRA_FLEET_DATA_DIR="${wsPath}"`); + } +} + +function cmdRemove(args: string[]): void { + const force = args.includes('--force'); + const nameArgs = args.filter(a => !a.startsWith('--')); + if (nameArgs.length === 0) { + console.error('Error: workspace remove requires a name. Usage: apra-fleet workspace remove [--force]'); + process.exit(1); + } + const name = nameArgs[0]; + if (name === 'default') { + console.error('Error: cannot remove the default workspace.'); + process.exit(1); + } + + const index = readIndex(); + const idx = index.workspaces.findIndex(w => w.name === name); + if (idx < 0) { + console.error(`Error: workspace "${name}" not found.`); + process.exit(1); + } + + const ws = index.workspaces[idx]; + const wsPath = ws.path.replace(/^~/, home); + + // Check for members + const members = memberCount(wsPath); + if (members > 0 && !force) { + console.error(`Error: workspace "${name}" has ${members} registered member(s). Use --force to remove anyway.`); + process.exit(1); + } + + index.workspaces.splice(idx, 1); + writeIndex(index); + console.log(`Workspace "${name}" removed from index.`); + console.log(`Data directory preserved at: ${wsPath}`); + console.log(`To also delete the data: rm -rf "${wsPath}"`); +} + +function cmdUse(args: string[]): void { + const nameArgs = args.filter(a => !a.startsWith('--')); + if (nameArgs.length === 0) { + console.error('Error: workspace use requires a name. Usage: apra-fleet workspace use '); + process.exit(1); + } + const name = nameArgs[0]; + + const index = readIndex(); + const workspaces = allWorkspaces(index); + const ws = workspaces.find(w => w.name === name); + if (!ws) { + console.error(`Error: workspace "${name}" not found. Run "apra-fleet workspace list" to see available workspaces.`); + process.exit(1); + } + + const wsPath = ws.path.replace(/^~/, home); + console.log(`# To activate workspace "${name}", run:`); + console.log(`export APRA_FLEET_DATA_DIR="${wsPath}"`); + console.log(`\n# Or eval directly:`); + console.log(`eval "$(apra-fleet workspace use ${name})"`); +} + +function cmdStatus(args: string[]): void { + const nameArgs = args.filter(a => !a.startsWith('--')); + + const index = readIndex(); + const workspaces = allWorkspaces(index); + + const targets = nameArgs.length > 0 + ? workspaces.filter(w => nameArgs.includes(w.name)) + : workspaces; + + if (targets.length === 0) { + console.error(`Error: workspace(s) not found: ${nameArgs.join(', ')}`); + process.exit(1); + } + + for (const ws of targets) { + const wsPath = ws.path.replace(/^~/, home); + const exists = fs.existsSync(wsPath); + const members = exists ? memberCount(wsPath) : 0; + const age = exists ? statuslineAge(wsPath) : '—'; + const saltExists = exists && fs.existsSync(path.join(wsPath, 'salt')); + const active = isActive(ws) ? ' [active]' : ''; + + console.log(`\nWorkspace: ${ws.name}${active}`); + console.log(` Path: ${wsPath}`); + console.log(` Exists: ${exists ? 'yes' : 'no'}`); + console.log(` Members: ${members}`); + console.log(` Status: ${age}`); + console.log(` Salt: ${saltExists ? 'present' : 'missing'}`); + } +} + +export async function runWorkspace(args: string[]): Promise { + const subcommand = args[0]; + const rest = args.slice(1); + + if (!subcommand || subcommand === '--help' || subcommand === '-h') { + console.log(`apra-fleet workspace — manage isolated fleet data directories + +Usage: + apra-fleet workspace list + apra-fleet workspace add [--install] + apra-fleet workspace remove [--force] + apra-fleet workspace use + apra-fleet workspace status [] + +Commands: + list Show all workspaces with member count and active state + add Create workspace ~/.apra-fleet/workspaces/, register in index + --install Also run MCP registration (apra-fleet install --instance ) + remove Remove workspace from index (data dir preserved unless deleted manually) + --force Remove even if members are registered + use Print export command to activate workspace in current shell + status [] Show health: data dir exists, member count, statusline age, salt`); + return; + } + + switch (subcommand) { + case 'list': cmdList(); break; + case 'add': cmdAdd(rest); break; + case 'remove': cmdRemove(rest); break; + case 'use': cmdUse(rest); break; + case 'status': cmdStatus(rest); break; + default: + console.error(`Error: unknown workspace subcommand "${subcommand}". Run "apra-fleet workspace --help" for usage.`); + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index 287033e5..11e936c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,18 +15,25 @@ if (arg === '--help' || arg === '-h') { console.log(`apra-fleet ${serverVersion} Usage: - apra-fleet Start MCP server (stdio) - apra-fleet update Check for and install latest update - apra-fleet update --check Check for update - apra-fleet install Install binary + hooks + statusline + MCP + fleet & PM skills (default) - apra-fleet install --skill all Same as bare install (all skills) - apra-fleet install --skill fleet Install fleet skill only - apra-fleet install --skill pm Install PM skill (also installs fleet — PM depends on fleet) - apra-fleet install --skill none Skip skill installation - apra-fleet install --no-skill Same as --skill none - apra-fleet auth Provide password for pending registration (auto-launched) - apra-fleet --version Print version - apra-fleet --help Show this help`); + apra-fleet Start MCP server (stdio) + apra-fleet update Check for and install latest update + apra-fleet update --check Check for update + apra-fleet install Install binary + hooks + statusline + MCP + fleet & PM skills (default) + apra-fleet install --skill all Same as bare install (all skills) + apra-fleet install --skill fleet Install fleet skill only + apra-fleet install --skill pm Install PM skill (also installs fleet — PM depends on fleet) + apra-fleet install --skill none Skip skill installation + apra-fleet install --no-skill Same as --skill none + apra-fleet install --data-dir Use a custom isolated data directory + apra-fleet install --instance Shorthand: --data-dir ~/.apra-fleet/workspaces/, registers as apra-fleet- + apra-fleet workspace list List all workspaces + apra-fleet workspace add Create and register a named workspace + apra-fleet workspace remove Remove workspace from index + apra-fleet workspace use Print export command to activate workspace + apra-fleet workspace status [] Show workspace health + apra-fleet auth Provide password for pending registration (auto-launched) + apra-fleet --version Print version + apra-fleet --help Show this help`); process.exit(0); } @@ -35,6 +42,10 @@ if (arg === 'install') { import('./cli/install.js') .then(m => m.runInstall(process.argv.slice(3))) .catch(err => { logError('cli', `Install failed: ${err.message}`); process.exit(1); }); +} else if (arg === 'workspace') { + import('./cli/workspace.js') + .then(m => m.runWorkspace(process.argv.slice(3))) + .catch(err => { logError('cli', `Workspace command failed: ${err.message}`); process.exit(1); }); } else if (arg === 'auth') { import('./cli/auth.js') .then(m => m.runAuth(process.argv.slice(3))) diff --git a/src/paths.ts b/src/paths.ts index 040363f0..5a65322a 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -2,3 +2,7 @@ import path from 'node:path'; import os from 'node:os'; export const FLEET_DIR = process.env.APRA_FLEET_DATA_DIR ?? path.join(os.homedir(), '.apra-fleet', 'data'); + +export const APRA_BASE = path.join(os.homedir(), '.apra-fleet'); +export const WORKSPACES_DIR = path.join(APRA_BASE, 'workspaces'); +export const WORKSPACES_INDEX = path.join(APRA_BASE, 'workspaces.json'); diff --git a/tests/install-data-dir.test.ts b/tests/install-data-dir.test.ts new file mode 100644 index 00000000..3c20798d --- /dev/null +++ b/tests/install-data-dir.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execSync, execFileSync } from 'node:child_process'; +import { runInstall } from '../src/cli/install.js'; + +vi.mock('node:os', () => ({ + default: { + homedir: vi.fn(() => '/mock/home'), + platform: vi.fn(() => 'linux'), + } +})); +vi.mock('node:fs'); +vi.mock('node:child_process'); + +const mockHome = '/mock/home'; + +function setupMocks() { + vi.mocked(os.homedir).mockReturnValue(mockHome); + const fileState = new Map(); + + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (ps.includes('version.json')) return true; + if (ps.includes('hooks-config.json')) return true; + if (fileState.has(ps)) return true; + return false; + }); + vi.mocked(fs.readFileSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (fileState.has(ps)) return fileState.get(ps)!; + if (ps.includes('version.json')) return JSON.stringify({ version: '0.1.3' }); + if (ps.includes('hooks-config.json')) return JSON.stringify({ hooks: { PostToolUse: [] } }); + return ''; + }); + vi.mocked(fs.writeFileSync).mockImplementation((p: any, content: any) => { + fileState.set(p.toString(), content.toString()); + }); + vi.mocked(fs.readdirSync).mockReturnValue([] as any); + + return fileState; +} + +describe('runInstall --data-dir / --instance', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupMocks(); + }); + + // --- Claude + --data-dir --- + + it('--data-dir passes -e APRA_FLEET_DATA_DIR to claude mcp add', async () => { + await runInstall(['--data-dir', '/custom/data']); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + const args = addCall![1] as string[]; + expect(args.join(' ')).toContain('-e APRA_FLEET_DATA_DIR=/custom/data'); + expect(args).toContain('apra-fleet'); + }); + + it('--data-dir with equals form works', async () => { + await runInstall(['--data-dir=/my/dir']); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + const args = addCall![1] as string[]; + expect(args.join(' ')).toContain('-e APRA_FLEET_DATA_DIR=/my/dir'); + }); + + it('no --data-dir → no -e env flag in claude mcp add', async () => { + await runInstall([]); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + const args = addCall![1] as string[]; + expect(args.join(' ')).not.toContain('APRA_FLEET_DATA_DIR'); + }); + + // --- Claude + --instance --- + + it('--instance sets server name to apra-fleet-', async () => { + await runInstall(['--instance', 'odm']); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + expect(addCall![1] as string[]).toContain('apra-fleet-odm'); + }); + + it('--instance sets APRA_FLEET_DATA_DIR to workspaces/', async () => { + await runInstall(['--instance', 'myproject']); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + const args = addCall![1] as string[]; + const expectedPath = path.join(mockHome, '.apra-fleet', 'workspaces', 'myproject'); + expect(args.join(' ')).toContain(expectedPath); + }); + + it('--instance equals form works', async () => { + await runInstall(['--instance=proj']); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + expect(addCall![1] as string[]).toContain('apra-fleet-proj'); + }); + + it('--instance removes the old server name before adding new', async () => { + await runInstall(['--instance', 'odm']); + + const calls = vi.mocked(execSync).mock.calls.map(c => c[0].toString()); + const removeCall = calls.find(c => c.includes('mcp remove') && c.includes('apra-fleet-odm')); + expect(removeCall).toBeDefined(); + }); + + it('--instance with invalid chars errors and exits 1', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + + await expect(runInstall(['--instance', 'my instance!'])).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + // --- Instance + workspaces.json registration --- + + it('--instance writes workspaces.json with the new workspace', async () => { + const fileState = new Map(); + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (ps.includes('version.json')) return true; + if (ps.includes('hooks-config.json')) return true; + if (fileState.has(ps)) return true; + return false; + }); + vi.mocked(fs.readFileSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (fileState.has(ps)) return fileState.get(ps)!; + if (ps.includes('version.json')) return JSON.stringify({ version: '0.1.3' }); + if (ps.includes('hooks-config.json')) return JSON.stringify({ hooks: { PostToolUse: [] } }); + return ''; + }); + vi.mocked(fs.writeFileSync).mockImplementation((p: any, content: any) => { + fileState.set(p.toString(), content.toString()); + }); + vi.mocked(fs.readdirSync).mockReturnValue([] as any); + + await runInstall(['--instance', 'odm']); + + const workspacesIndexPath = path.join(mockHome, '.apra-fleet', 'workspaces.json'); + const wsWrite = vi.mocked(fs.writeFileSync).mock.calls.find(c => + c[0].toString() === workspacesIndexPath + ); + expect(wsWrite).toBeDefined(); + const parsed = JSON.parse(wsWrite![1].toString()); + expect(parsed.workspaces).toBeDefined(); + const odm = parsed.workspaces.find((w: any) => w.name === 'odm'); + expect(odm).toBeDefined(); + expect(odm.path).toContain('odm'); + }); + + it('--data-dir alone does NOT write workspaces.json', async () => { + await runInstall(['--data-dir', '/custom/data']); + + const workspacesIndexPath = path.join(mockHome, '.apra-fleet', 'workspaces.json'); + const wsWrite = vi.mocked(fs.writeFileSync).mock.calls.find(c => + c[0].toString() === workspacesIndexPath + ); + expect(wsWrite).toBeUndefined(); + }); + + // --- Gemini + --data-dir / --instance --- + + it('Gemini + --data-dir embeds env in mcpServers config', async () => { + await runInstall(['--llm', 'gemini', '--data-dir', '/custom/data']); + + const geminiSettings = path.join(mockHome, '.gemini', 'settings.json'); + const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c => + c[0].toString().includes(geminiSettings) + ); + expect(writes.length).toBeGreaterThan(0); + const lastContent = writes.at(-1)![1].toString(); + const parsed = JSON.parse(lastContent); + expect(parsed.mcpServers?.['apra-fleet']?.env?.APRA_FLEET_DATA_DIR).toBe('/custom/data'); + }); + + it('Gemini + --instance uses apra-fleet- as server key', async () => { + await runInstall(['--llm', 'gemini', '--instance', 'odm']); + + const geminiSettings = path.join(mockHome, '.gemini', 'settings.json'); + const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c => + c[0].toString().includes(geminiSettings) + ); + const lastContent = writes.at(-1)![1].toString(); + const parsed = JSON.parse(lastContent); + expect(parsed.mcpServers?.['apra-fleet-odm']).toBeDefined(); + expect(parsed.mcpServers?.['apra-fleet']).toBeUndefined(); + }); + + it('Gemini + no --data-dir does NOT embed env', async () => { + await runInstall(['--llm', 'gemini']); + + const geminiSettings = path.join(mockHome, '.gemini', 'settings.json'); + const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c => + c[0].toString().includes(geminiSettings) + ); + const lastContent = writes.at(-1)![1].toString(); + const parsed = JSON.parse(lastContent); + expect(parsed.mcpServers?.['apra-fleet']?.env).toBeUndefined(); + }); + + // --- Permissions use correct server name --- + + it('permissions use mcp__apra-fleet-__* for --instance', async () => { + await runInstall(['--instance', 'odm']); + + const claudeSettings = path.join(mockHome, '.claude', 'settings.json'); + const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c => + c[0].toString().includes(claudeSettings) + ); + const permWrite = writes.find(c => c[1].toString().includes('mcp__apra-fleet')); + expect(permWrite).toBeDefined(); + expect(permWrite![1].toString()).toContain('mcp__apra-fleet-odm__*'); + expect(permWrite![1].toString()).not.toContain('mcp__apra-fleet__*'); + }); + + it('permissions use mcp__apra-fleet__* without --instance', async () => { + await runInstall([]); + + const claudeSettings = path.join(mockHome, '.claude', 'settings.json'); + const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c => + c[0].toString().includes(claudeSettings) + ); + const permWrite = writes.find(c => c[1].toString().includes('mcp__apra-fleet')); + expect(permWrite).toBeDefined(); + expect(permWrite![1].toString()).toContain('mcp__apra-fleet__*'); + }); + + // --- Tilde expansion in --data-dir --- + + it('--data-dir with ~ expands to home dir', async () => { + await runInstall(['--data-dir', '~/custom/data']); + + const calls = vi.mocked(execFileSync).mock.calls; + const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')); + expect(addCall).toBeDefined(); + const argStr = (addCall![1] as string[]).join(' '); + expect(argStr).toContain(`${mockHome}/custom/data`); + expect(argStr).not.toContain('~'); + }); +}); diff --git a/tests/install-multi-provider.test.ts b/tests/install-multi-provider.test.ts index c2679d52..97488eee 100644 --- a/tests/install-multi-provider.test.ts +++ b/tests/install-multi-provider.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { execSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import { parse as parseToml } from 'smol-toml'; import { runInstall } from '../src/cli/install.js'; @@ -59,10 +59,10 @@ describe('runInstall multi-provider', () => { ); // Check if Claude MCP command is run - expect(vi.mocked(execSync)).toHaveBeenCalledWith( - expect.stringContaining('claude mcp add'), - expect.any(Object) + const claudeAddCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add') ); + expect(claudeAddCall).toBeDefined(); }); it('installs for Gemini when --llm gemini is passed', async () => { @@ -76,7 +76,9 @@ describe('runInstall multi-provider', () => { ); // Should NOT run claude mcp add - const claudeCmd = vi.mocked(execSync).mock.calls.find(c => c[0].toString().includes('claude mcp add')); + const claudeCmd = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add') + ); expect(claudeCmd).toBeUndefined(); // Should have written to Gemini settings with trust: true @@ -229,10 +231,14 @@ describe('runInstall multi-provider', () => { it('Claude MCP registration uses --scope user flag', async () => { await runInstall([]); - const calls = vi.mocked(execSync).mock.calls.map(c => c[0].toString()); - const addCall = calls.find(c => c.includes('claude mcp add')); + const addCall = vi.mocked(execFileSync).mock.calls.find( + c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add') + ); expect(addCall).toBeDefined(); - expect(addCall).toContain('--scope user'); + const args = addCall![1] as string[]; + const scopeIdx = args.indexOf('--scope'); + expect(scopeIdx).toBeGreaterThanOrEqual(0); + expect(args[scopeIdx + 1]).toBe('user'); }); it('Gemini MCP registration embeds mcpServers.apra-fleet with trust:true', async () => { diff --git a/tests/update.test.ts b/tests/update.test.ts index 913b3847..a938881f 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -7,7 +7,13 @@ import { runUpdate } from '../src/cli/update.js'; import { serverVersion } from '../src/version.js'; vi.mock('node:fs'); -vi.mock('node:os'); +vi.mock('node:os', () => ({ + default: { + homedir: vi.fn(() => '/mock/home'), + tmpdir: vi.fn(() => '/tmp'), + platform: vi.fn(() => 'linux'), + } +})); vi.mock('node:child_process', () => ({ spawn: vi.fn(() => ({ unref: vi.fn(),