mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* perf(claude): run the Windows Claude hook as a native orca-hook.exe
Each Claude hook event on Windows started cmd.exe (plus the user's cmd
AutoRun) and then curl.exe on top of the agent's own shell. A small signed
Rust binary now does the same synchronous POST in one process, under Git
Bash, PowerShell and direct CreateProcess alike.
- native/windows-agent-hook: std-only crate, same contract as the .cmd hook
(prints {}, env guards before stdin, endpoint re-read, 0.5s/1.5s caps,
waits for the listener reply, always exit 0). Its installed file name
selects the agent profile, so the registration stays a bare path.
- Packaged as resources/bin/orca-hook.exe (SignPath signs it with the other
inner PEs); afterPack fails on a missing or wrong-machine binary.
- Claude install copies it verbatim to ~/.orca/agent-hooks/claude-hook.exe
(temp + rename, running copy moved aside) and registers it; it falls back
to the .cmd hook when the bundle is missing, unsigned or cannot be copied.
Only Claude opts in, via windowsNativeHookExecutable.
- The managed-command matcher sweeps .exe entries alongside .cmd/.ps1/.sh.
- The CLI launcher and hook builds share windows-rust-executable-build.mjs,
which now also checks the PE machine.
Refs #20913
* fix(build): publish native Windows binaries atomically so parallel builds cannot collide
The Windows CI lane runs every orca-hook.exe suite in one vitest
invocation, each building through build-windows-agent-hook.mjs. The build
copied straight onto native/windows-agent-hook/.build/orca-hook.exe, and
CopyFile opens its destination unshared, so a second build (or a scanner
still holding the fresh image) failed with EBUSY.
- windows-rust-executable-build.mjs skips the publish when the output
already has the built bytes, otherwise stages a per-process temp file and
renames it over the output, retrying briefly while another handle holds it.
- The test fixture hands each suite a private copy of the build output, so
no suite reads or starts the shared file while another build replaces it.
Refs #20913
* refactor(claude): simplify the native Windows hook to one Claude binary
Review follow-up on the native hook (#20913): the same behaviour with less
machinery.
- The binary is built, shipped and installed as claude-hook.exe, so the
installed name matches its PE OriginalFilename. It is Claude-only, so the
file-name profile table and the per-agent opt-in flag are gone; the
registration is gated by the existing Claude-only usesWindowsEntry check.
- Install and refresh reuse writeManagedScript / refreshManagedScriptIfPresent
(now accepting bytes): temp file, rename, skip when identical. A copy whose
hook is still running cannot be replaced; install then registers the .cmd
hook and refresh keeps the old build until a later start, instead of the
rename-aside and *.retired sweep.
- The install-time Authenticode-presence check is dropped. Release CI now
requires resources\bin\claude-hook.exe to be signed (release-cut and the
signing rehearsal), next to resources\bin\orca.exe.
- getStatus counts either Windows form through findManagedEventHookForms in
hook-settings.ts, replacing managed-event-hook-status.ts.
- Rust: plain Claude constants, one endpoint-file lookup (last assignment
wins, as under `call`), one request write and a deadline-bounded drain.
Guard order and form encoding are unchanged.
- Build: PR CI builds the hook once before the Windows tests, so the suites
only read it; the launcher publish goes back to copyFileSync and the retry
loop and its test are gone. The PE-machine check lives only in afterPack.
Cargo.lock joins both crates' build fingerprints, and the launcher's alias
exports are removed.
Refs #20913
* test(ci): run the managed hook stdin-lifecycle suite in the Windows lane
Its claude-hook.exe case (and the other Windows-only cases) are gated per
test, so the lane-registration guard did not require the file and it ran
only on Linux, where those cases skip. Register it in both Windows lists.
Refs #20913
* feat(agent-hooks): ship the native hook as orca-agent-hook.exe <agent>
The native Windows hook (#20913) becomes one general binary that stays
Claude-only in behaviour.
- Named orca-agent-hook.exe everywhere: build output, PE OriginalFilename
and InternalName, resources\bin, the afterPack check, the installed copy
in ~/.orca/agent-hooks, and the release signed-target lists.
- It takes the agent as its first argument and Claude registers
`<path> claude`. The CLAUDE_JOB_DIR exit and the Grok/Devin drain belong
to that agent; an unknown or missing agent answers {} without reading
stdin. The managed-command matcher owns `orca-agent-hook.exe claude` and
still owns the old claude-hook.exe entry, so installs migrate; install
deletes a leftover claude-hook.exe when nothing runs it.
- A copy still running from the previous build no longer forces the .cmd
fallback. Identical bytes are not rewritten; otherwise the temp file is
renamed over the target, and when Windows refuses because the image is
mapped, the running copy is renamed to orca-agent-hook.exe.old first.
Install deletes a stale .old; only when that copy is also still running
does the install fall back to .cmd. writeManagedScript and
refreshManagedScriptIfPresent take the aside path, sharing one helper.
Refs #20913
* fix(agent-hooks): roll back a failed aside rename and keep an installed hook exe
Design-review follow-up on the native hook (#20913).
- If the running copy was moved to orca-agent-hook.exe.old but the new
file then could not take its name, the copy is renamed back before the
error is rethrown, so settings.json never points at a missing exe. The
rename helper now owns the `.old` name, next to the comment that
explains it; a mocked-fs unit test covers both the sync and async paths.
- A failed write, or a build with no bundled exe, keeps registering a copy
that is already installed (every build knows `claude`), so a dev checkout
and an installed build no longer flip settings.json between forms.
- The claude-hook.exe migration is gone: no release ever shipped that
name, so the legacy sweep, the `.exe` matcher needle and their test are
removed.
Refs #20913
* fix(agent-hooks): derive the native hook's agent with win32 path rules
getWindowsNativeHookAgent took the host's basename of the managed script
path, so on a POSIX host (Linux CI) a Windows path was not split and the
registered command became `orca-agent-hook.exe C:\...\claude`. It now takes
the path and applies path.win32.basename itself. The install destination
and the `.old` aside stay on host paths: they name files the host opens.
Refs #20913
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
utimesSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
shouldReuseCompiledWindowsRustExecutable,
|
|
windowsRustExecutableFileVersion,
|
|
windowsRustExecutableTarget,
|
|
windowsRustExecutableFingerprint
|
|
} from './windows-rust-executable-build.mjs'
|
|
import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs'
|
|
|
|
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
|
const projectRoot = resolve(import.meta.dirname, '../..')
|
|
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
|
|
|
|
// Why: Windows releases the image handle on a just-executed exe (and finishes the
|
|
// AV scan of the freshly compiled one) after the process exits, so tearing down the
|
|
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
|
|
// teardown lock as a launcher failure.
|
|
function removeFixtureTree(path) {
|
|
try {
|
|
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
|
} catch (error) {
|
|
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
// Why: a cold cargo build compiles the resource crate and links from scratch,
|
|
// which far exceeds Vitest's 5s unit budget on a hosted Windows runner. Later
|
|
// cases reuse the shared target directory, so only the first pays it.
|
|
function itWindows(name, test) {
|
|
const runner = process.platform === 'win32' ? it : it.skip
|
|
runner(name, { timeout: 300_000 }, test)
|
|
}
|
|
|
|
describe('Windows CLI launcher', () => {
|
|
it('reuses restored builds only while all embedded inputs and the release version match', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
|
|
try {
|
|
const inputs = ['main.rs', 'build.rs', 'Cargo.toml', 'app.manifest', 'icon.ico'].map(
|
|
(name) => {
|
|
const path = join(root, name)
|
|
writeFileSync(path, name)
|
|
return path
|
|
}
|
|
)
|
|
const outputPath = join(root, 'orca.exe')
|
|
const fingerprint = windowsRustExecutableFingerprint(inputs, '1.4.214')
|
|
expect(shouldReuseCompiledWindowsRustExecutable(outputPath, fingerprint)).toBe(false)
|
|
writeFileSync(outputPath, 'binary')
|
|
expect(shouldReuseCompiledWindowsRustExecutable(outputPath, fingerprint)).toBe(false)
|
|
writeFileSync(`${outputPath}.sha256`, fingerprint)
|
|
for (const input of inputs) {
|
|
utimesSync(input, new Date(), new Date())
|
|
}
|
|
expect(
|
|
shouldReuseCompiledWindowsRustExecutable(
|
|
outputPath,
|
|
windowsRustExecutableFingerprint(inputs, '1.4.214')
|
|
)
|
|
).toBe(true)
|
|
expect(
|
|
shouldReuseCompiledWindowsRustExecutable(
|
|
outputPath,
|
|
windowsRustExecutableFingerprint(inputs, '1.4.215')
|
|
)
|
|
).toBe(false)
|
|
for (const input of inputs) {
|
|
const original = readFileSync(input)
|
|
writeFileSync(input, 'changed')
|
|
expect(
|
|
shouldReuseCompiledWindowsRustExecutable(
|
|
outputPath,
|
|
windowsRustExecutableFingerprint(inputs, '1.4.214')
|
|
)
|
|
).toBe(false)
|
|
writeFileSync(input, original)
|
|
}
|
|
} finally {
|
|
removeFixtureTree(root)
|
|
}
|
|
})
|
|
|
|
it('reduces a prerelease to the numeric version Windows can record', () => {
|
|
expect(windowsRustExecutableFileVersion('1.4.214-daily.202609281300')).toBe('1.4.214.0')
|
|
expect(windowsRustExecutableFileVersion('1.4.214')).toBe('1.4.214.0')
|
|
for (const version of ['1.4.65535', '1.4', '1.4.214"', undefined]) {
|
|
expect(() => windowsRustExecutableFileVersion(version)).toThrow('Invalid Windows')
|
|
}
|
|
})
|
|
|
|
itWindows(
|
|
'embeds publisher, release version, icon and an unelevated application manifest',
|
|
() => {
|
|
const root = mkdtempSync(join(tmpdir(), 'orca launcher metadata '))
|
|
try {
|
|
const launcherPath = join(root, 'orca.exe')
|
|
const build = spawnSync(
|
|
process.execPath,
|
|
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
|
{ cwd: projectRoot, encoding: 'utf8' }
|
|
)
|
|
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
|
const inspect = spawnSync(
|
|
'powershell.exe',
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
'[Diagnostics.FileVersionInfo]::GetVersionInfo($env:ORCA_TEST_LAUNCHER) | ConvertTo-Json -Compress'
|
|
],
|
|
{ encoding: 'utf8', env: { ...process.env, ORCA_TEST_LAUNCHER: launcherPath } }
|
|
)
|
|
expect(inspect.status, inspect.stderr).toBe(0)
|
|
const info = JSON.parse(inspect.stdout)
|
|
const { version } = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'))
|
|
expect(info.CompanyName).toBe('Stably AI')
|
|
expect(info.ProductName).toBe('Orca')
|
|
expect(info.FileDescription).toBe('Orca CLI Launcher')
|
|
expect(info.FileVersion).toBe(`${version.split(/[+-]/)[0]}.0`)
|
|
expect(info.ProductVersion).toBe(version)
|
|
const binary = readFileSync(launcherPath)
|
|
expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true)
|
|
const imports = readPeImportedDllNames(binary)
|
|
expect(imports.map((name) => name.toLowerCase())).toContain('kernel32.dll')
|
|
expect(findDynamicVcRuntimeImports(imports)).toEqual([])
|
|
const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico'))
|
|
const imageSize = icon.readUInt32LE(14)
|
|
const imageOffset = icon.readUInt32LE(18)
|
|
expect(binary.includes(icon.subarray(imageOffset, imageOffset + imageSize))).toBe(true)
|
|
} finally {
|
|
removeFixtureTree(root)
|
|
}
|
|
}
|
|
)
|
|
|
|
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
|
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
|
|
try {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')],
|
|
{ cwd: projectRoot, encoding: 'utf8' }
|
|
)
|
|
|
|
expect(result.status).not.toBe(0)
|
|
expect(result.stderr).toContain('Windows CLI launcher')
|
|
expect(result.stderr).toContain('Windows host')
|
|
} finally {
|
|
removeFixtureTree(outputRoot)
|
|
}
|
|
})
|
|
|
|
itCrossHost('never hands the child an explicit environment map', () => {
|
|
// Why: setting any entry on the child's environment makes the spawn build its own
|
|
// block from a case-insensitive map, which collapses an inherited PATH and Path
|
|
// into one entry and killed the CLI (stablyai/orca#12046). Mutating this process
|
|
// and leaving the map untouched passes the block through verbatim.
|
|
const source = readFileSync(
|
|
join(projectRoot, 'native', 'windows-cli-launcher', 'src', 'main.rs'),
|
|
'utf8'
|
|
)
|
|
const code = source.replace(/^\s*\/\/.*$/gm, '')
|
|
|
|
expect(code).not.toMatch(/\.envs?\(/u)
|
|
expect(code).not.toContain('env_clear')
|
|
expect(code).toContain('env::set_var')
|
|
})
|
|
|
|
itCrossHost('never reintroduces a managed launcher alongside the native one', () => {
|
|
// Why: the MSIL image is what vendors flagged (stablyai/orca#23383). A stray .cs
|
|
// left in the crate would compile back into the shape the rewrite removed.
|
|
expect(
|
|
existsSync(join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'))
|
|
).toBe(false)
|
|
})
|
|
|
|
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
|
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
|
try {
|
|
const resourcesPath = join(appRoot, 'resources')
|
|
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
|
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
|
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
|
|
mkdirSync(dirname(cliPath), { recursive: true })
|
|
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
|
writeFileSync(
|
|
cliPath,
|
|
`process.stdout.write(JSON.stringify({
|
|
argv: process.argv.slice(2),
|
|
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
|
nodeOptions: process.env.NODE_OPTIONS ?? null,
|
|
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null
|
|
}))\n`,
|
|
'utf8'
|
|
)
|
|
|
|
const build = spawnSync(
|
|
process.execPath,
|
|
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
|
{ cwd: projectRoot, encoding: 'utf8' }
|
|
)
|
|
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
|
|
|
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
|
|
const powershell = spawnSync(
|
|
'powershell.exe',
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
NODE_OPTIONS: '--no-warnings',
|
|
ORCA_TEST_BODY: body,
|
|
ORCA_TEST_LAUNCHER: launcherPath
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(powershell.status, powershell.stderr).toBe(0)
|
|
expect(JSON.parse(powershell.stdout)).toEqual({
|
|
argv: ['orchestration', 'send', '--body', body, '--json'],
|
|
electronRunAsNode: '1',
|
|
nodeOptions: null,
|
|
orcaNodeOptions: '--no-warnings'
|
|
})
|
|
} finally {
|
|
removeFixtureTree(appRoot)
|
|
}
|
|
})
|
|
|
|
it('cross-compiles only the architectures the orcad template ships', () => {
|
|
expect(windowsRustExecutableTarget(undefined)).toBeNull()
|
|
expect(windowsRustExecutableTarget('x64')).toBe('x86_64-pc-windows-msvc')
|
|
expect(windowsRustExecutableTarget('arm64')).toBe('aarch64-pc-windows-msvc')
|
|
expect(() => windowsRustExecutableTarget('ia32')).toThrow('Unsupported')
|
|
})
|
|
|
|
itWindows('runs a server profile CLI on pinned Node with argv and profile intact', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'orca server launcher '))
|
|
try {
|
|
const profile = join(root, 'server profile')
|
|
const launcherPath = join(profile, 'cli', 'bin', 'orca.exe')
|
|
const cliPath = join(root, 'server slot', 'out', 'cli', 'index.js')
|
|
mkdirSync(dirname(cliPath), { recursive: true })
|
|
writeFileSync(
|
|
cliPath,
|
|
`process.stdout.write(JSON.stringify({
|
|
argv: process.argv.slice(2),
|
|
profile: process.env.ORCA_USER_DATA_PATH,
|
|
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null,
|
|
nodeOptions: process.env.NODE_OPTIONS ?? null
|
|
}))\n`,
|
|
'utf8'
|
|
)
|
|
const build = spawnSync(
|
|
process.execPath,
|
|
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
|
{ cwd: projectRoot, encoding: 'utf8' }
|
|
)
|
|
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
|
writeFileSync(
|
|
join(dirname(launcherPath), 'orca-launcher.cfg'),
|
|
`node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n`
|
|
)
|
|
|
|
const inherited = {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
NODE_OPTIONS: '--require C:\\missing\\preload.js',
|
|
ORCA_USER_DATA_PATH: 'C:\\another\\owner',
|
|
ORCA_TEST_LAUNCHER: launcherPath
|
|
}
|
|
const expected = (body) => ({
|
|
argv: ['orchestration', 'send', '--body', body, '--json'],
|
|
profile,
|
|
electronRunAsNode: null,
|
|
nodeOptions: null
|
|
})
|
|
// Windows PowerShell 5.1 itself drops embedded double quotes, so its body has none.
|
|
const shellBody = 'line one & %PATH% ^caret\n\nline three'
|
|
const powershell = spawnSync(
|
|
'powershell.exe',
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
|
|
],
|
|
{ encoding: 'utf8', env: { ...inherited, ORCA_TEST_BODY: shellBody } }
|
|
)
|
|
expect(powershell.status, powershell.stderr).toBe(0)
|
|
expect(JSON.parse(powershell.stdout)).toEqual(expected(shellBody))
|
|
|
|
const body = 'line one "quoted" \\"escaped\\" & %PATH% ^caret\\\n\nline three\\'
|
|
const direct = spawnSync(launcherPath, expected(body).argv, {
|
|
encoding: 'utf8',
|
|
env: inherited
|
|
})
|
|
expect(direct.status, direct.stderr).toBe(0)
|
|
expect(JSON.parse(direct.stdout)).toEqual(expected(body))
|
|
} finally {
|
|
removeFixtureTree(root)
|
|
}
|
|
})
|
|
|
|
itWindows('survives an inherited environment block containing PATH and Path', () => {
|
|
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
|
|
try {
|
|
const resourcesPath = join(appRoot, 'resources')
|
|
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
|
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
|
const outputPath = join(appRoot, 'child-result.json')
|
|
const harnessSourcePath = join(
|
|
projectRoot,
|
|
'config',
|
|
'scripts',
|
|
'fixtures',
|
|
'DuplicatePathProcessLauncher.cs'
|
|
)
|
|
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
|
|
mkdirSync(dirname(launcherPath), { recursive: true })
|
|
mkdirSync(dirname(cliPath), { recursive: true })
|
|
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
|
writeFileSync(
|
|
cliPath,
|
|
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
|
|
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
|
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
|
|
}))\n`,
|
|
'utf8'
|
|
)
|
|
const build = spawnSync(
|
|
process.execPath,
|
|
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
|
{ cwd: projectRoot, encoding: 'utf8' }
|
|
)
|
|
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
|
|
|
const compiler = findFrameworkCompiler()
|
|
expect(compiler).not.toBeNull()
|
|
const compileHarness = spawnSync(
|
|
compiler,
|
|
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
|
|
{ encoding: 'utf8' }
|
|
)
|
|
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
|
|
|
|
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
|
|
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
|
|
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
|
|
electronRunAsNode: '1',
|
|
pathKeys: ['PATH', 'Path']
|
|
})
|
|
} finally {
|
|
removeFixtureTree(appRoot)
|
|
}
|
|
})
|
|
})
|
|
|
|
function findFrameworkCompiler() {
|
|
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
|
|
if (!windowsDirectory) {
|
|
return null
|
|
}
|
|
return (
|
|
[
|
|
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
|
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
|
|
].find((candidate) => existsSync(candidate)) ?? null
|
|
)
|
|
}
|