mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
On a Mac without the developer tools, /usr/bin/git is a placeholder: running it, even as `git --version`, makes macOS open its "install the command line developer tools" window. The server asked "is git here?" exactly that way on the first page load (isGitAvailable() from renderIndexHtml), so a no-admin install on such a Mac ended with that window one second after the server started, and it would have come back at every start. Found on a new MacBook on 2026-11-06: the system log names the Codeman server as the parent of that git process. One gate now stands in front of every git the server starts (src/utils/git-availability.ts). On macOS it resolves git on the PATH itself; when that is the placeholder, `xcode-select -p` must name a developer folder that holds a git of its own. xcode-select only prints and never opens a window. Without the tools, git counts as absent and is never started: every caller gets the ENOENT it already answers with "git is not installed". A refusal is asked again after a minute, so installing the tools later needs no server restart for the git features, and isGitAvailable() no longer memoizes that one refusal. Gated: the clone and worktree runners, the Git panel's status and fetch, the update check and repository status, and Read My Mind's workspace signals. test/git-spawn-gate.test.ts covers the decision and scans src/ for a git spawn in a file that does not call the gate, with the reviewed list of sites pinned. Checked for real on macOS 27 (Command Line Tools) and on a Mac with Xcode: both resolve /usr/bin/git, find the tools and allow git, in 3 to 5 ms. The refusal itself is covered by the unit tests; it still needs a run on a Mac without the tools. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
148 lines
5.9 KiB
TypeScript
148 lines
5.9 KiB
TypeScript
/**
|
|
* @fileoverview The ONE gate in front of every `git` the server starts.
|
|
*
|
|
* macOS ships `/usr/bin/git` as a placeholder. On a Mac without the developer tools (Xcode or the
|
|
* Command Line Tools), running it, even as `git --version`, makes macOS open its "install the command
|
|
* line developer tools" window and exit non-zero. The server used to ask "is git here?" that way on the
|
|
* first page load, so a user who had just installed Codeman without git and without an administrator
|
|
* password got that window one second after the install finished, and again at every login.
|
|
* `install.sh` never runs the placeholder for the same reason.
|
|
*
|
|
* So on macOS, when `git` on the server's PATH resolves to that placeholder, the developer tools are
|
|
* proven first: `xcode-select -p` (a real binary that prints and never opens a window) must name a
|
|
* folder that holds a git of its own. Otherwise git counts as absent and is never started.
|
|
*
|
|
* Every site that starts git calls `gitSpawnAllowed()` first and answers a refusal the way it already
|
|
* answers a machine with no git (`gitMissingError()` carries `code: 'ENOENT'` for that).
|
|
* `test/git-spawn-gate.test.ts` fails on a git spawn in a file that does not go through this gate.
|
|
*
|
|
* The decision is pure (`gitSpawnWouldPrompt`, `findOnPath`); the IO around it is two bounded steps, a
|
|
* PATH scan and one `xcode-select -p` (a few milliseconds, capped at 3 s), and only on macOS. A "yes" is
|
|
* kept for the life of the process; a "no" is asked again after `GIT_GATE_NEGATIVE_TTL_MS`, so
|
|
* installing the developer tools later needs no server restart for the git features (the page's own
|
|
* "git is available" flag stays memoized per process, like every other availability flag there).
|
|
*
|
|
* @module utils/git-availability
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { realpathSync } from 'node:fs';
|
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
import { isExecutableRegularFile } from './cli-executable-resolver.js';
|
|
|
|
/** Apple's placeholder. A real git (Homebrew, MacPorts, Nix) sits elsewhere on the PATH. */
|
|
export const APPLE_GIT_PLACEHOLDER = '/usr/bin/git';
|
|
|
|
const XCODE_SELECT = '/usr/bin/xcode-select';
|
|
const XCODE_SELECT_TIMEOUT_MS = 3_000;
|
|
|
|
/** How long a refusal is kept before the machine is looked at again. */
|
|
export const GIT_GATE_NEGATIVE_TTL_MS = 60_000;
|
|
|
|
/**
|
|
* The first `name` on a PATH-style list that `isExecutable` accepts, or null. Pure.
|
|
* Empty and relative entries are skipped: they mean "the current directory" of whoever runs the
|
|
* command, which this lookup cannot know.
|
|
*/
|
|
export function findOnPath(
|
|
name: string,
|
|
pathEnv: string | undefined,
|
|
isExecutable: (path: string) => boolean
|
|
): string | null {
|
|
for (const dir of (pathEnv ?? '').split(delimiter)) {
|
|
if (!dir || !isAbsolute(dir)) continue;
|
|
const candidate = join(dir, name);
|
|
if (isExecutable(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface GitGateFacts {
|
|
platform: NodeJS.Platform;
|
|
/** What `git` resolves to on the server's PATH (symlinks followed), or null when there is none. */
|
|
gitPath: string | null;
|
|
/** Asked only when it decides the answer, so other machines never pay for it. */
|
|
developerToolsPresent: () => boolean;
|
|
}
|
|
|
|
/**
|
|
* Would starting `git` open macOS's "install the developer tools" window? Pure.
|
|
* Only Apple's placeholder does that, and only while no developer tools are installed. No git on the
|
|
* PATH at all is not this function's case: the spawn then fails with ENOENT, which every caller handles.
|
|
*/
|
|
export function gitSpawnWouldPrompt(facts: GitGateFacts): boolean {
|
|
if (facts.platform !== 'darwin') return false;
|
|
if (facts.gitPath !== APPLE_GIT_PLACEHOLDER) return false;
|
|
return !facts.developerToolsPresent();
|
|
}
|
|
|
|
/**
|
|
* The developer folder `xcode-select -p` names must hold a git of its own (`usr/bin/git`, true for both
|
|
* Xcode and the Command Line Tools). A selected folder that has since been deleted would otherwise read
|
|
* as "tools present" and the placeholder would open its window after all.
|
|
*/
|
|
export function developerDirHoldsGit(developerDir: string, isExecutable: (path: string) => boolean): boolean {
|
|
const dir = developerDir.trim();
|
|
if (!dir || !isAbsolute(dir)) return false;
|
|
return isExecutable(join(dir, 'usr', 'bin', 'git'));
|
|
}
|
|
|
|
function resolveGitOnPath(): string | null {
|
|
const found = findOnPath('git', process.env.PATH, isExecutableRegularFile);
|
|
if (!found) return null;
|
|
try {
|
|
return realpathSync(found);
|
|
} catch {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
function developerToolsPresent(): boolean {
|
|
try {
|
|
const out = execFileSync(XCODE_SELECT, ['-p'], {
|
|
encoding: 'utf8',
|
|
timeout: XCODE_SELECT_TIMEOUT_MS,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return developerDirHoldsGit(out, isExecutableRegularFile);
|
|
} catch {
|
|
return false; // exit 2: no developer folder. Never the placeholder's window.
|
|
}
|
|
}
|
|
|
|
let allowed: boolean | null = null;
|
|
let refusedAt = 0;
|
|
|
|
/**
|
|
* May the server start `git` right now? False only on a Mac whose only git is Apple's placeholder with
|
|
* no developer tools behind it. Callers answer false as "git is not installed".
|
|
*/
|
|
export function gitSpawnAllowed(): boolean {
|
|
if (allowed === true) return true;
|
|
if (allowed === false && Date.now() - refusedAt < GIT_GATE_NEGATIVE_TTL_MS) return false;
|
|
const platform = process.platform;
|
|
allowed = !gitSpawnWouldPrompt({
|
|
platform,
|
|
gitPath: platform === 'darwin' ? resolveGitOnPath() : null,
|
|
developerToolsPresent,
|
|
});
|
|
if (!allowed) refusedAt = Date.now();
|
|
return allowed;
|
|
}
|
|
|
|
/** The error a refused spawn stands in for: what Node raises when there is no `git` to start. */
|
|
export function gitMissingError(): NodeJS.ErrnoException {
|
|
const err: NodeJS.ErrnoException = new Error('spawn git ENOENT');
|
|
err.code = 'ENOENT';
|
|
err.errno = -2;
|
|
err.syscall = 'spawn git';
|
|
err.path = 'git';
|
|
return err;
|
|
}
|
|
|
|
/** Test seam: forget the remembered answer. */
|
|
export function resetGitGateForTests(): void {
|
|
allowed = null;
|
|
refusedAt = 0;
|
|
}
|