alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
254 lines (226 loc) • 7.29 kB
text/typescript
import { exec, spawn } from "node:child_process";
import { $inject, AlephaError } from "alepha";
import { $logger } from "alepha/logger";
import { FileSystemProvider } from "./FileSystemProvider.ts";
import type { ShellProvider, ShellRunOptions } from "./ShellProvider.ts";
// ---------------------------------------------------------------------------------------------------------------------
/**
* Node.js implementation of ShellProvider.
*
* Executes shell commands using Node.js child_process module.
* Supports binary resolution from node_modules/.bin for local packages.
*/
export class NodeShellProvider implements ShellProvider {
protected readonly log = $logger();
protected readonly fs = $inject(FileSystemProvider);
/**
* Run a shell command or binary.
*/
public async run(
command: string,
options: ShellRunOptions = {},
): Promise<string> {
const { resolve = false, capture = false, root, env } = options;
const cwd = root ?? process.cwd();
this.log.debug(`Shell: ${command}`, { cwd, resolve, capture });
let executable: string;
let args: string[];
if (resolve) {
const [bin, ...rest] = this.parseCommand(command);
executable = await this.resolveExecutable(bin, cwd);
args = rest;
} else {
[executable, ...args] = this.parseCommand(command);
}
// Build properly escaped command string for shell execution
const shellCommand = this.buildShellCommand(executable, args);
if (capture) {
return this.execCapture(shellCommand, { cwd, env });
}
return this.execInherit(executable, args, { cwd, env });
}
/**
* Execute command with inherited stdio (streams to terminal).
*/
protected async execInherit(
executable: string,
args: string[],
options: { cwd: string; env?: Record<string, string> },
): Promise<string> {
const isWindows = process.platform === "win32";
// On Windows, use shell mode with a single command string to avoid
// Node.js DEP0190 deprecation warning about unescaped args with shell: true
const proc = isWindows
? spawn(this.buildShellCommand(executable, args), [], {
stdio: "inherit",
cwd: options.cwd,
shell: true,
env: { ...process.env, ...options.env },
})
: spawn(executable, args, {
stdio: "inherit",
cwd: options.cwd,
env: { ...process.env, ...options.env },
});
return new Promise<string>((resolve, reject) => {
proc.on("exit", (code) => {
if (code === 0 || code === null) {
resolve("");
} else {
reject(new AlephaError(`Command exited with code ${code}`));
}
});
proc.on("error", reject);
});
}
/**
* Build a shell command string with proper escaping for Windows.
* Quotes both executable and arguments that contain spaces or special characters.
*/
protected buildShellCommand(executable: string, args: string[]): string {
const escapeForShell = (str: string): string => {
// If str contains spaces or special chars, wrap in double quotes
// and escape internal double quotes
if (/[\s"&|<>^()]/.test(str)) {
return `"${str.replace(/"/g, '\\"')}"`;
}
return str;
};
return [escapeForShell(executable), ...args.map(escapeForShell)].join(" ");
}
/**
* Execute command and capture stdout.
*/
protected execCapture(
command: string,
options: { cwd: string; env?: Record<string, string> },
): Promise<string> {
return new Promise<string>((resolve, reject) => {
exec(
command,
{
cwd: options.cwd,
maxBuffer: 50 * 1024 * 1024,
env: {
...process.env,
LOG_FORMAT: "pretty",
...options.env,
},
},
(err, stdout, stderr) => {
if (err) {
// Attach both streams so callers (e.g. the CLI Runner) can surface
// the full output of a failed command, not just stdout.
(err as any).stdout = stdout;
(err as any).stderr = stderr;
reject(err);
} else {
resolve(stdout);
}
},
);
});
}
/**
* Resolve executable path from node_modules/.bin.
*
* Search order:
* 1. Local: node_modules/.bin/
* 2. Pnpm nested: node_modules/alepha/node_modules/.bin/
* 3. Monorepo: Walk up to 3 parent directories
*/
protected async resolveExecutable(
name: string,
root: string,
): Promise<string> {
const isWindows = process.platform === "win32";
// On Windows, try .cmd first (npm scripts), then .exe, then no extension
const suffixes = isWindows ? [".cmd", ".exe", ""] : [""];
for (const suffix of suffixes) {
// 1. Local node_modules
let execPath = await this.findExecutable(
root,
`node_modules/.bin/${name}${suffix}`,
);
if (execPath) return execPath;
// 2. Pnpm nested (alepha's own node_modules)
execPath = await this.findExecutable(
root,
`node_modules/alepha/node_modules/.bin/${name}${suffix}`,
);
if (execPath) return execPath;
// 3. Monorepo: check parent directories (up to 3 levels)
let parentDir = this.fs.join(root, "..");
for (let i = 0; i < 3; i++) {
execPath = await this.findExecutable(
parentDir,
`node_modules/.bin/${name}${suffix}`,
);
if (execPath) return execPath;
parentDir = this.fs.join(parentDir, "..");
}
}
throw new AlephaError(
`Could not find executable for '${name}'. Make sure the package is installed.`,
);
}
/**
* Check if executable exists at path.
*/
protected async findExecutable(
root: string,
relativePath: string,
): Promise<string | undefined> {
const fullPath = this.fs.join(root, relativePath);
if (await this.fs.exists(fullPath)) {
return fullPath;
}
return undefined;
}
/**
* Check if a command is installed and available in the system PATH.
*/
public isInstalled(command: string): Promise<boolean> {
return new Promise((resolve) => {
const check =
process.platform === "win32"
? `where ${command}`
: `command -v ${command}`;
exec(check, (error) => resolve(!error));
});
}
/**
* Parse a command string into executable and arguments.
*
* Handles quoted arguments properly for paths with spaces.
* Supports both single and double quotes.
*/
protected parseCommand(command: string): string[] {
const result: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (inQuote) {
if (char === inQuote) {
inQuote = null;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (char === " ") {
if (current) {
result.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) {
result.push(current);
}
return result;
}
}