diff --git a/.agents/skills/mcp-setup/SKILL.md b/.agents/skills/mcp-setup/SKILL.md index e93ad65..092868d 100644 --- a/.agents/skills/mcp-setup/SKILL.md +++ b/.agents/skills/mcp-setup/SKILL.md @@ -24,6 +24,7 @@ The package is **not published to npm** (`private: true`). Use the local `dist/c - After source changes, run `npm run build` to update `dist/`. - Prefer absolute paths — `~` may not expand in all MCP clients. +- **Local Bazel execution is the default.** `BITRISE_BUILD_CACHE_AUTH_TOKEN` is not required. Bitrise RBE configs (`bitrise`, `remote_linux`) are stripped automatically unless the token is set. ## Config File diff --git a/.cursor/mcp.json b/.cursor/mcp.json index e571085..b49ff74 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -4,7 +4,8 @@ "command": "~/.nvm/versions/node/v20.19.2/bin/node", "args": ["~/Projects/XcodeBazelMCP/dist/cli.js", "mcp"], "env": { - "BAZEL_IOS_WORKSPACE": "~/Projects/XcodeBazelMCP/example_projects/BazelApp" + "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", + "BAZEL_IOS_WORKSPACE": "~/Projects/ios-migration" } } } diff --git a/README.md b/README.md index 736d9c8..d53f51e 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,13 @@ Then switch at runtime: `xcodebazelmcp set-defaults --profile app` | `BAZEL_IOS_COMMAND_LOG_MAX_BYTES` | Rotate the command log past this size (default 5 MiB). | | `BAZEL_IOS_COMMAND_LOG_DISABLE`| Set to `1` to disable command logging. | | `IDB_PATH` | Path to the `idb` binary for UI automation (else discovered on `PATH`). | +| `BITRISE_BUILD_CACHE_AUTH_TOKEN` | **Not required.** Local builds/tests are the default. Set this only when you intentionally want Bitrise RBE (`--config=bitrise` / `--config=remote_linux`). Without it, those configs are dropped automatically. | + +### Local execution is the default + +DoorDash's `ios` repo defines `--config=bitrise` and `--config=remote_linux` for optional Bitrise remote execution. **You do not need `BITRISE_BUILD_CACHE_AUTH_TOKEN` for XcodeBazelMCP.** Agents and tools run locally on your Mac by default. + +If a call passes `--config=bitrise` or `--config=remote_linux` without the token, XcodeBazelMCP silently drops those flags and runs locally. Set the token only when you explicitly want RBE offload (e.g. heavy compiles on Bitrise executors). ## CLI Examples diff --git a/src/core/bazel.test.ts b/src/core/bazel.test.ts index f3eee53..b7f89ed 100644 --- a/src/core/bazel.test.ts +++ b/src/core/bazel.test.ts @@ -6,6 +6,7 @@ import type { CommandResult } from '../types/index.js'; import { runCommand, runCommandStreaming } from '../utils/process.js'; import { asStringArray, + bitriseAuthAvailable, buildCommandArgs, configArgs, discoverExpression, @@ -18,8 +19,10 @@ import { requireLabel, sanitizeQueryExpression, simulatorArgs, + stripBitriseAuthFlags, testFilterArgs, tokenizeArgs, + withoutBitriseAuthConfigs, runBazel, runBazelStreaming, getLastCommand, @@ -40,6 +43,8 @@ vi.mock('../utils/process.js', () => ({ vi.mock('./workspace.js', () => ({ assertBazelWorkspace: vi.fn(), + isBazelWorkspace: vi.fn(() => true), + discoverBazelWorkspace: vi.fn(), })); const mockRunCommand = vi.mocked(runCommand); @@ -228,6 +233,32 @@ describe('Bazel argument helpers', () => { expect(() => configArgs(['bad;config'])).toThrow('Invalid config value'); }); + it('drops Bitrise RBE configs when BITRISE_BUILD_CACHE_AUTH_TOKEN is unset', () => { + delete process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN; + expect(bitriseAuthAvailable()).toBe(false); + expect(withoutBitriseAuthConfigs(['local', 'bitrise', 'remote_linux', 'test'])).toEqual(['local', 'test']); + expect(configArgs(['bitrise', 'test'])).toEqual(['--config=test']); + expect(stripBitriseAuthFlags(['test', '--config=bitrise', '--jobs=1', '--config=remote_linux'])).toEqual([ + 'test', + '--jobs=1', + ]); + expect( + buildCommandArgs({ + target: '//:MyApp', + configs: ['bitrise'], + extraArgs: ['--config=remote_linux', '--cache_test_results=no'], + }), + ).toEqual(['build', '--cache_test_results=no', '//:MyApp']); + }); + + it('keeps Bitrise RBE configs when BITRISE_BUILD_CACHE_AUTH_TOKEN is set', () => { + process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN = 'test-token'; + expect(bitriseAuthAvailable()).toBe(true); + expect(configArgs(['bitrise', 'test'])).toEqual(['--config=bitrise', '--config=test']); + expect(stripBitriseAuthFlags(['--config=bitrise'])).toEqual(['--config=bitrise']); + delete process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN; + }); + it('testFilterArgs returns single --test_filter for simple string', () => { expect(testFilterArgs('MyTestSuite')).toEqual(['--test_filter=MyTestSuite']); }); @@ -540,7 +571,7 @@ describe('runBazel', () => { await runBazel(['build', '//:MyApp']); - expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['build', '//:MyApp'], { + expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['build', '//:MyApp'], { cwd: tempDir, timeoutSeconds: undefined, maxOutput: expect.any(Number), @@ -553,7 +584,7 @@ describe('runBazel', () => { await runBazel(['build', '//:MyApp'], undefined, ['--host_jvm_args=-Xmx4g']); - expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['--host_jvm_args=-Xmx4g', 'build', '//:MyApp'], expect.any(Object)); + expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['--host_jvm_args=-Xmx4g', 'build', '//:MyApp'], expect.any(Object)); }); it('passes timeout', async () => { @@ -561,7 +592,7 @@ describe('runBazel', () => { await runBazel(['build', '//:MyApp'], 300); - expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['build', '//:MyApp'], expect.objectContaining({ timeoutSeconds: 300 })); + expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['build', '//:MyApp'], expect.objectContaining({ timeoutSeconds: 300 })); }); it('respects BAZEL_IOS_STARTUP_ARGS env var', async () => { @@ -570,7 +601,7 @@ describe('runBazel', () => { await runBazel(['build', '//:MyApp']); - expect(mockRunCommand).toHaveBeenCalledWith('bazel', ['--batch', '--noautodetect_server_javabase', 'build', '//:MyApp'], expect.any(Object)); + expect(mockRunCommand).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['--batch', '--noautodetect_server_javabase', 'build', '//:MyApp'], expect.any(Object)); delete process.env.BAZEL_IOS_STARTUP_ARGS; }); @@ -598,7 +629,7 @@ describe('runBazelStreaming', () => { chunks.push(chunk); } - expect(mockRunCommandStreaming).toHaveBeenCalledWith('bazel', ['build', '//:MyApp'], { + expect(mockRunCommandStreaming).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['build', '//:MyApp'], { cwd: tempDir, timeoutSeconds: undefined, maxOutput: expect.any(Number), @@ -617,7 +648,7 @@ describe('runBazelStreaming', () => { chunks.push(chunk); } - expect(mockRunCommandStreaming).toHaveBeenCalledWith('bazel', ['--batch', 'test', '//:Tests'], expect.any(Object)); + expect(mockRunCommandStreaming).toHaveBeenCalledWith(expect.stringMatching(/bazel(isk)?$/), ['--batch', 'test', '//:Tests'], expect.any(Object)); }); it('stores final result in lastCommand', async () => { diff --git a/src/core/bazel.ts b/src/core/bazel.ts index c95bf0b..290965f 100644 --- a/src/core/bazel.ts +++ b/src/core/bazel.ts @@ -186,8 +186,54 @@ export function testFilterArgs(testFilter: unknown): string[] { return [`--test_filter=${trimmed}`]; } +/** Bazel configs that require `BITRISE_BUILD_CACHE_AUTH_TOKEN` (DoorDash ios `.bazelrc`). */ +export const BITRISE_AUTH_CONFIGS = ['bitrise', 'remote_linux'] as const; + +export type BitriseAuthConfig = (typeof BITRISE_AUTH_CONFIGS)[number]; + +export function bitriseAuthAvailable(): boolean { + return Boolean(process.env.BITRISE_BUILD_CACHE_AUTH_TOKEN?.trim()); +} + +function isBitriseAuthConfig(name: string): name is BitriseAuthConfig { + return (BITRISE_AUTH_CONFIGS as readonly string[]).includes(name); +} + +/** Drop Bitrise RBE configs from a `configs` array when the auth token is missing. */ +export function withoutBitriseAuthConfigs(configs: string[]): string[] { + if (bitriseAuthAvailable()) return configs; + return configs.filter((config) => !isBitriseAuthConfig(config)); +} + +/** + * Remove `--config=bitrise` / `--config=remote_linux` from a Bazel argv when + * `BITRISE_BUILD_CACHE_AUTH_TOKEN` is unset so local builds/tests still run. + */ +export function stripBitriseAuthFlags(args: string[]): string[] { + if (bitriseAuthAvailable()) return args; + + const stripped: string[] = []; + const kept = args.filter((arg) => { + const match = /^--config=(.+)$/.exec(arg); + if (match && isBitriseAuthConfig(match[1])) { + stripped.push(arg); + return false; + } + return true; + }); + + if (stripped.length > 0) { + process.stderr.write( + `[XcodeBazelMCP] Dropping Bitrise RBE config(s) ${stripped.join(', ')} — ` + + 'local execution is the default (set BITRISE_BUILD_CACHE_AUTH_TOKEN only for RBE offload).\n', + ); + } + + return kept; +} + export function configArgs(value: unknown): string[] { - return asStringArray(value, 'configs').map((config) => { + return withoutBitriseAuthConfigs(asStringArray(value, 'configs')).map((config) => { if (!/^[A-Za-z0-9_.-]+$/.test(config)) { throw new Error(`Invalid config value: ${config}`); } @@ -269,7 +315,8 @@ export async function runBazel( assertBazelWorkspace(config.workspacePath); const { all: allStartupArgs } = resolveStartupArgs(startupArgs); const id = randomUUID().slice(0, 8); - const result = await runCommand(config.bazelPath, [...allStartupArgs, ...args], { + const sanitizedArgs = stripBitriseAuthFlags(args); + const result = await runCommand(config.bazelPath, [...allStartupArgs, ...sanitizedArgs], { cwd: config.workspacePath, timeoutSeconds, maxOutput: config.maxOutput, @@ -293,9 +340,10 @@ export async function* runBazelStreaming( const { all: allStartupArgs } = resolveStartupArgs(startupArgs); const id = randomUUID().slice(0, 8); + const sanitizedArgs = stripBitriseAuthFlags(args); for await (const chunk of runCommandStreaming( config.bazelPath, - [...allStartupArgs, ...args], + [...allStartupArgs, ...sanitizedArgs], { cwd: config.workspacePath, timeoutSeconds, @@ -325,7 +373,7 @@ export function buildCommandArgs(args: BuildArgs): string[] { ...platformArgs(args.platform), ...(isDevice ? [] : simulatorArgs(args)), ...configArgs(args.configs), - ...asStringArray(args.extraArgs, 'extraArgs'), + ...stripBitriseAuthFlags(asStringArray(args.extraArgs, 'extraArgs')), target, ]; } diff --git a/src/core/workspace.test.ts b/src/core/workspace.test.ts index 76dc69d..b81d129 100644 --- a/src/core/workspace.test.ts +++ b/src/core/workspace.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { assertBazelWorkspace, readBspStatus } from './workspace.js'; +import { assertBazelWorkspace, discoverBazelWorkspace, isBazelWorkspace, readBspStatus } from './workspace.js'; const dirs: string[] = []; function makeTmp(): string { @@ -16,6 +16,30 @@ afterEach(() => { dirs.length = 0; }); +describe('isBazelWorkspace', () => { + it('returns true when MODULE.bazel exists', () => { + const tmp = makeTmp(); + writeFileSync(join(tmp, 'MODULE.bazel'), 'module(name = "test")'); + expect(isBazelWorkspace(tmp)).toBe(true); + }); + + it('returns false for a plain directory', () => { + const tmp = makeTmp(); + expect(isBazelWorkspace(tmp)).toBe(false); + }); +}); + +describe('discoverBazelWorkspace', () => { + it('walks up from a nested directory to find the Bazel root', () => { + const root = makeTmp(); + const nested = join(root, 'Apps', 'Dasher', 'Tests'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(root, 'MODULE.bazel'), 'module(name = "test")'); + + expect(discoverBazelWorkspace([nested])).toBe(root); + }); +}); + describe('assertBazelWorkspace', () => { it('throws when path does not exist', () => { expect(() => assertBazelWorkspace('/no/such/path')).toThrowError( diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 538448d..2721ca7 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -1,13 +1,46 @@ import { existsSync, readFileSync, statSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; + +const WORKSPACE_MARKERS = ['MODULE.bazel', 'WORKSPACE', 'WORKSPACE.bazel'] as const; + +export function isBazelWorkspace(workspacePath: string): boolean { + if (!existsSync(workspacePath) || !statSync(workspacePath).isDirectory()) { + return false; + } + return WORKSPACE_MARKERS.some((marker) => existsSync(join(workspacePath, marker))); +} + +/** + * Walk upward from each start directory and return the first Bazel workspace root. + */ +export function discoverBazelWorkspace(startDirs: string[]): string | undefined { + const seen = new Set(); + + for (const start of startDirs) { + if (!start?.trim()) continue; + let dir = resolve(start.trim()); + + for (let depth = 0; depth < 64; depth += 1) { + if (seen.has(dir)) break; + seen.add(dir); + + if (isBazelWorkspace(dir)) return dir; + + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + + return undefined; +} export function assertBazelWorkspace(workspacePath: string): void { if (!existsSync(workspacePath) || !statSync(workspacePath).isDirectory()) { throw new Error(`Workspace does not exist or is not a directory: ${workspacePath}`); } - const markers = ['MODULE.bazel', 'WORKSPACE', 'WORKSPACE.bazel']; - if (!markers.some((marker) => existsSync(join(workspacePath, marker)))) { + if (!isBazelWorkspace(workspacePath)) { throw new Error(`Workspace has no MODULE.bazel, WORKSPACE, or WORKSPACE.bazel: ${workspacePath}`); } } diff --git a/src/runtime/config.test.ts b/src/runtime/config.test.ts index 9a265af..a6144cf 100644 --- a/src/runtime/config.test.ts +++ b/src/runtime/config.test.ts @@ -175,7 +175,7 @@ describe('getConfig', () => { setWorkspace(tempDir); const config = getConfig(); expect(config.workspacePath).toBe(tempDir); - expect(config.bazelPath).toBe('bazel'); + expect(config.bazelPath).toMatch(/bazel(isk)?$/); }); it('loads config from workspace .xcodebazelmcp directory', () => { diff --git a/src/runtime/config.ts b/src/runtime/config.ts index 8ec9bd2..3f02305 100644 --- a/src/runtime/config.ts +++ b/src/runtime/config.ts @@ -2,6 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import type { FileConfig, ProfileConfig, RuntimeConfig, SessionDefaults } from '../types/index.js'; +import { discoverBazelWorkspace, isBazelWorkspace } from '../core/workspace.js'; +import { resolveBazelExecutable } from './resolve-toolchain.js'; /** Expand a leading `~` or `~/` to the user's home directory before resolving. */ export function expandTilde(p: string): string { @@ -30,12 +32,40 @@ let config: RuntimeConfig = { }; let configLoaded = false; +let workspaceExplicitlySet = false; + +function workspaceDiscoveryCandidates(): string[] { + return [ + process.env.BAZEL_IOS_WORKSPACE, + config.workspacePath, + process.env.CURSOR_WORKSPACE_FOLDER, + process.env.WORKSPACE_FOLDER, + process.env.VSCODE_CWD, + process.cwd(), + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .map((value) => resolvePath(value.trim())); +} + +function resolveRuntimePaths(): void { + if (!workspaceExplicitlySet && !isBazelWorkspace(config.workspacePath)) { + const discovered = discoverBazelWorkspace(workspaceDiscoveryCandidates()); + if (discovered) { + config.workspacePath = discovered; + config.workspaceAutoDiscovered = true; + } + } else if (isBazelWorkspace(config.workspacePath)) { + config.workspaceAutoDiscovered = false; + } + + config.bazelPath = resolveBazelExecutable(config.bazelPath); +} export function getConfig(): RuntimeConfig { if (!configLoaded) { loadConfigFile(); configLoaded = true; } + resolveRuntimePaths(); const clonedProfiles: Record = {}; for (const [k, v] of Object.entries(config.profiles)) { clonedProfiles[k] = { ...v }; @@ -44,10 +74,12 @@ export function getConfig(): RuntimeConfig { } export function setWorkspace(workspacePath: string, bazelPath?: string): RuntimeConfig { + workspaceExplicitlySet = true; config = { ...config, workspacePath: resolvePath(workspacePath), bazelPath: bazelPath ? expandTilde(bazelPath) : config.bazelPath, + workspaceAutoDiscovered: false, }; configLoaded = false; return getConfig(); diff --git a/src/runtime/resolve-toolchain.test.ts b/src/runtime/resolve-toolchain.test.ts new file mode 100644 index 0000000..9f6cb50 --- /dev/null +++ b/src/runtime/resolve-toolchain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { augmentPathForToolchain, resolveBazelExecutable } from './resolve-toolchain.js'; + +describe('augmentPathForToolchain', () => { + it('prepends Homebrew and local bin dirs when missing from PATH', () => { + const env = augmentPathForToolchain({ PATH: '/usr/bin:/bin' }); + expect(env.PATH).toMatch(/^\/opt\/homebrew\/bin:/); + expect(env.PATH).toContain('/usr/local/bin'); + expect(env.PATH).toContain('/usr/bin'); + }); + + it('does not duplicate dirs already present in PATH', () => { + const env = augmentPathForToolchain({ PATH: '/opt/homebrew/bin:/usr/bin' }); + expect(env.PATH?.split(':').filter((part) => part === '/opt/homebrew/bin')).toHaveLength(1); + }); +}); + +describe('resolveBazelExecutable', () => { + it('resolves bare bazel to an absolute path on this machine', () => { + const resolved = resolveBazelExecutable('bazel'); + expect(resolved).toMatch(/^\//); + expect(resolved).toMatch(/bazel(isk)?$/); + }); + + it('preserves absolute configured paths', () => { + expect(resolveBazelExecutable('/opt/homebrew/bin/bazel')).toBe('/opt/homebrew/bin/bazel'); + }); +}); diff --git a/src/runtime/resolve-toolchain.ts b/src/runtime/resolve-toolchain.ts new file mode 100644 index 0000000..e28bb28 --- /dev/null +++ b/src/runtime/resolve-toolchain.ts @@ -0,0 +1,68 @@ +import { accessSync, constants } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Common install locations when MCP inherits a minimal PATH (e.g. from Cursor). */ +export const COMMON_BIN_DIRS = [ + '/opt/homebrew/bin', + '/usr/local/bin', + join(homedir(), '.local/bin'), +]; + +const BAZEL_EXECUTABLE_NAMES = ['bazel', 'bazelisk'] as const; + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Prepend Homebrew and other common bin dirs so bare tool names resolve under MCP. + */ +export function augmentPathForToolchain(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const current = env.PATH || ''; + const parts = current.split(':').filter(Boolean); + const prefix = COMMON_BIN_DIRS.filter((dir) => dir && !parts.includes(dir)); + if (prefix.length === 0) return env; + return { ...env, PATH: [...prefix, ...parts].join(':') }; +} + +/** + * Resolve a configured Bazel binary name or path to an absolute executable. + * Falls back through `which` (with augmented PATH) and common install dirs. + */ +export function resolveBazelExecutable(configured: string): string { + const trimmed = configured.trim() || 'bazel'; + + if (trimmed.includes('/')) { + return trimmed; + } + + const env = augmentPathForToolchain(); + const names = trimmed === 'bazel' + ? BAZEL_EXECUTABLE_NAMES + : [trimmed, ...BAZEL_EXECUTABLE_NAMES.filter((name) => name !== trimmed)]; + + for (const name of names) { + try { + const resolved = execFileSync('which', [name], { env, encoding: 'utf8' }).trim(); + if (resolved && isExecutable(resolved)) return resolved; + } catch { + // try next candidate + } + } + + for (const dir of COMMON_BIN_DIRS) { + for (const name of names) { + const full = join(dir, name); + if (isExecutable(full)) return full; + } + } + + return trimmed; +} diff --git a/src/tools/handlers/session.ts b/src/tools/handlers/session.ts index 5cf2297..d485ae1 100644 --- a/src/tools/handlers/session.ts +++ b/src/tools/handlers/session.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { DEFAULT_WORKFLOWS, WORKFLOWS, getEnabledToolNames, validateWorkflowIds } from '../../core/workflows.js'; -import { asStringArray, getLastCommand, runBazel } from '../../core/bazel.js'; +import { asStringArray, bitriseAuthAvailable, getLastCommand, runBazel } from '../../core/bazel.js'; import { assertBazelWorkspace, readBspStatus } from '../../core/workspace.js'; import { activateProfile, @@ -151,9 +151,11 @@ export async function handle(name: string, args: JsonObject): Promise; diff --git a/src/utils/process.ts b/src/utils/process.ts index 48b954e..8c543c9 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import type { CommandResult, FailureKind } from '../types/index.js'; +import { augmentPathForToolchain } from '../runtime/resolve-toolchain.js'; export interface RunCommandOptions { cwd: string; @@ -84,7 +85,7 @@ export function runCommand( return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd, - env: options.env || process.env, + env: augmentPathForToolchain(options.env || process.env), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -167,7 +168,7 @@ export async function* runCommandStreaming( const child = spawn(command, args, { cwd: options.cwd, - env: options.env || process.env, + env: augmentPathForToolchain(options.env || process.env), stdio: ['ignore', 'pipe', 'pipe'], });