mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
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:
+5
-20
@@ -15,10 +15,8 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { resolveCloudflaredPath } from './utils/cloudflared-resolver.js';
|
||||
import {
|
||||
QR_TOKEN_TTL_MS,
|
||||
QR_TOKEN_GRACE_MS,
|
||||
@@ -95,23 +93,10 @@ export class TunnelManager extends EventEmitter {
|
||||
private resolveCloudflared(): string | null {
|
||||
if (this.cloudflaredPath) return this.cloudflaredPath;
|
||||
|
||||
// Check ~/.local/bin first (common user install location)
|
||||
const localBin = join(homedir(), '.local', 'bin', 'cloudflared');
|
||||
if (existsSync(localBin)) {
|
||||
this.cloudflaredPath = localBin;
|
||||
return localBin;
|
||||
}
|
||||
|
||||
// Check /usr/local/bin
|
||||
const usrLocalBin = '/usr/local/bin/cloudflared';
|
||||
if (existsSync(usrLocalBin)) {
|
||||
this.cloudflaredPath = usrLocalBin;
|
||||
return usrLocalBin;
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
this.cloudflaredPath = 'cloudflared';
|
||||
return 'cloudflared';
|
||||
// Shared with the welcome-screen availability check, so the button and the
|
||||
// spawn can never disagree about where cloudflared lives.
|
||||
this.cloudflaredPath = resolveCloudflaredPath() ?? 'cloudflared';
|
||||
return this.cloudflaredPath;
|
||||
}
|
||||
|
||||
/** Clear all pending timers */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @fileoverview Resolve the `cloudflared` binary across common install paths.
|
||||
*
|
||||
* Mirrors the CLI resolvers (gemini-cli-resolver.ts et al), for the same reason
|
||||
* they exist: the welcome screen should not offer a button whose only possible
|
||||
* outcome is an error toast.
|
||||
*
|
||||
* The search list is deliberately the SAME one `TunnelManager.resolveCloudflared()`
|
||||
* has always used, and that method now delegates here so the two can never drift.
|
||||
* The difference is the fallback: this module answers "is it installed?" honestly
|
||||
* with null, while the tunnel manager keeps falling back to the bare name so a
|
||||
* cloudflared that only exists somewhere on the tunnel process's PATH still
|
||||
* starts. A stricter answer there would turn a working tunnel into a refusal.
|
||||
*
|
||||
* @module utils/cloudflared-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the cloudflared binary may be installed */
|
||||
const CLOUDFLARED_SEARCH_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin'];
|
||||
|
||||
/** Cached path to the cloudflared binary (empty string = searched but not found) */
|
||||
let _cloudflaredPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the `cloudflared` binary.
|
||||
*
|
||||
* @returns Absolute path, or null if not found
|
||||
*/
|
||||
export function resolveCloudflaredPath(): string | null {
|
||||
if (_cloudflaredPath !== null) return _cloudflaredPath || null;
|
||||
|
||||
for (const dir of CLOUDFLARED_SEARCH_DIRS) {
|
||||
const candidate = join(dir, 'cloudflared');
|
||||
if (existsSync(candidate)) {
|
||||
_cloudflaredPath = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync('which cloudflared', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_cloudflaredPath = result;
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not on PATH either.
|
||||
}
|
||||
|
||||
_cloudflaredPath = ''; // mark as searched, not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cloudflared is available on the system.
|
||||
*/
|
||||
export function isCloudflaredAvailable(): boolean {
|
||||
return resolveCloudflaredPath() !== null;
|
||||
}
|
||||
@@ -315,6 +315,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude Code
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-opencode" id="welcomeOpencodeBtn" style="display: none;" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
@@ -324,6 +328,10 @@
|
||||
Run Gemini
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-input-row">
|
||||
|
||||
@@ -441,7 +441,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Load history sessions when menu opens
|
||||
if (menu.classList.contains('active')) {
|
||||
this._loadRunModeHistory();
|
||||
this._refreshRunModeAvailability();
|
||||
this._refreshRunModeAvailability(menu);
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target)) {
|
||||
menu.classList.remove('active');
|
||||
@@ -453,29 +453,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Hides run-mode dropdown entries for CLIs that aren't installed, so
|
||||
* #201: hides run-mode dropdown entries for CLIs that aren't installed, so
|
||||
* picking one doesn't spawn a session that immediately errors out.
|
||||
* Shell has no external CLI dependency and is never gated.
|
||||
*
|
||||
* Shell has no external CLI dependency and is never gated, which is also what
|
||||
* guarantees the menu is never empty. Scoped to `menu` rather than the document:
|
||||
* `.run-mode-option` is also the class the saved-dashboard rows and the history
|
||||
* rows use, and a bare querySelector would find whichever came first in the DOM.
|
||||
*
|
||||
* Antigravity is in this list even though #201 predates it — it is a run mode
|
||||
* like the rest, and `agy` is the LEAST likely of the five to be installed.
|
||||
*/
|
||||
async _refreshRunModeAvailability() {
|
||||
const checks = [
|
||||
['claude', '/api/claude/status'],
|
||||
['opencode', '/api/opencode/status'],
|
||||
['codex', '/api/codex/status'],
|
||||
['gemini', '/api/gemini/status'],
|
||||
];
|
||||
await Promise.all(checks.map(async ([mode, url]) => {
|
||||
const btn = document.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (!btn) return;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const env = await res.json();
|
||||
const status = env?.success === true ? env.data : env;
|
||||
btn.style.display = status?.available ? 'flex' : 'none';
|
||||
} catch {
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
}));
|
||||
_refreshRunModeAvailability(menu) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
|
||||
@@ -497,16 +497,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
* status effects) are passed to `codex` at launch, so on a box without codex
|
||||
* the tab is a promise nothing can keep.
|
||||
*
|
||||
* Availability comes from `window.__codemanCodexAvailable`, injected by
|
||||
* renderIndexHtml (same shape as the gesture flag) so the tab never flickers
|
||||
* Availability comes from the injected `window.__codemanCliAvailable`, shared
|
||||
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
|
||||
* in and back out. Only the tab BUTTON is toggled: the panel already carries
|
||||
* `.modal-tab-content.hidden` unless it is the selected tab, and
|
||||
* openAppSettings() always reopens on Display, so an unreachable button is
|
||||
* enough to keep the panel unreachable.
|
||||
*
|
||||
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
|
||||
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
|
||||
* and are still saved), whereas hiding a run button would leave a working
|
||||
* install with nothing to click.
|
||||
*/
|
||||
_applyCodexSettingsVisibility() {
|
||||
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
|
||||
if (btn) btn.style.display = window.__codemanCodexAvailable ? '' : 'none';
|
||||
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
|
||||
},
|
||||
|
||||
switchSettingsTab(tabName) {
|
||||
@@ -718,28 +723,39 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updatePollTimer = setInterval(poll, 1500);
|
||||
},
|
||||
|
||||
async loadGeminiAvailability() {
|
||||
await this._loadCliAvailability('welcomeGeminiBtn', '/api/gemini/status');
|
||||
/**
|
||||
* Is `tool` installed on the server? Reads `window.__codemanCliAvailable`,
|
||||
* injected by renderIndexHtml (see the comment there for why this is injected
|
||||
* rather than fetched per surface).
|
||||
*
|
||||
* Unknown reads as AVAILABLE. A missing flag means the page was rendered by a
|
||||
* build that predates the injection, or by a solo popup: hiding every run
|
||||
* button on a doubt would leave nothing to click, and the pre-existing failure
|
||||
* mode for a genuinely missing CLI is just an error toast.
|
||||
*/
|
||||
isCliAvailable(tool) {
|
||||
const flags = window.__codemanCliAvailable;
|
||||
if (!flags || typeof flags !== 'object') return true;
|
||||
return flags[tool] !== false;
|
||||
},
|
||||
|
||||
async loadClaudeAvailability() {
|
||||
await this._loadCliAvailability('welcomeClaudeBtn', '/api/claude/status');
|
||||
},
|
||||
|
||||
async loadOpencodeAvailability() {
|
||||
await this._loadCliAvailability('welcomeOpencodeBtn', '/api/opencode/status');
|
||||
},
|
||||
|
||||
async _loadCliAvailability(buttonId, statusUrl) {
|
||||
const btn = document.getElementById(buttonId);
|
||||
if (!btn) return;
|
||||
try {
|
||||
const res = await fetch(statusUrl);
|
||||
const env = await res.json();
|
||||
const status = env?.success === true ? env.data : env;
|
||||
btn.style.display = status?.available ? 'flex' : 'none';
|
||||
} catch {
|
||||
btn.style.display = 'none';
|
||||
/**
|
||||
* #200: show a welcome-screen button only where the thing it launches exists.
|
||||
* The markup ships them hidden, so an old cached page can never flash a button
|
||||
* for a tool this server does not have.
|
||||
*/
|
||||
applyWelcomeCliVisibility() {
|
||||
const buttons = [
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
['welcomeTunnelBtn', 'cloudflared'],
|
||||
];
|
||||
for (const [id, tool] of buttons) {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1195,9 +1195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (overlay) {
|
||||
overlay.classList.add('visible');
|
||||
this.loadTunnelStatus();
|
||||
this.loadClaudeAvailability();
|
||||
this.loadOpencodeAvailability();
|
||||
this.loadGeminiAvailability();
|
||||
this.applyWelcomeCliVisibility();
|
||||
this.loadHistorySessions();
|
||||
this.initSearchPanel();
|
||||
}
|
||||
|
||||
+41
-11
@@ -1288,18 +1288,48 @@ export class WebServer extends EventEmitter {
|
||||
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
|
||||
// show the toggle only when the feature is available, and inject the bundle
|
||||
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
|
||||
// Codex CLI settings tab: the App Settings "Codex CLI" tab only means anything
|
||||
// when the codex binary is actually installed, so expose availability and let
|
||||
// settings-ui.js hide the tab otherwise (same shape as the gesture flag below).
|
||||
// Injected rather than fetched so the tab never flickers in and back out on
|
||||
// modal open. `isCodexAvailable()` memoizes its PATH probe, so this is cheap
|
||||
// per render; installing codex afterwards needs a server restart, exactly like
|
||||
// the /api/codex/status route that backs the Run menu.
|
||||
// Tool availability (#200/#201): the welcome-screen run buttons, the run-mode
|
||||
// dropdown entries and the App Settings "Codex CLI" tab are all offers that a
|
||||
// box without the binary cannot keep — picking one spawns a session that
|
||||
// errors out immediately. One object answers all three.
|
||||
//
|
||||
// INJECTED, not fetched per surface. The `/api/<cli>/status` routes exist and
|
||||
// stay (they mirror each other and are a fine API surface), but as the source
|
||||
// for UI gating they buy nothing: every resolver memoizes its PATH probe on
|
||||
// the server, so a fetch is exactly as stale as an injected value, while
|
||||
// costing a round trip each time the dropdown opens and leaving the welcome
|
||||
// buttons to flicker in after paint. Installing a CLI later needs a server
|
||||
// restart either way. Memoized probes also make this cheap per render.
|
||||
//
|
||||
// Solo popups skip it: no settings modal, no welcome screen, no run menu.
|
||||
if (!soloSessionId) {
|
||||
const { isCodexAvailable } = await import('../utils/codex-cli-resolver.js');
|
||||
if (isCodexAvailable()) {
|
||||
html = html.replace('</head>', `<script>window.__codemanCodexAvailable=true;</script>\n</head>`);
|
||||
}
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
] = await Promise.all([
|
||||
import('../utils/claude-cli-resolver.js'),
|
||||
import('../utils/opencode-cli-resolver.js'),
|
||||
import('../utils/codex-cli-resolver.js'),
|
||||
import('../utils/gemini-cli-resolver.js'),
|
||||
import('../utils/antigravity-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
]);
|
||||
const available = {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
};
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user