@bo-agent/pwsh-local 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/resolve.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PowerShell executable resolution, dependency-free so non-package consumers
3
+ * (the repository's coverage-gate probe in `vitest.config.ts`) can share the
4
+ * ONE resolution definition with the executor and its suites — a probe that
5
+ * resolved differently from the code under test could exempt a file whose
6
+ * suites actually run.
7
+ *
8
+ * @module @bo-agent/pwsh-local/resolve
9
+ */
10
+
11
+ import { lstatSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ /**
15
+ * Well-known Windows PowerShell install locations plus PATH entries, newest
16
+ * first. Explicitly parameterized (env) so resolution is a pure function of
17
+ * its inputs on every platform.
18
+ * @param env - the environment to probe; defaults to the process environment.
19
+ * @returns candidate `pwsh` executable paths in resolution order.
20
+ */
21
+ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
22
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
23
+ const systemRoot = env.SystemRoot ?? 'C:\\Windows'
24
+ const candidates = [
25
+ join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
26
+ ]
27
+ // Microsoft Store installs (and any user-added location) live on PATH;
28
+ // entries may carry surrounding quotes from `setx`-style definitions.
29
+ for (const entry of (env.PATH ?? '').split(';')) {
30
+ const trimmed = entry.trim().replace(/^"|"$/g, '')
31
+ if (trimmed.length === 0) continue
32
+ candidates.push(join(trimmed, 'pwsh.exe'))
33
+ }
34
+ // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
35
+ candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
36
+ return candidates
37
+ }
38
+
39
+ /**
40
+ * Whether a candidate can be spawned. lstat opens the entry itself instead of
41
+ * following reparse points, so it sees the Store app execution alias where
42
+ * stat hits the target's ACL (EACCES); Node reports that alias as a symlink
43
+ * on current releases and as a plain file on older ones, and CreateProcess
44
+ * resolves either shape. A real directory never matches.
45
+ */
46
+ function candidateExists(candidate: string): boolean {
47
+ try {
48
+ const stat = lstatSync(candidate)
49
+ return stat.isFile() || stat.isSymbolicLink()
50
+ } catch {
51
+ // ENOENT (the candidate vanished between listing and probing) is the only
52
+ // expected failure; any other error names an unspawnable path, so false
53
+ // is the safe answer for it too.
54
+ return false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Resolve the pwsh executable this executor spawns.
60
+ * @param configured - an explicit `pwshPath` config value, trusted as-is.
61
+ * @param env - the environment to probe on Windows; defaults to the process environment.
62
+ * @param platform - the platform to resolve for; defaults to the process platform.
63
+ * @returns the first existing well-known location on Windows (PowerShell 7
64
+ * install, a PATH entry such as the Microsoft Store install, then Windows
65
+ * PowerShell 5.1), else `pwsh` for PATH resolution.
66
+ */
67
+ export function resolvePwshPath(
68
+ configured?: string,
69
+ env: NodeJS.ProcessEnv = process.env,
70
+ platform: NodeJS.Platform = process.platform,
71
+ ): string {
72
+ if (configured !== undefined && configured.length > 0) return configured
73
+ if (platform === 'win32') {
74
+ for (const candidate of candidatePwshPaths(env)) {
75
+ if (candidateExists(candidate)) return candidate
76
+ }
77
+ }
78
+ return 'pwsh'
79
+ }
package/src/timeout.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Minimal timeout + deadline primitives used by the pwsh-local executor.
3
+ * Inlined here (rather than imported from a separate `@bo-agent/timeout`
4
+ * package) to keep the dependency surface small — same pattern as
5
+ * `@bo-agent/bash-local`.
6
+ *
7
+ * @module @bo-agent/pwsh-local/timeout
8
+ */
9
+
10
+ /**
11
+ * Largest value `setTimeout` can express as a 32-bit signed milliseconds
12
+ * delay. Anything larger would wrap to a near-immediate fire. Inlined to
13
+ * mirror Node's `TIMEOUT_MAX` and to keep the dependency surface small.
14
+ */
15
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647
16
+
17
+ /**
18
+ * Clamp a per-call value against a configured range. The default applies
19
+ * first; the maximum caps any explicit caller-supplied value.
20
+ * @param value - the requested value (may be undefined).
21
+ * @param defaultValue - the value used when none is requested.
22
+ * @param maxValue - the upper bound for explicit values.
23
+ * @param name - label prefixed to thrown errors.
24
+ * @returns a positive finite value within [1, maxValue].
25
+ * @throws Error when neither default nor requested value is a positive finite
26
+ * number, or when maxValue itself is invalid.
27
+ */
28
+ export function clampTimeout(
29
+ value: number | undefined,
30
+ defaultValue: number,
31
+ maxValue: number,
32
+ name: string,
33
+ ): number {
34
+ if (!Number.isFinite(maxValue) || maxValue <= 0) {
35
+ throw new Error(`${name}: maxValue must be a positive finite number`)
36
+ }
37
+ const candidate = value ?? defaultValue
38
+ if (!Number.isFinite(candidate) || candidate <= 0) {
39
+ throw new Error(`${name}: value must be a positive finite number`)
40
+ }
41
+ return Math.min(candidate, maxValue)
42
+ }
43
+
44
+ /** A symbol tagged on the AbortSignal's reason so the timeout reason is identifiable. */
45
+ export const TIMEOUT_REASON = Symbol.for('@bo-agent/pwsh-local/TIMEOUT_REASON')
46
+
47
+ /**
48
+ * Combine a caller's optional AbortSignal with a millisecond timeout into a
49
+ * single controller. Either side aborts the controller; disposal clears the
50
+ * timer and removes the upstream listener, so the result is safe to discard
51
+ * at end of scope via the `using` declaration.
52
+ */
53
+ export interface DeadlineHandle {
54
+ readonly signal: AbortSignal
55
+ [Symbol.dispose](): void
56
+ }
57
+
58
+ /**
59
+ * Build a deadline fused from the caller's signal and the executor's
60
+ * timeout. The returned controller aborts on whichever fires first; a
61
+ * successful command observes `signal.aborted === false`. The timeout
62
+ * reason is tagged so callers can tell timeout apart from upstream
63
+ * cancellation.
64
+ * @param signal - caller's upstream signal; may be undefined.
65
+ * @param timeoutMs - milliseconds until the timeout branch aborts.
66
+ * @param reason - human-readable label for the timeout reason; only used
67
+ * for tagging, never surfaced to the user.
68
+ */
69
+ export function deadline(
70
+ signal: AbortSignal | undefined,
71
+ timeoutMs: number,
72
+ reason: string,
73
+ ): DeadlineHandle {
74
+ const controller = new AbortController()
75
+ const onAbort = (): void => controller.abort(signal?.reason)
76
+ if (signal !== undefined) {
77
+ if (signal.aborted) {
78
+ controller.abort(signal.reason)
79
+ } else {
80
+ signal.addEventListener('abort', onAbort, { once: true })
81
+ }
82
+ }
83
+ // `setTimeout` cannot express delays above MAX_TIMER_DELAY_MS without
84
+ // wrapping; cap the request so the timeout branch actually fires when
85
+ // promised.
86
+ const safeTimeout = Math.min(timeoutMs, MAX_TIMER_DELAY_MS)
87
+ const timer = setTimeout(() => {
88
+ controller.abort(TIMEOUT_REASON)
89
+ }, safeTimeout)
90
+ return {
91
+ signal: controller.signal,
92
+ [Symbol.dispose](): void {
93
+ clearTimeout(timer)
94
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
95
+ },
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Return the tagged reason if the controller aborted because of the timeout
101
+ * branch (or upstream cancellation that mirrored it). Other abort reasons
102
+ * resolve as the caller's own cancellation; undefined means the controller
103
+ * is still live.
104
+ * @param signal - a controller's signal after a wait.
105
+ * @param expected - the label passed to {@link deadline}; matched against
106
+ * the symbol tag, so the timeout reason identifies itself.
107
+ */
108
+ export function timeoutOf(signal: AbortSignal, _expected: string): symbol | undefined {
109
+ if (!signal.aborted) return undefined
110
+ if (signal.reason === TIMEOUT_REASON) return TIMEOUT_REASON
111
+ return undefined
112
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2024",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "sourceMap": true,
8
+ "declarationMap": true,
9
+ "composite": true,
10
+ "incremental": true,
11
+ "skipLibCheck": true,
12
+ "esModuleInterop": true,
13
+ "allowImportingTsExtensions": true,
14
+ "rewriteRelativeImportExtensions": true,
15
+ "verbatimModuleSyntax": false,
16
+ "strict": true,
17
+ "noUncheckedIndexedAccess": true,
18
+ "exactOptionalPropertyTypes": true,
19
+ "noImplicitOverride": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUnusedLocals": false,
22
+ "noUnusedParameters": false,
23
+ "types": ["node"],
24
+ "outDir": "lib",
25
+ "rootDir": "src",
26
+ "tsBuildInfoFile": "lib/.tsbuildinfo",
27
+ "paths": {
28
+ "@bo-agent/inline": ["../inline/src"],
29
+ "@bo-agent/shell": ["../shell/src"],
30
+ "@bo-agent/subprocess": ["../subprocess/src"]
31
+ }
32
+ },
33
+ "include": ["src"],
34
+ "references": [
35
+ { "path": "../inline" },
36
+ { "path": "../subprocess" },
37
+ { "path": "../shell" }
38
+ ]
39
+ }