Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,15 @@ jobs:
bun-version-file: package.json
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun run test

windows-command-argv:
name: Windows command argv
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun test tests/unit/core/native/types.test.ts tests/unit/cli/workspace-setup-command.test.ts
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"js-yaml": "^4.1.0",
"json5": "^2.2.3",
"micromatch": "^4.0.8",
"read-cmd-shim": "^4.0.0",
"simple-git": "^3.30.0",
"zod": "^3.22.4"
},
Expand Down
205 changes: 179 additions & 26 deletions src/core/native/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { access, open } from 'node:fs/promises';
import { delimiter, dirname, extname, resolve } from 'node:path';
import readCmdShim from 'read-cmd-shim';

export interface NativeCommandResult {
success: boolean;
Expand Down Expand Up @@ -50,20 +54,177 @@ export interface NativeClient {
syncPlugins(plugins: string[], scope: 'user' | 'project', options?: { cwd?: string; dryRun?: boolean }): Promise<NativeSyncResult>;
}

async function resolveWindowsBinary(
binary: string,
nativeOnly = false,
): Promise<string> {
const pathEntries = /[\\/]/.test(binary)
? ['']
: (process.env.PATH ?? '')
.split(delimiter)
.map((pathEntry) => {
const trimmed = pathEntry.trim();
return trimmed.startsWith('"') && trimmed.endsWith('"')
? trimmed.slice(1, -1)
: trimmed;
})
// Empty Windows PATH entries mean cwd, but client binaries must come
// from an explicit PATH directory rather than the workspace.
.filter((pathEntry) => pathEntry.length > 0);
const configuredExtensions = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(delimiter)
.map((extension) => {
const normalized = extension.trim().toLowerCase();
return normalized && !normalized.startsWith('.')
? `.${normalized}`
: normalized;
})
.filter((extension) => extension.length > 0);
const extensions = extname(binary)
? ['']
: nativeOnly
? configuredExtensions.filter(
(extension) => extension === '.com' || extension === '.exe',
)
: configuredExtensions;

for (const directory of pathEntries) {
for (const extension of extensions) {
const candidate = resolve(directory, `${binary}${extension}`);
try {
await access(candidate);
return candidate;
} catch {
// Continue through PATH.
}
}
}

throw new Error(`command not found on PATH: ${binary}`);
}

async function resolveWindowsNativeBinary(binary: string): Promise<string> {
const extension = extname(binary).toLowerCase();
if (extension && extension !== '.com' && extension !== '.exe') {
throw new Error(`unsafe Windows interpreter '${binary}'`);
}
return resolveWindowsBinary(binary, true);
}

async function resolveWindowsShimInterpreter(
interpreter: string,
shimDirectory: string,
): Promise<string> {
const normalizedInterpreter = interpreter.toLowerCase();
if (
normalizedInterpreter !== 'node' &&
normalizedInterpreter !== 'node.exe'
) {
throw new Error(`unsupported command shim interpreter '${interpreter}'`);
}

const siblingNode = resolve(shimDirectory, 'node.exe');
try {
await access(siblingNode);
return siblingNode;
} catch {
return resolveWindowsNativeBinary(interpreter);
}
}

async function resolveWindowsCommand(
binary: string,
args: string[],
): Promise<{ binary: string; args: string[] }> {
const resolvedBinary = await resolveWindowsBinary(binary);
const extension = extname(resolvedBinary).toLowerCase();
if (extension !== '.cmd') {
if (extension !== '.com' && extension !== '.exe') {
throw new Error(
`cannot safely execute Windows command '${resolvedBinary}'`,
);
}
return { binary: resolvedBinary, args };
}

const target = resolve(
dirname(resolvedBinary),
await readCmdShim(resolvedBinary),
);
const targetExtension = extname(target).toLowerCase();
if (
targetExtension === '.bat' ||
targetExtension === '.cmd' ||
targetExtension === '.ps1'
) {
throw new Error(`cannot safely execute command shim target '${target}'`);
}
const file = await open(target, 'r');
const buffer = Buffer.alloc(256);
let bytesRead = 0;
try {
({ bytesRead } = await file.read(buffer, 0, buffer.length, 0));
} finally {
await file.close();
}
const [firstLine = ''] = buffer
.toString('utf8', 0, bytesRead)
.split(/\r?\n/, 1);
const shebang = firstLine.match(
/^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?)?([^ \t]+)\s*$/,
);
if (!shebang) {
if (firstLine.startsWith('#!')) {
throw new Error(
`unsupported command shim shebang in '${resolvedBinary}'`,
);
}
if (targetExtension !== '.com' && targetExtension !== '.exe') {
throw new Error(`unsupported command shim target '${target}'`);
}
return { binary: target, args };
}

const interpreter = shebang[1];
if (!interpreter) {
throw new Error(`missing command shim interpreter in '${resolvedBinary}'`);
}

return {
binary: await resolveWindowsShimInterpreter(
interpreter,
dirname(resolvedBinary),
),
args: [target, ...args],
};
}

/**
* Execute a CLI command and capture output.
* Shared helper for all native client implementations.
*/
export function executeCommand(
export async function executeCommand(
binary: string,
args: string[],
options: { cwd?: string } = {},
): Promise<NativeCommandResult> {
return new Promise((resolve) => {
const proc = spawn(binary, args, {
let command = { binary, args };
if (process.platform === 'win32') {
try {
command = await resolveWindowsCommand(binary, args);
} catch (err) {
return {
success: false,
output: '',
error: `Failed to execute ${binary} CLI: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

try {
const proc = spawn(command.binary, command.args, {
cwd: options.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env: { ...process.env },
});

Expand All @@ -77,28 +238,20 @@ export function executeCommand(
stderr += data.toString();
});

let resolved = false;
proc.on('close', (code: number | null) => {
if (resolved) return;
resolved = true;
const trimmedStderr = stderr.trim();
resolve({
success: code === 0,
output: stdout.trim(),
...(trimmedStderr && { error: trimmedStderr }),
});
});

proc.on('error', (err: Error) => {
if (resolved) return;
resolved = true;
resolve({
success: false,
output: '',
error: `Failed to execute ${binary} CLI: ${err.message}`,
});
});
});
const [code] = (await once(proc, 'close')) as [number | null];
const trimmedStderr = stderr.trim();
return {
success: code === 0,
output: stdout.trim(),
...(trimmedStderr && { error: trimmedStderr }),
};
} catch (err) {
return {
success: false,
output: '',
error: `Failed to execute ${binary} CLI: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/types/read-cmd-shim.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module 'read-cmd-shim' {
function readCmdShim(path: string): Promise<string>;
export default readCmdShim;
}
Loading