src / powershell.ts
import { spawn } from "child_process";
import os from "os";
import type { PowerShell7Config } from "./config";
import { resolvePowerShellExecutable } from "./powershellPath";
export type PowerShellRunResult = {
ok: boolean;
exitCode: number | null;
signal: string | null;
stdout: string;
stderr: string;
durationMs: number;
timedOut: boolean;
truncated: boolean;
stdoutBytes: number;
stderrBytes: number;
stdoutMaxBytes: number;
stderrMaxBytes: number;
stdoutTruncated: boolean;
stderrTruncated: boolean;
pathResolutionError?: string;
powershellPathMode?: "auto" | "manual";
};
type SpawnRunResult = Omit<PowerShellRunResult, "ok" | "durationMs">;
type PowerShellRunOptions = {
stderrHostMetadataValues?: string[];
};
export async function runPowerShellEncodedCommand(
command: string,
cwd: string,
timeoutSeconds: number,
config: PowerShell7Config,
): Promise<PowerShellRunResult> {
const commandWithBootstrap = `${getPowerShellBootstrap(config)}& {
${command}
} 6>&1`;
const args = [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
Buffer.from(commandWithBootstrap, "utf16le").toString("base64"),
];
return runResolvedPowerShell(args, cwd, timeoutSeconds, config, {
stderrHostMetadataValues: ["Write-Host"],
});
}
export async function runPowerShellFile(
scriptPath: string,
scriptArgs: string[],
cwd: string,
timeoutSeconds: number,
config: PowerShell7Config,
): Promise<PowerShellRunResult> {
const commandWithBootstrap = `${getPowerShellBootstrap(config)}$scriptPath = ${psSingleQuotedLiteral(scriptPath)}
$scriptArgs = ${psArrayLiteral(scriptArgs)}
& $scriptPath @scriptArgs 6>&1`;
const args = [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
Buffer.from(commandWithBootstrap, "utf16le").toString("base64"),
];
return runResolvedPowerShell(args, cwd, timeoutSeconds, config, {
stderrHostMetadataValues: [scriptPath, "Write-Host"],
});
}
async function runResolvedPowerShell(
args: string[],
cwd: string,
timeoutSeconds: number,
config: PowerShell7Config,
options: PowerShellRunOptions = {},
): Promise<PowerShellRunResult> {
const startedAt = Date.now();
const resolution = await resolvePowerShellExecutable(config);
if (!resolution.ok) {
return {
ok: false,
exitCode: null,
signal: null,
stdout: "",
stderr: resolution.error,
durationMs: Date.now() - startedAt,
timedOut: false,
truncated: false,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(resolution.error, "utf8"),
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stdoutTruncated: false,
stderrTruncated: false,
pathResolutionError: resolution.error,
powershellPathMode: resolution.mode,
};
}
const runResult = await spawnPowerShell(resolution.path, args, cwd, timeoutSeconds, config, options);
return {
ok: !runResult.timedOut && runResult.exitCode === 0,
durationMs: Date.now() - startedAt,
...runResult,
};
}
function spawnPowerShell(
pwshPath: string,
args: string[],
cwd: string,
timeoutSeconds: number,
config: PowerShell7Config,
options: PowerShellRunOptions,
): Promise<SpawnRunResult> {
return new Promise((resolve) => {
let stdout: Buffer<ArrayBufferLike> = Buffer.alloc(0);
let stderr: Buffer<ArrayBufferLike> = Buffer.alloc(0);
let stdoutBytes = 0;
let stderrBytes = 0;
let stdoutTruncated = false;
let stderrTruncated = false;
let timedOut = false;
let settled = false;
const child = spawn(pwshPath, args, {
cwd,
env: {
...process.env,
COMPUTERNAME: process.env.COMPUTERNAME ?? os.hostname(),
},
windowsHide: true,
shell: false,
});
const timeout = setTimeout(() => {
timedOut = true;
child.kill();
}, timeoutSeconds * 1000);
child.stdout?.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
const result = appendCapped(stdout, chunk, config.stdoutMaxBytes);
stdout = result.buffer;
stdoutTruncated = stdoutTruncated || result.truncated;
});
child.stderr?.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
const result = appendCapped(stderr, chunk, config.stderrMaxBytes);
stderr = result.buffer;
stderrTruncated = stderrTruncated || result.truncated;
});
child.once("error", (error) => {
clearTimeout(timeout);
if (!settled) {
settled = true;
resolve({
exitCode: null,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
timedOut,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(error instanceof Error ? error.message : String(error), "utf8"),
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stdoutTruncated: false,
stderrTruncated: false,
truncated: false,
});
}
});
child.once("close", (exitCode, signal) => {
clearTimeout(timeout);
if (!settled) {
settled = true;
const normalizedStdout = normalizePowerShellStdout(stdout.toString("utf8"), config);
const normalizedStderr = normalizePowerShellStderr(stderr.toString("utf8"), config, options.stderrHostMetadataValues);
resolve({
exitCode,
signal,
stdout: appendTruncationMarker(normalizedStdout, Math.max(0, stdoutBytes - stdout.length), stdoutTruncated),
stderr: appendTruncationMarker(normalizedStderr, Math.max(0, stderrBytes - stderr.length), stderrTruncated),
timedOut,
stdoutBytes,
stderrBytes,
stdoutMaxBytes: config.stdoutMaxBytes,
stderrMaxBytes: config.stderrMaxBytes,
stdoutTruncated,
stderrTruncated,
truncated: stdoutTruncated || stderrTruncated,
});
}
});
});
}
export function normalizePowerShellStreamText(
text: string,
options: {
stripAnsiOutput: boolean;
removeBootstrapNoise: boolean;
} = { stripAnsiOutput: true, removeBootstrapNoise: true },
): string {
if (!text) {
return text;
}
const withoutNulls = text.replace(/\0/g, "");
const withoutClixml = withoutNulls.trimStart().startsWith("#< CLIXML")
? decodeClixml(withoutNulls)
: decodePowerShellXmlText(withoutNulls);
const withoutAnsi = options.stripAnsiOutput ? stripAnsi(withoutClixml) : withoutClixml;
const withoutNoise = options.removeBootstrapNoise ? removePowerShellNoiseLines(withoutAnsi) : withoutAnsi;
return trimExcessTrailingWhitespace(withoutNoise);
}
export function normalizePowerShellStdout(text: string, config: PowerShell7Config): string {
return normalizePowerShellStreamText(text, {
stripAnsiOutput: config.stripAnsiOutput,
removeBootstrapNoise: true,
});
}
export function normalizePowerShellStderr(
text: string,
config: PowerShell7Config,
hostMetadataValues: string[] = [],
): string {
const normalized = normalizePowerShellStreamText(text, {
stripAnsiOutput: config.stripAnsiOutput,
removeBootstrapNoise: true,
});
return removePowerShellHostInformationMetadata(normalized, hostMetadataValues);
}
function decodeClixml(text: string): string {
const xml = text.replace(/^\s*#< CLIXML\s*/i, "");
if (!/^\s*<Objs(?:\s|>)/i.test(xml)) {
return text;
}
try {
const extracted = Array.from(xml.matchAll(/<S(?:\s+[^>]*)?>([\s\S]*?)<\/S>/gi), (match) =>
decodePowerShellXmlText(match[1] ?? ""),
);
if (extracted.length > 0) {
return extracted.join("\n");
}
return bestEffortClixmlCleanup(xml);
} catch {
return bestEffortClixmlCleanup(text);
}
}
function bestEffortClixmlCleanup(text: string): string {
return decodePowerShellXmlText(
text
.replace(/^\s*#< CLIXML\s*/i, "")
.replace(/<[^>]+>/g, " ")
.replace(/[ \t]+\r?\n/g, "\n")
.replace(/\s{2,}/g, " "),
);
}
function decodePowerShellXmlText(text: string): string {
return text
.replace(/_x([0-9a-fA-F]{4})_/g, (_match, hex: string) => {
const codePoint = Number.parseInt(hex, 16);
return String.fromCharCode(codePoint);
})
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&")
.replace(/"/g, '"')
.replace(/'/g, "'");
}
function stripAnsi(text: string): string {
return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function removePowerShellNoiseLines(text: string): string {
const metadataLinePattern =
/^System\.Management\.Automation\.PSCustomObject\s+System\.Object\s+1\s+0\s+-1\s+-1\s+Completed\s+-1$/i;
const cleanedLines = text
.split(/\r?\n/)
.filter((line) => {
const trimmed = line.trim();
return (
trimmed !== "$ErrorView = 'NormalView'" &&
trimmed !== "$PSStyle.OutputRendering = 'PlainText'" &&
!metadataLinePattern.test(trimmed)
);
});
return cleanedLines.join("\n");
}
function removePowerShellHostInformationMetadata(text: string, additionalMetadataValues: string[]): string {
if (!text) {
return text;
}
const lines = text.split(/\r?\n/);
const hasHostMetadata =
lines.some((line) => ["Gray", "Black", "PSHOST"].includes(line.trim())) ||
lines.some((line) => /^Write-Host$/i.test(line.trim()));
if (!hasHostMetadata) {
return text;
}
const hostName = os.hostname();
const userName = process.env.USERNAME ?? process.env.USER ?? "";
const userDomain = process.env.USERDOMAIN ?? "";
const computerName = process.env.COMPUTERNAME ?? hostName;
const hostMetadataValues = new Set(
[
"Gray",
"Black",
"PSHOST",
"Write-Host",
...additionalMetadataValues,
hostName,
computerName,
userName,
userDomain && userName ? `${userDomain}\\${userName}` : "",
]
.filter(Boolean)
.map((value) => value.toLowerCase()),
);
const cleanedLines = lines.filter((line) => {
const trimmed = line.trim();
if (!trimmed) {
return true;
}
return !hostMetadataValues.has(trimmed.toLowerCase());
});
return cleanedLines.join("\n");
}
function trimExcessTrailingWhitespace(text: string): string {
return text.replace(/[ \t]+\r?\n/g, "\n").replace(/[ \t]+$/gm, "").replace(/\s+$/g, "");
}
function appendTruncationMarker(text: string, omittedBytes: number, truncated: boolean): string {
if (!truncated) {
return text;
}
const marker = `...[truncated: omitted ${omittedBytes} bytes]`;
return text ? `${text}\n${marker}` : marker;
}
function getPowerShellBootstrap(config: PowerShell7Config): string {
const lines: string[] = [];
if (config.prependNormalErrorView) {
lines.push("$ErrorView = 'NormalView'");
}
if (config.stripAnsiOutput) {
lines.push("$PSStyle.OutputRendering = 'PlainText'");
}
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
}
export function psSingleQuotedLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function psArrayLiteral(values: string[]): string {
if (values.length === 0) {
return "@()";
}
return `@(${values.map(psSingleQuotedLiteral).join(",")})`;
}
export function truncateBufferOrText(
current: Buffer<ArrayBufferLike>,
chunk: Buffer<ArrayBufferLike>,
maxBytes: number,
): { buffer: Buffer<ArrayBufferLike>; truncated: boolean } {
return appendCapped(current, chunk, maxBytes);
}
function appendCapped(
current: Buffer<ArrayBufferLike>,
chunk: Buffer<ArrayBufferLike>,
maxBytes: number,
): { buffer: Buffer<ArrayBufferLike>; truncated: boolean } {
const remaining = maxBytes - current.length;
if (remaining <= 0) {
return { buffer: current, truncated: true };
}
if (chunk.length > remaining) {
return { buffer: Buffer.concat([current, chunk.subarray(0, remaining)]), truncated: true };
}
return { buffer: Buffer.concat([current, chunk]), truncated: false };
}