diff --git a/.gemini/skills/offload/scripts/setup.ts b/.gemini/skills/offload/scripts/setup.ts index 13f3c87de0..4c2b18fda0 100644 --- a/.gemini/skills/offload/scripts/setup.ts +++ b/.gemini/skills/offload/scripts/setup.ts @@ -40,39 +40,54 @@ export async function runSetup(env: NodeJS.ProcessEnv = process.env) { const remoteWorkDir = await prompt('Remote Work Directory', `${OFFLOAD_BASE}/workspace`); console.log(`šŸ” Checking state of ${remoteHost}...`); + const envLoader = 'export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"'; + + // Probe remote for existing installations + const ghCheck = spawnSync('ssh', [remoteHost, 'sh -lc "command -v gh"'], { stdio: 'pipe' }); + const tmuxCheck = spawnSync('ssh', [remoteHost, 'sh -lc "command -v tmux"'], { stdio: 'pipe' }); + const geminiCheck = spawnSync('ssh', [remoteHost, `sh -lc "${envLoader} && command -v gemini"`], { stdio: 'pipe' }); + + const hasGH = ghCheck.status === 0; + const hasTmux = tmuxCheck.status === 0; + const hasGemini = geminiCheck.status === 0; // 1. Gemini CLI Isolation Choice - const geminiChoice = await prompt('\nGemini CLI Setup: Use [p]re-existing instance or [i]solated sandbox instance? (Isolated is recommended)', 'i'); - const geminiSetup = geminiChoice.toLowerCase() === 'p' ? 'preexisting' : 'isolated'; + let geminiSetup = 'isolated'; + if (hasGemini) { + const geminiChoice = await prompt(`\nGemini CLI found on remote. Use [p]re-existing instance or [i]solated sandbox instance? (Isolated is recommended)`, 'i'); + geminiSetup = geminiChoice.toLowerCase() === 'p' ? 'preexisting' : 'isolated'; + } else { + console.log('\nšŸ’” Gemini CLI not found on remote. Defaulting to isolated sandbox instance.'); + } // 2. GitHub CLI Isolation Choice - const ghChoice = await prompt('GitHub CLI Setup: Use [p]re-existing instance or [i]solated sandbox instance? (Isolated is recommended)', 'i'); - const ghSetup = ghChoice.toLowerCase() === 'p' ? 'preexisting' : 'isolated'; + let ghSetup = 'isolated'; + if (hasGH) { + const ghChoice = await prompt(`GitHub CLI found on remote. Use [p]re-existing instance or [i]solated sandbox instance? (Isolated is recommended)`, 'i'); + ghSetup = ghChoice.toLowerCase() === 'p' ? 'preexisting' : 'isolated'; + } else { + console.log('šŸ’” GitHub CLI not found on remote. Defaulting to isolated sandbox instance.'); + } const ISOLATED_GEMINI_CONFIG = `${OFFLOAD_BASE}/gemini-cli-config`; const ISOLATED_GH_CONFIG = `${OFFLOAD_BASE}/gh-cli-config`; - console.log(`šŸ” Checking state of ${remoteHost}...`); - // Use a login shell to ensure the same PATH as the interactive user - const ghCheck = spawnSync('ssh', [remoteHost, 'sh -lc "command -v gh"'], { stdio: 'pipe' }); - const tmuxCheck = spawnSync('ssh', [remoteHost, 'sh -lc "command -v tmux"'], { stdio: 'pipe' }); - - if (ghCheck.status !== 0 || tmuxCheck.status !== 0) { + if (!hasGH || !hasTmux) { console.log('\nšŸ“„ System Requirements Check:'); - if (ghCheck.status !== 0) console.log(' āŒ GitHub CLI (gh) is not installed on remote.'); - if (tmuxCheck.status !== 0) console.log(' āŒ tmux is not installed on remote.'); - + if (!hasGH) console.log(' āŒ GitHub CLI (gh) is not installed on remote.'); + if (!hasTmux) console.log(' āŒ tmux is not installed on remote.'); + const shouldProvision = await confirm('\nWould you like Gemini to automatically provision missing requirements?'); if (shouldProvision) { console.log(`šŸš€ Attempting to provision dependencies on ${remoteHost}...`); const osCheck = spawnSync('ssh', [remoteHost, 'uname -s'], { stdio: 'pipe' }); const os = osCheck.stdout.toString().trim(); - + let installCmd = ''; if (os === 'Linux') { - installCmd = 'sudo apt update && sudo apt install -y ' + [ghCheck.status !== 0 ? 'gh' : '', tmuxCheck.status !== 0 ? 'tmux' : ''].filter(Boolean).join(' '); + installCmd = 'sudo apt update && sudo apt install -y ' + [!hasGH ? 'gh' : '', !hasTmux ? 'tmux' : ''].filter(Boolean).join(' '); } else if (os === 'Darwin') { - installCmd = 'brew install ' + [ghCheck.status !== 0 ? 'gh' : '', tmuxCheck.status !== 0 ? 'tmux' : ''].filter(Boolean).join(' '); + installCmd = 'brew install ' + [!hasGH ? 'gh' : '', !hasTmux ? 'tmux' : ''].filter(Boolean).join(' '); } if (installCmd) { @@ -141,8 +156,6 @@ export async function runSetup(env: NodeJS.ProcessEnv = process.env) { const terminalType = await prompt('\nTerminal Automation (iterm2 / terminal / none)', 'iterm2'); // Local Dependencies Install (Isolated) - const envLoader = 'export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"'; - console.log(`\nšŸ“¦ Checking isolated dependencies in ${remoteWorkDir}...`); const checkCmd = `ssh ${remoteHost} ${q(`${envLoader} && [ -x ${remoteWorkDir}/node_modules/.bin/tsx ] && [ -x ${remoteWorkDir}/node_modules/.bin/gemini ]`)}`; const depCheck = spawnSync(checkCmd, { shell: true }); diff --git a/.gemini/skills/offload/tests/matrix.test.ts b/.gemini/skills/offload/tests/matrix.test.ts index 2c8c021da0..bf9e3e2686 100644 --- a/.gemini/skills/offload/tests/matrix.test.ts +++ b/.gemini/skills/offload/tests/matrix.test.ts @@ -33,6 +33,7 @@ describe('Offload Tooling Matrix', () => { vi.spyOn(process, 'chdir').mockImplementation(() => {}); vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => { + if (cmd === 'ssh' && args?.[1]?.includes('command -v')) return { status: 0 } as any; return { status: 0, stdout: Buffer.from('test-meta\n'), stderr: Buffer.from('') } as any; }); diff --git a/.gemini/skills/offload/tests/orchestration.test.ts b/.gemini/skills/offload/tests/orchestration.test.ts index 4c385c4d41..b1ac60228a 100644 --- a/.gemini/skills/offload/tests/orchestration.test.ts +++ b/.gemini/skills/offload/tests/orchestration.test.ts @@ -116,11 +116,11 @@ describe('Offload Orchestration', () => { vi.mocked(readline.createInterface).mockReturnValue(mockInterface as any); }); - it('should correctly detect pre-existing setup', async () => { + it('should correctly detect pre-existing setup when everything is present', async () => { vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => { if (cmd === 'ssh') { const remoteCmd = args[1]; - if (remoteCmd.includes('[ -d ~/test-dir/.git ]')) return { status: 0 } as any; + // Mock dependencies present (gh, tmux, gemini) if (remoteCmd.includes('command -v')) return { status: 0 } as any; if (remoteCmd.includes('gh auth status')) return { status: 0 } as any; if (remoteCmd.includes('google_accounts.json')) return { status: 0 } as any; @@ -131,15 +131,44 @@ describe('Offload Orchestration', () => { mockInterface.question .mockImplementationOnce((q, cb) => cb('test-host')) .mockImplementationOnce((q, cb) => cb('~/test-dir')) - .mockImplementationOnce((q, cb) => cb('p')) - .mockImplementationOnce((q, cb) => cb('p')) + .mockImplementationOnce((q, cb) => cb('p')) // gemini preexisting + .mockImplementationOnce((q, cb) => cb('p')) // gh preexisting .mockImplementationOnce((q, cb) => cb('none')); await runSetup({ HOME: '/test-home' }); - const writeCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => call[0].toString().includes('.gemini/settings.json')); + const writeCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + call[0].toString().includes('.gemini/settings.json') + ); expect(writeCall).toBeDefined(); }); + + it('should default to isolated when dependencies are missing', async () => { + vi.mocked(spawnSync).mockImplementation((cmd: any, args: any) => { + if (cmd === 'ssh') { + const remoteCmd = args[1]; + // Mock dependencies missing + if (remoteCmd.includes('command -v')) return { status: 1 } as any; + } + return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') } as any; + }); + + // Only 3 questions now: host, dir, terminal (gemini/gh choice skipped) + mockInterface.question + .mockImplementationOnce((q, cb) => cb('test-host')) + .mockImplementationOnce((q, cb) => cb('~/test-dir')) + .mockImplementationOnce((q, cb) => cb('y')) // provision requirements + .mockImplementationOnce((q, cb) => cb('none')); + + await runSetup({ HOME: '/test-home' }); + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls.find(call => + call[0].toString().includes('.gemini/settings.json') + ); + const savedSettings = JSON.parse(writeCall![1] as string); + expect(savedSettings.maintainer.deepReview.geminiSetup).toBe('isolated'); + expect(savedSettings.maintainer.deepReview.ghSetup).toBe('isolated'); + }); }); describe('worker.ts (playbooks)', () => {