mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-07 07:59:33 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91cea50d9c |
No files matched your search
@@ -104,6 +104,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
|
||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||
- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason.
|
||||
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||
|
||||
|
||||
+20
-1
@@ -54,6 +54,7 @@ import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -728,6 +729,17 @@ interface GitRun {
|
||||
* signal is used rather than `child.kill()`.
|
||||
*/
|
||||
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as a missing git.
|
||||
if (!gitSpawnAllowed()) {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
started: false,
|
||||
spawnError: String(gitMissingError()),
|
||||
};
|
||||
}
|
||||
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
|
||||
// promise about the whole call, not about git's runtime after some unbounded
|
||||
// wait. A full queue is refused outright rather than queued.
|
||||
@@ -814,10 +826,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
}
|
||||
}
|
||||
|
||||
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
|
||||
/**
|
||||
* Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. The one
|
||||
* exception is a Mac whose only git is Apple's placeholder with no developer tools behind it: running
|
||||
* that, even as `git --version`, opens macOS's "install the developer tools" window, so it is never
|
||||
* run, and that refusal is not memoized here (the gate keeps its own short memory), so the page finds
|
||||
* git once the tools are installed.
|
||||
*/
|
||||
let gitAvailable: boolean | null = null;
|
||||
export function isGitAvailable(): boolean {
|
||||
if (gitAvailable !== null) return gitAvailable;
|
||||
if (!gitSpawnAllowed()) return false;
|
||||
try {
|
||||
execFileSync('git', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
resolveWorkspaceRepos,
|
||||
type GitRunner,
|
||||
} from './git-workspace-status.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -205,6 +206,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu
|
||||
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
|
||||
*/
|
||||
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
|
||||
if (!gitSpawnAllowed()) return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
|
||||
cwd,
|
||||
@@ -240,6 +242,8 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis
|
||||
* as a timeout without starting the fetch.
|
||||
*/
|
||||
export const runFetch: GitRunner = async (cwd, args, opts) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as GIT_MISSING.
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
||||
const startedAt = Date.now();
|
||||
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
|
||||
|
||||
@@ -50,6 +50,7 @@ import { homedir } from 'node:os';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -265,6 +266,8 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||
|
||||
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||
|
||||
@@ -62,6 +62,7 @@ import { homedir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
|
||||
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
// ─── Limits ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -371,6 +372,11 @@ const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=fals
|
||||
*/
|
||||
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
|
||||
new Promise<WorktreeGitResult>((resolveRun) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as "git is not installed".
|
||||
if (!gitSpawnAllowed()) {
|
||||
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(gitMissingError()) });
|
||||
return;
|
||||
}
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', args, {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { PredictionToolCall, WorkspaceSignals } from './readmymind-context.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -150,6 +151,8 @@ const MAX_STATUS_LINES = 30;
|
||||
*/
|
||||
export async function collectWorkspaceSignals(workingDir: string): Promise<WorkspaceSignals | null> {
|
||||
const git = async (args: string[]): Promise<string> => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd: workingDir,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { createRequire } from 'node:module';
|
||||
import { promisify } from 'node:util';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
|
||||
import { gitSpawnAllowed } from '../utils/git-availability.js';
|
||||
import { getInstallInfo } from './self-update.js';
|
||||
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
|
||||
|
||||
@@ -242,6 +243,8 @@ interface GitResult {
|
||||
* spawn would block the event loop for the whole wait.
|
||||
*/
|
||||
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) return { ok: false, stdout: '', stderr: 'git is not installed' };
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
|
||||
@@ -41,6 +41,7 @@ import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { gitSpawnAllowed } from '../utils/git-availability.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
@@ -429,6 +430,8 @@ export function reconcileUpdateOnBoot(now = Date.now()): void {
|
||||
|
||||
/** Run a command, returning trimmed stdout, or null on any error. */
|
||||
function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (cmd === 'git' && !gitSpawnAllowed()) return null;
|
||||
try {
|
||||
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @fileoverview The gate in front of every `git` the server starts (src/utils/git-availability.ts).
|
||||
*
|
||||
* On a Mac without the developer tools, `/usr/bin/git` is Apple's placeholder: running it, even as
|
||||
* `git --version`, opens the system's "install the command line developer tools" window. The server did
|
||||
* exactly that on the first page load after a no-admin install (found 2026-11-06 on a new MacBook: the
|
||||
* system log named the Codeman server as the parent of that `git`).
|
||||
*
|
||||
* Two halves: the pure decision, and a scan of `src/` that fails when a file starts git without going
|
||||
* through the gate, so the next git caller cannot bring the window back.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
APPLE_GIT_PLACEHOLDER,
|
||||
developerDirHoldsGit,
|
||||
findOnPath,
|
||||
gitMissingError,
|
||||
gitSpawnAllowed,
|
||||
gitSpawnWouldPrompt,
|
||||
resetGitGateForTests,
|
||||
} from '../src/utils/git-availability.js';
|
||||
|
||||
const never = (): boolean => {
|
||||
throw new Error('must not be asked');
|
||||
};
|
||||
|
||||
describe('gitSpawnWouldPrompt', () => {
|
||||
it('is true only for Apple’s placeholder on a Mac without developer tools', () => {
|
||||
expect(
|
||||
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => false })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the placeholder run once the developer tools are there', () => {
|
||||
expect(
|
||||
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never asks about developer tools for a git that is not the placeholder', () => {
|
||||
for (const gitPath of ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/nix/store/x/bin/git']) {
|
||||
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath, developerToolsPresent: never })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a Mac with no git on the PATH to the spawn’s own ENOENT', () => {
|
||||
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath: null, developerToolsPresent: never })).toBe(false);
|
||||
});
|
||||
|
||||
it('is never true off macOS, where /usr/bin/git is a real git', () => {
|
||||
for (const platform of ['linux', 'win32', 'freebsd'] as const) {
|
||||
expect(gitSpawnWouldPrompt({ platform, gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: never })).toBe(
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOnPath', () => {
|
||||
const has =
|
||||
(...paths: string[]) =>
|
||||
(p: string) =>
|
||||
paths.includes(p);
|
||||
|
||||
it('returns the first match in PATH order', () => {
|
||||
const exists = has('/opt/homebrew/bin/git', '/usr/bin/git');
|
||||
expect(findOnPath('git', '/opt/homebrew/bin:/usr/bin', exists)).toBe('/opt/homebrew/bin/git');
|
||||
expect(findOnPath('git', '/usr/bin:/opt/homebrew/bin', exists)).toBe('/usr/bin/git');
|
||||
});
|
||||
|
||||
it('skips empty and relative entries, which mean the current directory', () => {
|
||||
const exists = has('git', 'bin/git', '/usr/bin/git');
|
||||
expect(findOnPath('git', ':.:bin:/usr/bin', exists)).toBe('/usr/bin/git');
|
||||
});
|
||||
|
||||
it('is null when nothing matches or there is no PATH', () => {
|
||||
expect(findOnPath('git', '/a:/b', () => false)).toBeNull();
|
||||
expect(findOnPath('git', undefined, () => true)).toBeNull();
|
||||
expect(findOnPath('git', '', () => true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('developerDirHoldsGit', () => {
|
||||
it('needs a git inside the folder xcode-select names', () => {
|
||||
const exists = (p: string) => p === '/Library/Developer/CommandLineTools/usr/bin/git';
|
||||
expect(developerDirHoldsGit('/Library/Developer/CommandLineTools\n', exists)).toBe(true);
|
||||
// A selected Xcode that was deleted since: the placeholder would open its window after all.
|
||||
expect(developerDirHoldsGit('/Applications/Xcode.app/Contents/Developer\n', exists)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses empty and relative answers', () => {
|
||||
expect(developerDirHoldsGit('', () => true)).toBe(false);
|
||||
expect(developerDirHoldsGit(' \n', () => true)).toBe(false);
|
||||
expect(developerDirHoldsGit('relative/dir', () => true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitMissingError', () => {
|
||||
it('reads as the ENOENT every caller already answers with "git is not installed"', () => {
|
||||
const err = gitMissingError();
|
||||
expect(err.code).toBe('ENOENT');
|
||||
expect(err.message).toBe('spawn git ENOENT');
|
||||
expect(String(err)).toMatch(/enoent/i); // git-worktrees.ts and git-clone.ts match the string form
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitSpawnAllowed', () => {
|
||||
it.skipIf(process.platform === 'darwin')('is always true off macOS and does no IO there', () => {
|
||||
resetGitGateForTests();
|
||||
expect(gitSpawnAllowed()).toBe(true);
|
||||
expect(gitSpawnAllowed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scan: no git spawn outside the gate ─────────────────────────────────────
|
||||
|
||||
const SRC = join(__dirname, '..', 'src');
|
||||
|
||||
/** A call that starts `git` as the program (argv form; the server never builds a git shell string). */
|
||||
const GIT_SPAWN = /\b(?:spawn|spawnSync|execFile|execFileSync|execFileAsync|exec|execSync|tryExec)\(\s*['"`]git['"`]/g;
|
||||
|
||||
/**
|
||||
* Files that start git, with how many call sites each has. A new site in one of them, or a new file,
|
||||
* fails here on purpose: add `gitSpawnAllowed()` in front of it (see the module's fileoverview), then
|
||||
* update this table.
|
||||
*/
|
||||
const GATED: Record<string, number> = {
|
||||
'git-clone.ts': 2,
|
||||
'git-fetch.ts': 2,
|
||||
'git-workspace-status.ts': 1,
|
||||
'git-worktrees.ts': 1,
|
||||
'readmymind-collectors.ts': 1,
|
||||
'web/repo-status.ts': 1,
|
||||
'web/self-update.ts': 8,
|
||||
};
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'public' || entry.name === 'node_modules') continue;
|
||||
walk(full, out);
|
||||
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('every git the server starts goes through the gate', () => {
|
||||
const found: Record<string, number> = {};
|
||||
const ungated: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const sites = text.match(GIT_SPAWN)?.length ?? 0;
|
||||
if (!sites) continue;
|
||||
const rel = relative(SRC, file).split(sep).join('/');
|
||||
found[rel] = sites;
|
||||
if (!/\bgitSpawnAllowed\(\)/.test(text)) ungated.push(rel);
|
||||
}
|
||||
|
||||
it('finds the spawn sites at all (the pattern still matches the code)', () => {
|
||||
expect(Object.keys(found).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has no file that starts git without calling gitSpawnAllowed()', () => {
|
||||
expect(ungated).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches the reviewed list of sites, so a new one is looked at', () => {
|
||||
expect(found).toEqual(GATED);
|
||||
});
|
||||
|
||||
it('keeps the gate itself free of git', () => {
|
||||
const gate = readFileSync(join(SRC, 'utils', 'git-availability.ts'), 'utf8');
|
||||
expect(gate.match(GIT_SPAWN)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user