fix(cli): preserve invoked cwd for local launcher (#299)

This commit is contained in:
ROOOO
2026-03-17 22:55:23 +08:00
committed by GitHub
parent 1691100328
commit caa76826f4
16 changed files with 127 additions and 16 deletions
+9
View File
@@ -0,0 +1,9 @@
import { isAbsolute } from 'node:path';
export function getInvokedCwd(): string {
const invokedCwd = process.env.HAPI_INVOKED_CWD?.trim();
if (invokedCwd && isAbsolute(invokedCwd)) {
return invokedCwd;
}
return process.cwd();
}
+51
View File
@@ -12,6 +12,7 @@ vi.mock('child_process', async () => {
});
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
const originalInvokedCwd = process.env.HAPI_INVOKED_CWD;
function setPlatform(value: string) {
Object.defineProperty(process, 'platform', {
@@ -39,6 +40,11 @@ describe('spawnHappyCLI windowsHide behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
if (originalInvokedCwd === undefined) {
delete process.env.HAPI_INVOKED_CWD;
} else {
process.env.HAPI_INVOKED_CWD = originalInvokedCwd;
}
});
afterAll(() => {
@@ -88,4 +94,49 @@ describe('spawnHappyCLI windowsHide behavior', () => {
expect(options.detached).toBe(true);
expect('windowsHide' in options).toBe(false);
});
it('forces Bun child processes to run with the cli project root as cwd', async () => {
const { getHappyCliCommand } = await import('./spawnHappyCLI');
const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']);
const isBunRuntime = Boolean((process.versions as Record<string, string | undefined>).bun);
expect(command.command).toBe(process.execPath);
if (isBunRuntime) {
expect(command.args[0]).toBe('--cwd');
expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/hapi\/cli$/);
expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/hapi\/cli\/src\/index\.ts$/);
} else {
expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/hapi/cli/src/index.ts'))).toBe(true);
}
});
it('passes invoked workspace cwd to child processes when cwd is provided', async () => {
const { spawnHappyCLI } = await import('./spawnHappyCLI');
const childCwd = 'C:\\workspace\\project';
spawnHappyCLI(['runner', 'start-sync'], {
cwd: childCwd,
stdio: 'ignore'
});
const options = getSpawnOptionsOrThrow();
expect(options.env?.HAPI_INVOKED_CWD).toBe(childCwd);
});
it('keeps an existing absolute HAPI_INVOKED_CWD when provided explicitly', async () => {
const { spawnHappyCLI } = await import('./spawnHappyCLI');
const inheritedInvokedCwd = 'C:\\workspace\\other-project';
spawnHappyCLI(['runner', 'start-sync'], {
cwd: 'C:\\workspace\\project',
env: {
HAPI_INVOKED_CWD: inheritedInvokedCwd
},
stdio: 'ignore'
});
const options = getSpawnOptionsOrThrow();
expect(options.env?.HAPI_INVOKED_CWD).toBe(inheritedInvokedCwd);
});
});
+36 -3
View File
@@ -26,7 +26,8 @@
*/
import { spawn, SpawnOptions, type ChildProcess } from 'child_process';
import { join } from 'node:path';
import { join, isAbsolute, resolve, win32 } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isBunCompiled, projectPath } from '@/projectPath';
import { logger } from '@/ui/logger';
import { existsSync } from 'node:fs';
@@ -48,6 +49,28 @@ export interface HappyCliCommand {
args: string[];
}
function isCrossPlatformAbsolutePath(value: string): boolean {
return isAbsolute(value) || win32.isAbsolute(value);
}
function resolveInvokedCwd(cwd: SpawnOptions['cwd']): string {
if (cwd instanceof URL) {
return fileURLToPath(cwd);
}
if (typeof cwd === 'string' && cwd.trim().length > 0) {
const normalizedCwd = cwd.trim();
return isCrossPlatformAbsolutePath(normalizedCwd) ? normalizedCwd : resolve(normalizedCwd);
}
const inheritedInvokedCwd = process.env.HAPI_INVOKED_CWD?.trim();
if (inheritedInvokedCwd && isCrossPlatformAbsolutePath(inheritedInvokedCwd)) {
return inheritedInvokedCwd;
}
return process.cwd();
}
export function getHappyCliCommand(args: string[]): HappyCliCommand {
// Compiled binary mode: just use the executable directly
if (isBunCompiled()) {
@@ -63,10 +86,12 @@ export function getHappyCliCommand(args: string[]): HappyCliCommand {
const isBunRuntime = Boolean((process.versions as Record<string, string | undefined>).bun);
if (isBunRuntime) {
// Bun can run TypeScript directly
// Bun can run TypeScript directly.
// Force Bun's cwd to the CLI project root so alias resolution via bunfig.toml
// keeps working even when external tools launch HAPI from another workspace.
return {
command: process.execPath,
args: [entrypoint, ...args]
args: ['--cwd', projectRoot, entrypoint, ...args]
};
}
@@ -108,6 +133,14 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child
// On Windows, detached processes allocate a new console window by default.
// windowsHide: true suppresses this to prevent cmd windows from accumulating.
const finalOptions: SpawnOptions = { ...options };
if (!isBunCompiled()) {
const finalEnv = { ...process.env, ...options.env };
const invokedCwd = finalEnv.HAPI_INVOKED_CWD?.trim();
finalEnv.HAPI_INVOKED_CWD = invokedCwd && isCrossPlatformAbsolutePath(invokedCwd)
? invokedCwd
: resolveInvokedCwd(options.cwd);
finalOptions.env = finalEnv;
}
if (process.platform === 'win32' && options.detached) {
finalOptions.windowsHide = true;
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path';
import type { WorktreeInfo } from '@/runner/worktree';
import { logger } from '@/ui/logger';
import { getInvokedCwd } from '@/utils/invokedCwd';
export function readWorktreeEnv(): WorktreeInfo | null {
return readWorktreeFromEnv() ?? readWorktreeFromGit();
@@ -39,7 +40,7 @@ function readWorktreeFromGit(): WorktreeInfo | null {
let result: WorktreeInfo | null = null;
try {
const cwd = process.cwd();
const cwd = getInvokedCwd();
const isInside = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
if (isInside !== 'true') {
return null;