fix(cli-gating): gate the tunnel button instead of deleting it, and cover antigravity

Follow-up to #200 and #201, which gate the welcome buttons and the run-mode
dropdown on whether the CLI is actually installed. Four corrections:

1. #200 also DELETED the Cloudflare Tunnel welcome button and the QR widget
   outright. Its rationale is right (offering a tunnel where cloudflared is not
   installed is a bad default) but the conclusion overshoots: the welcome QR is
   the whole scan-to-connect-from-your-phone flow, and deleting it left a large
   block of live tunnel code in settings-ui.js driving elements that no longer
   existed. Both are restored and the button is gated on cloudflared, which is
   what the stated rationale actually asks for. New cloudflared-resolver.ts
   mirrors the CLI resolvers, and TunnelManager now shares its search path so
   the button and the spawn can never disagree about where cloudflared lives.

2. Antigravity was missing from the run-mode gating, the one run mode LEAST
   likely to be installed. It slipped past because #201 predates it. Covered
   now, plus a static test that fails if a sixth mode reaches the dropdown
   without being gated, so the next one cannot slip the same way.

3. The per-surface fetches are replaced by the injected availability object
   already used for the Codex settings tab, so the codebase has one mechanism
   rather than two. The status routes buy nothing as a gating source: every
   resolver memoizes its PATH probe server-side, so a fetch is exactly as stale
   as an injected value while costing a round trip every time the dropdown opens
   and leaving the welcome buttons to flicker in after paint. The routes
   themselves stay, including the /api/claude/status that #200 adds.

4. Unknown availability now reads as AVAILABLE for run buttons. Both PRs hid the
   button on a failed fetch, so a blip left a working install with nothing to
   click; a genuinely missing CLI only ever produced an error toast. The Codex
   settings TAB keeps the opposite default, since hiding it costs nothing.

The dropdown query is also scoped to the menu: `.run-mode-option` is the class
the saved-dashboard and history rows use too, and a document-wide querySelector
would have found whichever came first in the DOM.

Fixes a latent environment-sensitivity in 816d900 while here: the index-title
test asserted the template was untouched apart from the title, which held only
on a machine with no codex installed.

Verified end-to-end against a real server on an isolated instance+socket, with
Playwright: gemini/codex hidden and claude/opencode/antigravity/shell shown,
matching this host, tunnel button back, Codex settings tab still hidden, no
console errors. Full test:ci sweep green (3902 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-05 01:53:43 +02:00
parent 8facd5e7e7
commit 5d2899907e
10 changed files with 349 additions and 93 deletions
+62 -10
View File
@@ -12,16 +12,40 @@
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js';
import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js';
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
// renderIndexHtml probes the real PATH for `codex`, which would make the codex
// renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine
// running the suite. Default to "not installed" so every OTHER test in this file
// renders exactly as before, and opt in per test.
// running the suite. Default them all to "not installed" and opt in per test.
vi.mock('../src/utils/claude-cli-resolver.js', () => ({
isClaudeAvailable: vi.fn(() => false),
findClaudeDir: vi.fn(() => null),
}));
vi.mock('../src/utils/opencode-cli-resolver.js', () => ({
isOpenCodeAvailable: vi.fn(() => false),
resolveOpenCodeDir: vi.fn(() => null),
}));
vi.mock('../src/utils/codex-cli-resolver.js', () => ({
isCodexAvailable: vi.fn(() => false),
resolveCodexDir: vi.fn(() => null),
}));
vi.mock('../src/utils/gemini-cli-resolver.js', () => ({
isGeminiAvailable: vi.fn(() => false),
resolveGeminiDir: vi.fn(() => null),
}));
vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({
isAntigravityAvailable: vi.fn(() => false),
resolveAntigravityDir: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
}));
const TEMPLATE = [
'<head>',
@@ -96,25 +120,53 @@ describe('WebServer.renderIndexHtml', () => {
expect(html).toContain('gesture-codeman.js');
});
it('exposes codex availability so the App Settings Codex tab can be gated', async () => {
it('reports every tool the welcome buttons, run menu and Codex tab gate on', async () => {
vi.mocked(isClaudeAvailable).mockReturnValue(true);
vi.mocked(isOpenCodeAvailable).mockReturnValue(false);
vi.mocked(isCodexAvailable).mockReturnValue(true);
vi.mocked(isGeminiAvailable).mockReturnValue(false);
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server);
expect(html).toContain('window.__codemanCodexAvailable=true');
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
// Every key must be PRESENT, not merely truthy where installed: the client
// treats a missing key as available, so a dropped key silently un-gates.
expect(flags).toEqual({
claude: true,
opencode: false,
codex: true,
gemini: false,
antigravity: false,
cloudflared: true,
});
});
it('omits the codex flag entirely when the codex binary does not resolve', async () => {
vi.mocked(isCodexAvailable).mockReturnValue(false);
it('still emits the object when nothing at all is installed', async () => {
// The all-false case is the one that matters most and the easiest to get
// wrong by only injecting when something resolves.
for (const probe of [
isClaudeAvailable,
isOpenCodeAvailable,
isCodexAvailable,
isGeminiAvailable,
isAntigravityAvailable,
isCloudflaredAvailable,
]) {
vi.mocked(probe).mockReturnValue(false);
}
const { server } = makeServer({});
const html = await render(server);
expect(html).not.toContain('__codemanCodexAvailable');
expect(html).toContain('window.__codemanCliAvailable=');
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
expect(Object.values(flags).every((v) => v === false)).toBe(true);
});
it('skips the codex probe for a solo window, which has no settings modal', async () => {
it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
vi.mocked(isCodexAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server, 'sess-123');
expect(html).not.toContain('__codemanCodexAvailable');
expect(html).not.toContain('__codemanCliAvailable');
});
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
+103 -2
View File
@@ -296,7 +296,7 @@ describe('Codex quick start settings', () => {
describe('Codex CLI tab visibility', () => {
// Both settings on the tab are handed to `codex` at launch, so on an instance
// where the binary does not resolve the tab is a promise nothing can keep.
// renderIndexHtml injects window.__codemanCodexAvailable; this pins the client
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
// so deleting the call in openAppSettings() is what it is meant to catch.
function loadSettingsUi(codexAvailable: boolean | undefined) {
@@ -313,7 +313,7 @@ describe('Codex quick start settings', () => {
console,
});
context.window = context;
if (codexAvailable !== undefined) context.__codemanCodexAvailable = codexAvailable;
if (codexAvailable !== undefined) context.__codemanCliAvailable = { codex: codexAvailable };
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
return { app: new (CodemanApp as any)(), codexTabBtn };
@@ -345,6 +345,107 @@ describe('Codex quick start settings', () => {
});
});
describe('CLI availability gating (#200/#201)', () => {
// Drives the REAL settings-ui.js + session-ui.js against stub elements, so an
// added run mode that nobody wires up here is what these are meant to catch.
function loadUi(flags: Record<string, boolean> | undefined) {
const CodemanApp = function CodemanApp(this: any) {};
const welcomeBtns: Record<string, { style: { display: string } }> = {};
for (const id of ['welcomeClaudeBtn', 'welcomeOpencodeBtn', 'welcomeGeminiBtn', 'welcomeTunnelBtn']) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
querySelector: (sel: string) => {
const m = sel.match(/data-mode="([^"]+)"/);
return m ? (modeBtns[m[1]] ?? null) : null;
},
};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => welcomeBtns[id] ?? null, querySelector: () => null },
console,
});
context.window = context;
if (flags !== undefined) context.__codemanCliAvailable = flags;
for (const file of ['settings-ui.js', 'session-ui.js']) {
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
vm.runInContext(src, context, { filename: file });
}
return { app: new (CodemanApp as any)(), welcomeBtns, modeBtns, menu };
}
const ALL_OFF = {
claude: false,
opencode: false,
codex: false,
gemini: false,
antigravity: false,
cloudflared: false,
};
it('hides each welcome button whose tool is missing, including the tunnel', () => {
const { app, welcomeBtns } = loadUi({ ...ALL_OFF, claude: true });
app.applyWelcomeCliVisibility();
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
expect(welcomeBtns.welcomeOpencodeBtn.style.display).toBe('none');
expect(welcomeBtns.welcomeGeminiBtn.style.display).toBe('none');
// #200 originally DELETED the tunnel button and its QR outright; it is gated
// on cloudflared instead, so a box that has cloudflared keeps the feature.
expect(welcomeBtns.welcomeTunnelBtn.style.display).toBe('none');
const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
withTunnel.app.applyWelcomeCliVisibility();
expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
app._refreshRunModeAvailability(menu);
expect(modeBtns.claude.style.display).toBe('flex');
expect(modeBtns.antigravity.style.display).toBe('flex');
expect(modeBtns.opencode.style.display).toBe('none');
expect(modeBtns.codex.style.display).toBe('none');
expect(modeBtns.gemini.style.display).toBe('none');
// Shell needs no external CLI, and leaving it alone is what guarantees the
// menu is never empty on a box with nothing installed.
expect(modeBtns.shell.style.display).toBe('PRISTINE');
});
it('gates every mode the run-mode menu actually offers', () => {
// Catches a sixth run mode being added to index.html without being gated,
// which is exactly how antigravity slipped past #201.
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
const menuHtml = html.slice(html.indexOf('id="runModeMenu"'));
const offered = [...menuHtml.slice(0, menuHtml.indexOf('</div>')).matchAll(/data-mode="([^"]+)"/g)].map(
(m) => m[1]
);
expect(offered).toContain('antigravity');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
const gated = fn.slice(0, fn.indexOf('\n },'));
for (const mode of offered.filter((m) => m !== 'shell')) {
expect(gated).toContain(`'${mode}'`);
}
});
it('shows everything when the flags were never injected', () => {
// A cached page from a build without the injection, or a solo popup. Hiding
// every run button on a doubt would leave a working install nothing to click.
const { app, welcomeBtns, modeBtns, menu } = loadUi(undefined);
app.applyWelcomeCliVisibility();
app._refreshRunModeAvailability(menu);
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
expect(modeBtns.gemini.style.display).toBe('flex');
});
});
it('passes global Codex settings into quick-start config for new sessions', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'codex-case' },
+10 -2
View File
@@ -96,8 +96,16 @@ describe('WebServer index.html <title> templating (#82)', () => {
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
// .js/.css refs; strip them so the title remains the only other change.
const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
// .js/.css refs, and injects the CLI-availability flags before </head>; strip
// both so the title remains the only other change.
//
// The flag strip is what keeps this test environment-independent. It used to
// pass here by luck: the availability script was injected only where a CLI
// resolved, so the assertion held on a machine with none installed and would
// have failed on a developer's box that had them.
const html = (await render('laptop'))
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '');
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true);