mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
Merge remote-tracking branch 'origin/fix/windows-git-bash-preflight' into windows-megafix
This commit is contained in:
@@ -8,10 +8,11 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { styleText } from 'node:util';
|
||||
import { isPluginInstalled, marketplaceDirectory, readPluginVersion } from '../utils/paths.js';
|
||||
import { IS_WINDOWS, isPluginInstalled, marketplaceDirectory, readPluginVersion } from '../utils/paths.js';
|
||||
import { getBunVersion, getUvVersion, isInstallCurrent } from '../install/setup-runtime.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { resolveDataDir } from '../../shared/paths.js';
|
||||
import { checkWindowsGitBash } from '../utils/windows-git-bash-preflight.js';
|
||||
|
||||
type CheckStatus = 'ok' | 'warn' | 'fail';
|
||||
|
||||
@@ -115,7 +116,20 @@ export async function runDoctorCommand(): Promise<void> {
|
||||
required: false, // worker can be intentionally stopped; don't hard-fail
|
||||
});
|
||||
|
||||
// 6. Last recorded install error (surface remediation if present).
|
||||
// 6. Windows Git Bash reachability. All claude-mem hooks run via
|
||||
// `"shell": "bash"`; on Windows, Claude Code resolves that through Git for
|
||||
// Windows with no WSL fallback. No-op on macOS/Linux.
|
||||
if (IS_WINDOWS) {
|
||||
const gitBash = checkWindowsGitBash();
|
||||
checks.push({
|
||||
name: 'Git Bash (Windows)',
|
||||
status: gitBash.ok ? 'ok' : 'fail',
|
||||
detail: gitBash.detail,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Last recorded install error (surface remediation if present).
|
||||
const lastErrorPath = join(dataDir, 'last-install-error.json');
|
||||
if (existsSync(lastErrorPath)) {
|
||||
let detail = `present at ${lastErrorPath}`;
|
||||
|
||||
@@ -177,6 +177,7 @@ import { readJsonSafe } from '../../utils/json-utils.js';
|
||||
import { readFlatSettings } from '../utils/settings.js';
|
||||
import { shutdownWorkerAndWait } from '../../services/install/shutdown-helper.js';
|
||||
import { detectInstalledIDEs } from './ide-detection.js';
|
||||
import { checkWindowsGitBash } from '../utils/windows-git-bash-preflight.js';
|
||||
|
||||
function registerMarketplace(): void {
|
||||
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||
@@ -1967,6 +1968,18 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS
|
||||
}
|
||||
log.info(segments.join(` ${dot} `));
|
||||
|
||||
// All claude-mem hooks run via `"shell": "bash"`; on Windows, Claude Code
|
||||
// resolves that through Git for Windows with no WSL fallback. Surfacing it
|
||||
// here — rather than letting the first hook throw an unbranded error — is
|
||||
// a warning, not a hard stop: the operator may install Git for Windows
|
||||
// after this run and hooks will start working without a reinstall.
|
||||
if (IS_WINDOWS) {
|
||||
const gitBash = checkWindowsGitBash();
|
||||
if (!gitBash.ok) {
|
||||
log.warn(gitBash.detail);
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit --provider flag wins over the trial funnel: never pitch,
|
||||
// email, poll, or override a provider the operator asked for by name.
|
||||
const trialPairing = options.provider ? null : await promptProTrialOptIn(version);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Windows-only preflight for claude-mem's hooks.
|
||||
*
|
||||
* Every hook in plugin/hooks/hooks.json declares `"shell": "bash"`. On
|
||||
* Windows, Claude Code resolves that through a closed chain — verified
|
||||
* against the Claude Code CLI binary, no WSL fallback exists:
|
||||
*
|
||||
* 1. CLAUDE_CODE_GIT_BASH_PATH env var
|
||||
* 2. C:\Program Files\Git\bin\bash.exe
|
||||
* 3. C:\Program Files (x86)\Git\bin\bash.exe
|
||||
* 4. `git` resolved on PATH, then ../../bin/bash.exe relative to it
|
||||
* 5. null — Claude Code throws, and every claude-mem hook throws with it
|
||||
*
|
||||
* A Windows user who satisfied Claude Code's own requirements via PowerShell
|
||||
* (no Git for Windows at all) hits step 5 with a raw, unbranded error. This
|
||||
* module replicates the same chain so claude-mem can detect that case ahead
|
||||
* of time and say so plainly. It does not change hook behavior — see #3605.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { win32 } from 'path';
|
||||
import { lookupWindowsCommand } from '../../shared/spawn.js';
|
||||
|
||||
export const STANDARD_GIT_BASH_PATHS = [
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
||||
];
|
||||
|
||||
export const GIT_BASH_REMEDIATION =
|
||||
'Git Bash not found. claude-mem hooks require bash, and Claude Code resolves it via Git for ' +
|
||||
'Windows on Windows. Install Git for Windows (https://git-scm.com/download/win), or if bash is ' +
|
||||
'installed somewhere non-standard, set CLAUDE_CODE_GIT_BASH_PATH to the full path of bash.exe.';
|
||||
|
||||
/** Filesystem access this check needs, injectable so tests never touch the real FS. */
|
||||
export interface GitBashProbe {
|
||||
fileExists: (path: string) => boolean;
|
||||
/** Resolve `git` on PATH the way Claude Code does — injectable for tests. */
|
||||
lookupGitOnPath: () => string | null;
|
||||
}
|
||||
|
||||
const defaultProbe: GitBashProbe = {
|
||||
fileExists: existsSync,
|
||||
lookupGitOnPath: () => lookupWindowsCommand('git'),
|
||||
};
|
||||
|
||||
export interface GitBashPreflightResult {
|
||||
ok: boolean;
|
||||
/** bash.exe path claude-mem expects Claude Code to resolve, when found. */
|
||||
resolvedPath?: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates Claude Code's Git Bash resolution chain. A no-op on
|
||||
* non-Windows platforms — no probe call is made, since bash is not
|
||||
* Windows-specific there.
|
||||
*/
|
||||
export function checkWindowsGitBash(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
probe: GitBashProbe = defaultProbe,
|
||||
): GitBashPreflightResult {
|
||||
if (platform !== 'win32') {
|
||||
return { ok: true, detail: 'not applicable (non-Windows)' };
|
||||
}
|
||||
|
||||
const override = env.CLAUDE_CODE_GIT_BASH_PATH;
|
||||
if (override && probe.fileExists(override)) {
|
||||
return { ok: true, resolvedPath: override, detail: `CLAUDE_CODE_GIT_BASH_PATH=${override}` };
|
||||
}
|
||||
|
||||
for (const candidate of STANDARD_GIT_BASH_PATHS) {
|
||||
if (probe.fileExists(candidate)) {
|
||||
return { ok: true, resolvedPath: candidate, detail: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
const gitOnPath = probe.lookupGitOnPath();
|
||||
if (gitOnPath) {
|
||||
// Windows paths regardless of the host platform running this check —
|
||||
// win32.join, not the platform-dependent `path` import, so this resolves
|
||||
// correctly under tests on macOS/Linux too.
|
||||
const candidate = win32.join(gitOnPath, '..', '..', 'bin', 'bash.exe');
|
||||
if (probe.fileExists(candidate)) {
|
||||
return { ok: true, resolvedPath: candidate, detail: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, detail: GIT_BASH_REMEDIATION };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
checkWindowsGitBash,
|
||||
GIT_BASH_REMEDIATION,
|
||||
STANDARD_GIT_BASH_PATHS,
|
||||
type GitBashProbe,
|
||||
} from '../../src/npx-cli/utils/windows-git-bash-preflight.js';
|
||||
|
||||
// Windows #3605 (fail-loudly slice) — claude-mem hooks require bash, and on
|
||||
// Windows Claude Code resolves it via a closed chain (CLAUDE_CODE_GIT_BASH_PATH
|
||||
// -> standard Git for Windows paths -> `git` on PATH -> null, no WSL fallback).
|
||||
// This preflight replicates that chain to detect "no Git Bash reachable" ahead
|
||||
// of the first opaque hook throw.
|
||||
|
||||
function probeThatMustNotBeCalled(): GitBashProbe {
|
||||
return {
|
||||
fileExists: () => {
|
||||
throw new Error('fileExists should not be called off-Windows');
|
||||
},
|
||||
lookupGitOnPath: () => {
|
||||
throw new Error('lookupGitOnPath should not be called off-Windows');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('checkWindowsGitBash', () => {
|
||||
it('reports the actionable error when no bash is reachable anywhere in the chain', () => {
|
||||
const probe: GitBashProbe = {
|
||||
fileExists: () => false,
|
||||
lookupGitOnPath: () => null,
|
||||
};
|
||||
|
||||
const result = checkWindowsGitBash('win32', {}, probe);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.detail).toBe(GIT_BASH_REMEDIATION);
|
||||
expect(result.detail).toContain('Git for Windows');
|
||||
expect(result.detail).toContain('CLAUDE_CODE_GIT_BASH_PATH');
|
||||
});
|
||||
|
||||
it('passes when CLAUDE_CODE_GIT_BASH_PATH points at a real file', () => {
|
||||
const probe: GitBashProbe = {
|
||||
fileExists: (path) => path === 'D:\\custom\\bash.exe',
|
||||
lookupGitOnPath: () => {
|
||||
throw new Error('should not fall through to PATH lookup');
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkWindowsGitBash(
|
||||
'win32',
|
||||
{ CLAUDE_CODE_GIT_BASH_PATH: 'D:\\custom\\bash.exe' },
|
||||
probe,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.resolvedPath).toBe('D:\\custom\\bash.exe');
|
||||
});
|
||||
|
||||
it('passes when Git is present at the standard install path', () => {
|
||||
const probe: GitBashProbe = {
|
||||
fileExists: (path) => path === STANDARD_GIT_BASH_PATHS[0],
|
||||
lookupGitOnPath: () => {
|
||||
throw new Error('should not fall through to PATH lookup');
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkWindowsGitBash('win32', {}, probe);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.resolvedPath).toBe(STANDARD_GIT_BASH_PATHS[0]);
|
||||
});
|
||||
|
||||
it('falls through to `git` on PATH and resolves ../../bin/bash.exe relative to it', () => {
|
||||
const probe: GitBashProbe = {
|
||||
fileExists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
lookupGitOnPath: () => 'C:\\Program Files\\Git\\cmd\\git.exe',
|
||||
};
|
||||
|
||||
const result = checkWindowsGitBash('win32', {}, probe);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.resolvedPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
|
||||
});
|
||||
|
||||
it('does not run at all on darwin', () => {
|
||||
const result = checkWindowsGitBash('darwin', {}, probeThatMustNotBeCalled());
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('does not run at all on linux', () => {
|
||||
const result = checkWindowsGitBash('linux', {}, probeThatMustNotBeCalled());
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user