fix(runtime): harden local launch plumbing
This commit is contained in:
parent
6fb0c714ef
commit
5551eea482
9 changed files with 191 additions and 1 deletions
|
|
@ -435,6 +435,10 @@ export class FileWatcher extends EventEmitter {
|
||||||
// Guard: stop() may have been called while awaiting pathExists
|
// Guard: stop() may have been called while awaiting pathExists
|
||||||
if (!this.isWatching) return;
|
if (!this.isWatching) return;
|
||||||
|
|
||||||
|
// Teams deliberately use TeamTaskWatchRegistry instead of recursive fs.watch.
|
||||||
|
// Linux recursive watching expands across the whole team runtime tree and can
|
||||||
|
// hit EMFILE/ENOSPC. The registry keeps the watched surface aligned with
|
||||||
|
// processTeamsChange(): team root JSON files plus inbox JSON files only.
|
||||||
const registry = new TeamTaskWatchRegistry({
|
const registry = new TeamTaskWatchRegistry({
|
||||||
kind: 'teams',
|
kind: 'teams',
|
||||||
rootPath: this.teamsPath,
|
rootPath: this.teamsPath,
|
||||||
|
|
@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
// Guard: stop() may have been called while awaiting pathExists
|
// Guard: stop() may have been called while awaiting pathExists
|
||||||
if (!this.isWatching) return;
|
if (!this.isWatching) return;
|
||||||
|
|
||||||
|
// Tasks share the same shallow registry rule as teams. Keep polling out of
|
||||||
|
// the normal path here; it is only the known-error fallback below.
|
||||||
const registry = new TeamTaskWatchRegistry({
|
const registry = new TeamTaskWatchRegistry({
|
||||||
kind: 'tasks',
|
kind: 'tasks',
|
||||||
rootPath: this.tasksPath,
|
rootPath: this.tasksPath,
|
||||||
|
|
@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter {
|
||||||
error: unknown,
|
error: unknown,
|
||||||
watcher?: CloseableWatcher
|
watcher?: CloseableWatcher
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// Polling fallback is intentionally narrow. Projects/todos keep their native
|
||||||
|
// watcher retry behavior, while teams/tasks can switch to scoped polling only
|
||||||
|
// after known OS watcher-limit or platform errors from Chokidar/fs.watch.
|
||||||
if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) {
|
if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
};
|
};
|
||||||
|
|
||||||
runPoll();
|
runPoll();
|
||||||
|
// This is fallback content polling after watcher failure, not the default mode.
|
||||||
|
// Keep intervals conservative and scoped to the same shallow artifacts as the registry.
|
||||||
const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType));
|
const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType));
|
||||||
timer.unref();
|
timer.unref();
|
||||||
|
|
||||||
|
|
@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
const snapshot = new Map<string, string>();
|
const snapshot = new Map<string, string>();
|
||||||
const teamEntries = await this.safeReadDir(this.teamsPath);
|
const teamEntries = await this.safeReadDir(this.teamsPath);
|
||||||
|
|
||||||
|
// Fallback polling mirrors TeamTaskWatchRegistry. Do not recurse into members,
|
||||||
|
// runtime, .opencode-runtime, logs, or other deep trees from here.
|
||||||
for (const teamEntry of teamEntries) {
|
for (const teamEntry of teamEntries) {
|
||||||
if (!teamEntry.isDirectory()) {
|
if (!teamEntry.isDirectory()) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
const snapshot = new Map<string, string>();
|
const snapshot = new Map<string, string>();
|
||||||
const teamEntries = await this.safeReadDir(this.tasksPath);
|
const teamEntries = await this.safeReadDir(this.tasksPath);
|
||||||
|
|
||||||
|
// Keep task fallback scoped to tasks/<team>/*.json. Hidden files and nested
|
||||||
|
// runtime directories are intentionally outside the public team-change surface.
|
||||||
for (const teamEntry of teamEntries) {
|
for (const teamEntry of teamEntries) {
|
||||||
if (!teamEntry.isDirectory()) {
|
if (!teamEntry.isDirectory()) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep this classifier in lockstep with TeamTaskWatchRegistry.shouldEmit().
|
||||||
|
// If a path is emitted by the registry but ignored here, the UI will miss it.
|
||||||
|
// If a path is added here but not emitted there, Chokidar mode will never see it.
|
||||||
if (relative === 'processes.json') {
|
if (relative === 'processes.json') {
|
||||||
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
|
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
|
||||||
this.emit('team-change', event);
|
this.emit('team-change', event);
|
||||||
|
|
@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep this in sync with the tasks registry and fallback polling filters:
|
||||||
|
// only tasks/<team>/*.json is a user-visible task event.
|
||||||
// Ignore known non-task files in ~/.claude/tasks
|
// Ignore known non-task files in ~/.claude/tasks
|
||||||
if (
|
if (
|
||||||
relative === '.lock' ||
|
relative === '.lock' ||
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(launchSpec.env ?? {})) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
env[MCP_COMMAND_ENV] = command;
|
env[MCP_COMMAND_ENV] = command;
|
||||||
env[MCP_ENTRY_ENV] = entry;
|
env[MCP_ENTRY_ENV] = entry;
|
||||||
env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args);
|
env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args);
|
||||||
|
|
|
||||||
|
|
@ -1095,6 +1095,7 @@ export class AgentTeamsMcpHttpServer {
|
||||||
};
|
};
|
||||||
const childEnv = applyAgentTeamsIdentityEnv({
|
const childEnv = applyAgentTeamsIdentityEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
|
...launchSpec.env,
|
||||||
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
||||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,
|
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
|
||||||
export interface McpLaunchSpec {
|
export interface McpLaunchSpec {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpLaunchSpecResolveProgress {
|
export interface McpLaunchSpecResolveProgress {
|
||||||
|
|
@ -40,6 +41,7 @@ interface WriteMcpConfigOptions {
|
||||||
const MCP_SERVER_NAME = 'agent-teams';
|
const MCP_SERVER_NAME = 'agent-teams';
|
||||||
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||||
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
|
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
|
||||||
|
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
|
||||||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||||
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
||||||
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
||||||
|
|
@ -58,6 +60,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro
|
||||||
|
|
||||||
function isPackagedApp(): boolean {
|
function isPackagedApp(): boolean {
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { app } = require('electron') as typeof import('electron');
|
const { app } = require('electron') as typeof import('electron');
|
||||||
return app.isPackaged;
|
return app.isPackaged;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -88,6 +91,25 @@ function getWorkspaceRoot(): string {
|
||||||
return process.cwd();
|
return process.cwd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUsePackagedElectronNodeRuntime(): boolean {
|
||||||
|
return (
|
||||||
|
isPackagedApp() &&
|
||||||
|
process.platform === 'linux' &&
|
||||||
|
typeof process.execPath === 'string' &&
|
||||||
|
process.execPath.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [entry],
|
||||||
|
env: {
|
||||||
|
[ELECTRON_RUN_AS_NODE_ENV]: '1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkspaceMcpServerDir(): string {
|
function getWorkspaceMcpServerDir(): string {
|
||||||
return path.join(getWorkspaceRoot(), 'mcp-server');
|
return path.join(getWorkspaceRoot(), 'mcp-server');
|
||||||
}
|
}
|
||||||
|
|
@ -450,6 +472,14 @@ export async function resolveAgentTeamsMcpLaunchSpec(
|
||||||
const packagedEntry = await resolvePackagedServerEntry(options);
|
const packagedEntry = await resolvePackagedServerEntry(options);
|
||||||
checked.push(packagedEntry);
|
checked.push(packagedEntry);
|
||||||
if (await pathExists(packagedEntry)) {
|
if (await pathExists(packagedEntry)) {
|
||||||
|
if (shouldUsePackagedElectronNodeRuntime()) {
|
||||||
|
emitProgress(
|
||||||
|
options,
|
||||||
|
'electron-node-runtime-found',
|
||||||
|
'Using bundled Electron Node runtime...'
|
||||||
|
);
|
||||||
|
return buildPackagedElectronNodeLaunchSpec(packagedEntry);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: await resolveNodePath(options),
|
command: await resolveNodePath(options),
|
||||||
args: [packagedEntry],
|
args: [packagedEntry],
|
||||||
|
|
@ -520,6 +550,7 @@ export class TeamMcpConfigBuilder {
|
||||||
args: launchSpec.args,
|
args: launchSpec.args,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
env: {
|
env: {
|
||||||
|
...launchSpec.env,
|
||||||
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
|
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
|
||||||
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
|
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,20 @@ function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindows
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
|
||||||
|
const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1];
|
||||||
|
if (!nativeTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolveCmdPathTemplate(nativeTarget, launcherDir);
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: target, argsPrefix: [] };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some Windows launchers are thin wrappers around a real JS entrypoint.
|
* Some Windows launchers are thin wrappers around a real JS entrypoint.
|
||||||
* Running that entrypoint directly with an argv array avoids cmd.exe's
|
* Running that entrypoint directly with an argv array avoids cmd.exe's
|
||||||
|
|
@ -245,7 +259,9 @@ function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher
|
||||||
const content = readFileSync(binaryPath, 'utf8');
|
const content = readFileSync(binaryPath, 'utf8');
|
||||||
const launcherDir = path.dirname(binaryPath);
|
const launcherDir = path.dirname(binaryPath);
|
||||||
return (
|
return (
|
||||||
resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir)
|
resolveGeneratedBunLauncher(content, launcherDir) ??
|
||||||
|
resolveNpmNodeShim(content, launcherDir) ??
|
||||||
|
resolveNpmNativeShim(content, launcherDir)
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,25 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('propagates Agent Teams MCP launch env overrides for OpenCode provider commands', async () => {
|
||||||
|
resolveAgentTeamsMcpLaunchSpecMock.mockResolvedValue({
|
||||||
|
command: '/opt/Agent Teams AI/agent-teams-ai',
|
||||||
|
args: ['/app/mcp-server/index.js'],
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
});
|
||||||
|
const { buildProviderAwareCliEnv } =
|
||||||
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
|
||||||
|
const result = await buildProviderAwareCliEnv({
|
||||||
|
providerId: 'opencode',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.env.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||||
|
expect(result.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe(
|
||||||
|
'/opt/Agent Teams AI/agent-teams-ai'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves explicit local Agent Teams MCP launch env for OpenCode provider commands', async () => {
|
it('preserves explicit local Agent Teams MCP launch env for OpenCode provider commands', async () => {
|
||||||
const { buildProviderAwareCliEnv } =
|
const { buildProviderAwareCliEnv } =
|
||||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
}),
|
}),
|
||||||
allocatePort: async () => 41001,
|
allocatePort: async () => 41001,
|
||||||
spawnProcess,
|
spawnProcess,
|
||||||
|
|
@ -167,6 +168,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
'/mcp',
|
'/mcp',
|
||||||
],
|
],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
ELECTRON_RUN_AS_NODE: '1',
|
||||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
|
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
|
||||||
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
||||||
|
|
@ -521,6 +523,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
allocatePort,
|
allocatePort,
|
||||||
spawnProcess,
|
spawnProcess,
|
||||||
waitForPort,
|
waitForPort,
|
||||||
|
canListenOnPort: async () => true,
|
||||||
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
|
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ vi.mock('@main/utils/shellEnv', async (importOriginal) => {
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearResolvedNodePathForTests,
|
clearResolvedNodePathForTests,
|
||||||
|
resolveAgentTeamsMcpLaunchSpec,
|
||||||
TeamMcpConfigBuilder,
|
TeamMcpConfigBuilder,
|
||||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||||
|
|
@ -354,6 +355,53 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
|
expect(hoisted.resolveInteractiveShellEnvMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the packaged Electron Node runtime for Linux packaged MCP launches', async () => {
|
||||||
|
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
|
||||||
|
const electronBinary = '/opt/Agent Teams AI/agent-teams-ai';
|
||||||
|
setPackagedMode(true, '3.0.0');
|
||||||
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
|
createdDirs.push(resourcesDir);
|
||||||
|
createPackagedServerBundle(resourcesDir, '// packaged linux server');
|
||||||
|
setResourcesPath(resourcesDir);
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: 'linux',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process, 'execPath', {
|
||||||
|
value: electronBinary,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
const server = readGeneratedServer(configPath);
|
||||||
|
const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js');
|
||||||
|
|
||||||
|
expect(launchSpec).toEqual({
|
||||||
|
command: electronBinary,
|
||||||
|
args: [expectedEntry],
|
||||||
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
});
|
||||||
|
expect(server?.command).toBe(electronBinary);
|
||||||
|
expect(server?.args).toEqual([expectedEntry]);
|
||||||
|
expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||||
|
expect(hoisted.execCliMock).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
if (platformDescriptor) {
|
||||||
|
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||||
|
}
|
||||||
|
if (execPathDescriptor) {
|
||||||
|
Object.defineProperty(process, 'execPath', execPathDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => {
|
it('falls back to strict shell env lookup when fast Node lookup cannot resolve Node', async () => {
|
||||||
mockBuiltWorkspaceEntryAvailable();
|
mockBuiltWorkspaceEntryAvailable();
|
||||||
const previousNodeBinary = process.env.NODE_BINARY;
|
const previousNodeBinary = process.env.NODE_BINARY;
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,36 @@ function createExtensionlessNpmNodeLauncher(): {
|
||||||
return { dir, launcher, target };
|
return { dir, launcher, target };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createNpmNativeExeLauncher(): {
|
||||||
|
dir: string;
|
||||||
|
launcher: string;
|
||||||
|
target: string;
|
||||||
|
} {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-native-launcher-'));
|
||||||
|
const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin');
|
||||||
|
mkdirSync(targetDir, { recursive: true });
|
||||||
|
const target = path.join(targetDir, 'opencode.exe');
|
||||||
|
writeFileSync(target, '', 'utf8');
|
||||||
|
const launcher = path.join(dir, 'opencode.cmd');
|
||||||
|
writeFileSync(
|
||||||
|
launcher,
|
||||||
|
[
|
||||||
|
'@ECHO off',
|
||||||
|
'GOTO start',
|
||||||
|
':find_dp0',
|
||||||
|
'SET dp0=%~dp0',
|
||||||
|
'EXIT /b',
|
||||||
|
':start',
|
||||||
|
'SETLOCAL',
|
||||||
|
'CALL :find_dp0',
|
||||||
|
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*',
|
||||||
|
'',
|
||||||
|
].join('\r\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return { dir, launcher, target };
|
||||||
|
}
|
||||||
|
|
||||||
describe('cli child process helpers', () => {
|
describe('cli child process helpers', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
@ -220,6 +250,25 @@ describe('cli child process helpers', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('runs npm native exe cmd launchers directly', () => {
|
||||||
|
setPlatform('win32');
|
||||||
|
const fake = new EventEmitter() as ReturnType<typeof spawnCli>;
|
||||||
|
const spawnMock = child.spawn as unknown as Mock;
|
||||||
|
spawnMock.mockReturnValue(fake);
|
||||||
|
const { dir, launcher, target } = createNpmNativeExeLauncher();
|
||||||
|
try {
|
||||||
|
const result = spawnCli(launcher, ['serve', '--hostname', '127.0.0.1']);
|
||||||
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spawnMock.mock.calls[0][0]).toBe(target);
|
||||||
|
expect(spawnMock.mock.calls[0][1]).toEqual(['serve', '--hostname', '127.0.0.1']);
|
||||||
|
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
|
||||||
|
expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true });
|
||||||
|
expect(result).toBe(fake);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('uses shell directly when path contains non-ASCII on windows', () => {
|
it('uses shell directly when path contains non-ASCII on windows', () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
const fake = {} as any;
|
const fake = {} as any;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue