|
1 | 1 | import fs from 'node:fs'; |
2 | 2 | import path from 'node:path'; |
3 | 3 | import crypto from 'node:crypto'; |
4 | | -import { spawnSync } from 'node:child_process'; |
| 4 | +import { spawn, spawnSync } from 'node:child_process'; |
5 | 5 | import { fileURLToPath } from 'node:url'; |
6 | 6 |
|
7 | 7 | export const engineHome = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
@@ -66,12 +66,101 @@ export function stableJson(value) { |
66 | 66 | export function stableHash(value) { return sha256(JSON.stringify(stableJson(value))); } |
67 | 67 | export function estimateTokens(value) { return Math.ceil(Buffer.byteLength(String(value), 'utf8') / 3.6); } |
68 | 68 | export function slug(value) { return String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item'; } |
69 | | -export function commandExists(command) { |
70 | | - const executable = process.platform === 'win32' ? 'where' : 'which'; |
71 | | - return spawnSync(executable, [command], { stdio: 'ignore', shell: process.platform === 'win32' }).status === 0; |
| 69 | + |
| 70 | +function directCommand(command) { |
| 71 | + if (!command) return null; |
| 72 | + const value = String(command); |
| 73 | + if (path.isAbsolute(value) || value.includes('/') || value.includes('\\')) { |
| 74 | + const full = path.resolve(value); |
| 75 | + return fs.existsSync(full) ? full : null; |
| 76 | + } |
| 77 | + return null; |
72 | 78 | } |
| 79 | + |
| 80 | +export function resolveCommand(command) { |
| 81 | + const direct = directCommand(command); |
| 82 | + if (direct) return direct; |
| 83 | + const locator = process.platform === 'win32' |
| 84 | + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'where.exe') |
| 85 | + : 'which'; |
| 86 | + const result = spawnSync(locator, [String(command)], { |
| 87 | + encoding: 'utf8', |
| 88 | + stdio: ['ignore', 'pipe', 'ignore'], |
| 89 | + shell: false, |
| 90 | + windowsHide: true |
| 91 | + }); |
| 92 | + if (result.status !== 0) return null; |
| 93 | + const candidates = String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); |
| 94 | + if (!candidates.length) return null; |
| 95 | + if (process.platform !== 'win32') return candidates[0]; |
| 96 | + return candidates.find((item) => /\.(?:exe|com)$/i.test(item)) |
| 97 | + ?? candidates.find((item) => /\.(?:cmd|bat)$/i.test(item)) |
| 98 | + ?? candidates.find((item) => fs.existsSync(item)) |
| 99 | + ?? null; |
| 100 | +} |
| 101 | + |
| 102 | +export function commandExists(command) { return Boolean(resolveCommand(command)); } |
| 103 | + |
| 104 | +function powershellHost() { |
| 105 | + return resolveCommand('pwsh.exe') ?? resolveCommand('powershell.exe'); |
| 106 | +} |
| 107 | + |
| 108 | +export function spawnCommand(command, args = [], options = {}) { |
| 109 | + const resolved = resolveCommand(command) ?? String(command); |
| 110 | + const baseOptions = { ...options, shell: false }; |
| 111 | + if (process.platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(resolved)) { |
| 112 | + return spawn(resolved, args.map(String), baseOptions); |
| 113 | + } |
| 114 | + const host = powershellHost(); |
| 115 | + if (!host) throw new Error(`Cannot safely launch Windows command shim: ${resolved}. PowerShell was not found.`); |
| 116 | + const encodedArgs = Buffer.from(JSON.stringify(args.map(String)), 'utf8').toString('base64'); |
| 117 | + const env = { |
| 118 | + ...(options.env ?? process.env), |
| 119 | + DOCGEN_CHILD_EXECUTABLE: resolved, |
| 120 | + DOCGEN_CHILD_ARGS_B64: encodedArgs |
| 121 | + }; |
| 122 | + const script = [ |
| 123 | + "$ErrorActionPreference='Stop'", |
| 124 | + '$exe=$env:DOCGEN_CHILD_EXECUTABLE', |
| 125 | + '$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:DOCGEN_CHILD_ARGS_B64))', |
| 126 | + '$childArgs=@(ConvertFrom-Json -InputObject $json)', |
| 127 | + '& $exe @childArgs', |
| 128 | + 'if ($null -eq $LASTEXITCODE) { exit 0 } else { exit $LASTEXITCODE }' |
| 129 | + ].join('; '); |
| 130 | + return spawn(host, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { |
| 131 | + ...baseOptions, |
| 132 | + env |
| 133 | + }); |
| 134 | +} |
| 135 | + |
| 136 | +export function terminateProcessTree(child) { |
| 137 | + if (!child?.pid) return; |
| 138 | + if (process.platform === 'win32') { |
| 139 | + const taskkill = resolveCommand('taskkill.exe') ?? 'taskkill.exe'; |
| 140 | + spawnSync(taskkill, ['/PID', String(child.pid), '/T', '/F'], { |
| 141 | + stdio: 'ignore', |
| 142 | + shell: false, |
| 143 | + windowsHide: true |
| 144 | + }); |
| 145 | + return; |
| 146 | + } |
| 147 | + try { child.kill('SIGTERM'); } catch {} |
| 148 | + const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 5_000); |
| 149 | + timer.unref?.(); |
| 150 | +} |
| 151 | + |
| 152 | +export function formatDuration(milliseconds) { |
| 153 | + const seconds = Math.max(0, Math.floor(Number(milliseconds) / 1000)); |
| 154 | + const hours = Math.floor(seconds / 3600); |
| 155 | + const minutes = Math.floor((seconds % 3600) / 60); |
| 156 | + const remaining = seconds % 60; |
| 157 | + return hours ? `${hours}h ${String(minutes).padStart(2, '0')}m ${String(remaining).padStart(2, '0')}s` |
| 158 | + : `${minutes}m ${String(remaining).padStart(2, '0')}s`; |
| 159 | +} |
| 160 | + |
73 | 161 | export function git(root, args, fallback = null) { |
74 | | - const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); |
| 162 | + const executable = resolveCommand('git') ?? 'git'; |
| 163 | + const result = spawnSync(executable, ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: false }); |
75 | 164 | return result.status === 0 ? result.stdout.trim() : fallback; |
76 | 165 | } |
77 | 166 | export function sourceSnapshot(root) { |
|
0 commit comments