Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 77 additions & 4 deletions src/cli/install.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execSync, execFileSync } from 'node:child_process';
import { serverVersion } from '../version.js';
Expand Down Expand Up @@ -276,6 +277,57 @@ export function killApraFleet(): void {
}
}

export function installGbrain(): void {
const homeDir = os.homedir();
const gbrainDir = path.join(homeDir, 'gbrain');

// Step 1: Check bun is available
try {
execFileSync('bun', ['--version'], { stdio: 'pipe', shell: true });
} catch {
console.warn(' ⚠ gbrain install skipped — bun not found. Install bun first: https://bun.sh');
return;
}

// Step 2: Check if already installed
if (fs.existsSync(gbrainDir)) {
// Already cloned — just verify it works
try {
execFileSync('gbrain', ['--version'], { stdio: 'pipe', shell: true });
console.log(' ✓ gbrain already installed');
return;
} catch {
// Exists but not in PATH — re-link
console.log(' gbrain dir exists, re-linking...');
}
} else {
// Clone
console.log(' Cloning gbrain...');
execFileSync('git', ['clone', 'https://github.com/garrytan/gbrain.git', gbrainDir], { stdio: 'inherit', shell: true });
}

// Step 3: bun install + bun link
console.log(' Running bun install...');
try {
execFileSync('bun', ['install'], { cwd: gbrainDir, stdio: 'inherit', shell: true });
} catch {
// postinstall script fails on Windows — benign, packages are still installed
}
console.log(' Linking gbrain CLI...');
execFileSync('bun', ['link'], { cwd: gbrainDir, stdio: 'inherit', shell: true });

// Step 4: verify
let gbrainVersion = 'installed';
try {
const v = execFileSync('gbrain', ['--version'], { stdio: 'pipe', encoding: 'utf-8', shell: true });
gbrainVersion = (v as string).trim() || 'installed';
} catch {
gbrainVersion = 'linked (restart shell to use gbrain in PATH)';
}
console.log(` ✓ gbrain ${gbrainVersion}`);
console.log(' Next: run `gbrain init` to create your brain database.');
}

export async function runInstall(args: string[]): Promise<void> {
// --help / -h guard — must come first, before any side effects (#142)
if (args.includes('--help') || args.includes('-h')) {
Expand All @@ -292,6 +344,7 @@ Usage:
apra-fleet install --no-skill Same as --skill none
apra-fleet install --force Stop a running server before installing
apra-fleet install --llm <provider> Target LLM provider: claude (default), gemini, codex, copilot
apra-fleet install --with-gbrain Install gbrain alongside fleet (git clone + bun link)
apra-fleet install --help Show this help

Options:
Expand Down Expand Up @@ -359,9 +412,12 @@ Options:
// Parse --force flag
const force = args.includes('--force');

// Parse --with-gbrain flag
const withGbrain = args.includes('--with-gbrain');

// Reject unknown flags to catch typos early
const knownFlagPrefixes = ['--llm=', '--skill='];
const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--help', '-h']);
const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--with-gbrain', '--help', '-h']);
for (const a of args) {
if (knownFlagExact.has(a)) continue;
if (knownFlagPrefixes.some(p => a.startsWith(p))) continue;
Expand All @@ -372,7 +428,8 @@ Options:

const installFleet = skillMode === 'fleet' || skillMode === 'pm' || skillMode === 'all';
const installPm = skillMode === 'pm' || skillMode === 'all';
const totalSteps = (installFleet && installPm) ? 8 : installFleet ? 7 : installPm ? 8 : 6;
const baseSteps = (installFleet && installPm) ? 8 : installFleet ? 7 : installPm ? 8 : 6;
const totalSteps = withGbrain ? baseSteps + 1 : baseSteps;

if (llm === 'gemini' && (installFleet || installPm)) {
console.warn(`\n⚠ Note: Gemini does not support background agents. If you plan to use Gemini as the\n PM/orchestrator, fleet operations will run sequentially (no parallel dispatch).\n For best orchestration performance, consider using Claude. See docs for details.\n`);
Expand Down Expand Up @@ -523,7 +580,7 @@ ${killHint}
// --- Step 8: Install Beads task tracker ---
// shell:true required on Windows — npm global packages install as .cmd wrappers
// that cannot be directly spawned by Node without a shell
console.log(` [${totalSteps}/${totalSteps}] Installing Beads task tracker...`);
console.log(` [${baseSteps}/${totalSteps}] Installing Beads task tracker...`);
try {
// Check if already installed
try {
Expand All @@ -538,6 +595,12 @@ ${killHint}
console.warn(' ⚠ Beads install skipped — npm not available or install failed');
}

// --- Step 9: Install gbrain (optional) ---
if (withGbrain) {
console.log(` [${totalSteps}/${totalSteps}] Installing gbrain...`);
installGbrain();
}

// Finalize permissions
mergePermissions(paths);

Expand All @@ -553,6 +616,16 @@ ${killHint}
beadsVersion = 'not available';
}

let gbrainStatus = '';
if (withGbrain) {
try {
const gv = execFileSync('gbrain', ['--version'], { stdio: 'pipe', encoding: 'utf-8', shell: true });
gbrainStatus = (gv as string).trim() || 'installed';
} catch {
gbrainStatus = 'linked (restart shell to use gbrain in PATH)';
}
}

const instructions = llm === 'claude' ? 'Run /mcp in Claude Code to load the server.' : `Restart ${paths.name} to load the server.`;
const forceNote = force ? '\nRestart Claude Code to reload the MCP server.' : '';
console.log(`
Expand All @@ -561,7 +634,7 @@ Apra Fleet ${serverVersion} installed successfully for ${paths.name}.
Hooks: ${HOOKS_DIR}
Scripts: ${SCRIPTS_DIR}
Settings: ${paths.settingsFile}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''}
Beads: ${beadsVersion}
Beads: ${beadsVersion}${withGbrain ? `\n gbrain: ${gbrainStatus}` : ''}

${instructions}${forceNote}
`);
Expand Down
124 changes: 123 additions & 1 deletion tests/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { runInstall, _setSeaOverride, _setManifestOverride } from '../src/cli/install.js';
import { runInstall, installGbrain, _setSeaOverride, _setManifestOverride } from '../src/cli/install.js';

vi.mock('node:os', () => ({
default: {
Expand Down Expand Up @@ -178,3 +178,125 @@ describe('install step 8 — Beads task tracker', () => {
warnSpy.mockRestore();
});
});

describe('installGbrain()', () => {
const mockHome = '/mock/home';
const gbrainDir = path.join(mockHome, 'gbrain');

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(os.homedir).mockReturnValue(mockHome);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});

it('skips with warning when bun not found', () => {
vi.mocked(execFileSync).mockImplementation((cmd: any) => {
if (cmd === 'bun') throw new Error('bun: command not found');
return undefined as any;
});

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
installGbrain();

const warns = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
expect(warns).toContain('bun not found');

// git clone should not be called
const cloneCall = vi.mocked(execFileSync).mock.calls.find(
c => c[0] === 'git' && Array.isArray(c[1]) && c[1].includes('clone')
);
expect(cloneCall).toBeUndefined();
});

it('skips with "already installed" when gbrain --version succeeds', () => {
// bun --version succeeds; gbrainDir exists; gbrain --version succeeds
vi.mocked(fs.existsSync).mockImplementation((p: any) => p.toString() === gbrainDir);
vi.mocked(execFileSync).mockReturnValue('1.0.0\n' as any);

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
installGbrain();

const logs = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
expect(logs).toContain('already installed');

// git clone should not be called
const cloneCall = vi.mocked(execFileSync).mock.calls.find(
c => c[0] === 'git' && Array.isArray(c[1]) && c[1].includes('clone')
);
expect(cloneCall).toBeUndefined();
});

it('calls git clone when gbrainDir does not exist', () => {
// bun --version succeeds; gbrainDir does NOT exist
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(execFileSync).mockReturnValue(undefined as any);

installGbrain();

const cloneCall = vi.mocked(execFileSync).mock.calls.find(
c => c[0] === 'git' && Array.isArray(c[1]) && c[1].includes('clone')
);
expect(cloneCall).toBeDefined();
expect(cloneCall![1]).toContain(gbrainDir);
});

it('calls bun install and bun link after cloning', () => {
// bun --version succeeds; gbrainDir does NOT exist
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(execFileSync).mockReturnValue(undefined as any);

installGbrain();

const bunInstallCall = vi.mocked(execFileSync).mock.calls.find(
c => c[0] === 'bun' && Array.isArray(c[1]) && c[1][0] === 'install'
);
expect(bunInstallCall).toBeDefined();

const bunLinkCall = vi.mocked(execFileSync).mock.calls.find(
c => c[0] === 'bun' && Array.isArray(c[1]) && c[1][0] === 'link'
);
expect(bunLinkCall).toBeDefined();
});
});

describe('--with-gbrain flag parsing', () => {
it('--with-gbrain is in knownFlagExact (no unknown flag error)', async () => {
// Minimal setup to get past flag validation — we just want to confirm no process.exit(1) for unknown flag
vi.mocked(os.homedir).mockReturnValue('/mock/home');
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;
return false;
});
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
const ps = p.toString();
if (ps.includes('version.json')) return JSON.stringify({ version: '0.1.0' });
if (ps.includes('hooks-config.json')) return JSON.stringify({ hooks: { PostToolUse: [] } });
return '';
});
vi.mocked(fs.readdirSync).mockReturnValue([] as any);
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined as any);
vi.mocked(fs.chmodSync).mockImplementation(() => {});
vi.mocked(fs.copyFileSync).mockImplementation(() => {});
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
_setSeaOverride(false);
_setManifestOverride({ version: '0.1.0', hooks: {}, scripts: {}, skills: {}, fleetSkills: {} });
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(execFileSync).mockReturnValue(undefined as any);

// Should not throw or call process.exit with error
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
await runInstall(['--with-gbrain']);
// process.exit(1) should NOT have been called (unknown flag path)
const errorExits = exitSpy.mock.calls.filter(c => c[0] === 1);
expect(errorExits).toHaveLength(0);

exitSpy.mockRestore();
_setSeaOverride(null);
_setManifestOverride(null);
});
});
Loading