mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(cli): preserve invoked cwd for local launcher (#299)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { runCli } from './src/commands/runCli';
|
||||
|
||||
await runCli();
|
||||
@@ -11,6 +11,7 @@ import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
|
||||
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
|
||||
import { bootstrapSession } from '@/agent/sessionFactory';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
function emitReadyIfIdle(props: {
|
||||
queueSize: () => number;
|
||||
@@ -28,13 +29,14 @@ export async function runAgentSession(opts: {
|
||||
agentType: string;
|
||||
startedBy?: 'runner' | 'terminal';
|
||||
}): Promise<void> {
|
||||
const workingDirectory = getInvokedCwd();
|
||||
const initialState: AgentState = {
|
||||
controlledByUser: false
|
||||
};
|
||||
const { session } = await bootstrapSession({
|
||||
flavor: opts.agentType,
|
||||
startedBy: opts.startedBy ?? 'terminal',
|
||||
workingDirectory: process.cwd(),
|
||||
workingDirectory,
|
||||
agentState: initialState
|
||||
});
|
||||
|
||||
@@ -67,7 +69,7 @@ export async function runAgentSession(opts: {
|
||||
];
|
||||
|
||||
const agentSessionId = await backend.newSession({
|
||||
cwd: process.cwd(),
|
||||
cwd: workingDirectory,
|
||||
mcpServers
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readSettings } from '@/persistence'
|
||||
import { configuration } from '@/configuration'
|
||||
import { logger } from '@/ui/logger'
|
||||
import { runtimePath } from '@/projectPath'
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd'
|
||||
import { readWorktreeEnv } from '@/utils/worktreeEnv'
|
||||
import packageJson from '../../package.json'
|
||||
|
||||
@@ -105,7 +106,7 @@ async function reportSessionStarted(sessionId: string, metadata: Metadata): Prom
|
||||
}
|
||||
|
||||
export async function bootstrapSession(options: SessionBootstrapOptions): Promise<SessionBootstrapResult> {
|
||||
const workingDirectory = options.workingDirectory ?? process.cwd()
|
||||
const workingDirectory = options.workingDirectory ?? getInvokedCwd()
|
||||
const startedBy = options.startedBy ?? 'terminal'
|
||||
const sessionTag = options.tag ?? randomUUID()
|
||||
const agentState = options.agentState === undefined ? {} : options.agentState
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Update, UpdateMachineBody } from '@hapi/protocol'
|
||||
import type { RunnerState, Machine, MachineMetadata } from './types'
|
||||
import { RunnerStateSchema, MachineMetadataSchema } from './types'
|
||||
import { backoff } from '@/utils/time'
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd'
|
||||
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
|
||||
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
|
||||
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
|
||||
@@ -77,7 +78,7 @@ export class ApiMachineClient {
|
||||
logger: (msg, data) => logger.debug(msg, data)
|
||||
})
|
||||
|
||||
registerCommonHandlers(this.rpcHandlerManager, process.cwd())
|
||||
registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd())
|
||||
|
||||
this.rpcHandlerManager.registerHandler<PathExistsRequest, PathExistsResponse>('path-exists', async (params) => {
|
||||
const rawPaths = Array.isArray(params?.paths) ? params.paths : []
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { normalizeClaudeSessionModel } from './model';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
export interface StartOptions {
|
||||
model?: string
|
||||
@@ -30,7 +31,7 @@ export interface StartOptions {
|
||||
}
|
||||
|
||||
export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
const workingDirectory = process.cwd();
|
||||
const workingDirectory = getInvokedCwd();
|
||||
const startedBy = options.startedBy ?? 'terminal';
|
||||
|
||||
// Log environment info at startup
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f
|
||||
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
|
||||
|
||||
@@ -21,7 +22,7 @@ export async function runCodex(opts: {
|
||||
resumeSessionId?: string;
|
||||
model?: string;
|
||||
}): Promise<void> {
|
||||
const workingDirectory = process.cwd();
|
||||
const workingDirectory = getInvokedCwd();
|
||||
const startedBy = opts.startedBy ?? 'terminal';
|
||||
|
||||
logger.debug(`[codex] Starting with options: startedBy=${startedBy}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f
|
||||
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
const formatFailureReason = (message: string): string => {
|
||||
const maxLength = 200;
|
||||
@@ -26,7 +27,7 @@ export async function runCursor(opts: {
|
||||
resumeSessionId?: string;
|
||||
model?: string;
|
||||
}): Promise<void> {
|
||||
const workingDirectory = process.cwd();
|
||||
const workingDirectory = getInvokedCwd();
|
||||
const startedBy = opts.startedBy ?? 'terminal';
|
||||
|
||||
logger.debug(`[cursor] Starting with options: startedBy=${startedBy}`);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolveGeminiRuntimeConfig } from './utils/config';
|
||||
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
export async function runGemini(opts: {
|
||||
startedBy?: 'runner' | 'terminal';
|
||||
@@ -21,7 +22,7 @@ export async function runGemini(opts: {
|
||||
permissionMode?: PermissionMode;
|
||||
model?: string;
|
||||
} = {}): Promise<void> {
|
||||
const workingDirectory = process.cwd();
|
||||
const workingDirectory = getInvokedCwd();
|
||||
const startedBy = opts.startedBy ?? 'terminal';
|
||||
|
||||
logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { startOpencodeHookServer } from './utils/startOpencodeHookServer';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
export async function runOpencode(opts: {
|
||||
startedBy?: 'runner' | 'terminal';
|
||||
@@ -19,7 +20,7 @@ export async function runOpencode(opts: {
|
||||
permissionMode?: PermissionMode;
|
||||
resumeSessionId?: string;
|
||||
} = {}): Promise<void> {
|
||||
const workingDirectory = process.cwd();
|
||||
const workingDirectory = getInvokedCwd();
|
||||
const startedBy = opts.startedBy ?? 'terminal';
|
||||
|
||||
logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AcpSdkBackend } from '@/agent/backends/acp';
|
||||
import { buildOpencodeEnv } from './config';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
|
||||
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
@@ -15,7 +16,7 @@ export function createOpencodeBackend(opts: {
|
||||
cwd?: string;
|
||||
}): AcpSdkBackend {
|
||||
const env = buildOpencodeEnv();
|
||||
const args = ['acp', '--cwd', opts.cwd ?? process.cwd()];
|
||||
const args = ['acp', '--cwd', opts.cwd ?? getInvokedCwd()];
|
||||
|
||||
return new AcpSdkBackend({
|
||||
command: 'opencode',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logger } from '@/ui/logger'
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd'
|
||||
import type {
|
||||
TerminalErrorPayload,
|
||||
TerminalExitPayload,
|
||||
@@ -120,7 +121,7 @@ export class TerminalManager {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionPath = this.getSessionPath() ?? process.cwd()
|
||||
const sessionPath = this.getSessionPath() ?? getInvokedCwd()
|
||||
const shell = resolveShell()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { isBunCompiled, projectPath, runtimePath } from '@/projectPath'
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd'
|
||||
import packageJson from '../../package.json'
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ export function getEnvironmentInfo(): Record<string, any> {
|
||||
DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING: process.env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DEBUG: process.env.DEBUG,
|
||||
workingDirectory: process.cwd(),
|
||||
workingDirectory: getInvokedCwd(),
|
||||
processArgv: process.argv,
|
||||
happyDir: configuration?.happyHomeDir,
|
||||
apiUrl: configuration?.apiUrl,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user