@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.
@@ -0,0 +1,28 @@
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
+ * Well-known Windows PowerShell install locations plus PATH entries, newest
12
+ * first. Explicitly parameterized (env) so resolution is a pure function of
13
+ * its inputs on every platform.
14
+ * @param env - the environment to probe; defaults to the process environment.
15
+ * @returns candidate `pwsh` executable paths in resolution order.
16
+ */
17
+ export declare function candidatePwshPaths(env?: NodeJS.ProcessEnv): string[];
18
+ /**
19
+ * Resolve the pwsh executable this executor spawns.
20
+ * @param configured - an explicit `pwshPath` config value, trusted as-is.
21
+ * @param env - the environment to probe on Windows; defaults to the process environment.
22
+ * @param platform - the platform to resolve for; defaults to the process platform.
23
+ * @returns the first existing well-known location on Windows (PowerShell 7
24
+ * install, a PATH entry such as the Microsoft Store install, then Windows
25
+ * PowerShell 5.1), else `pwsh` for PATH resolution.
26
+ */
27
+ export declare function resolvePwshPath(configured?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
28
+ //# sourceMappingURL=resolve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,EAAE,CAgBjF;AAqBD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,UAAU,CAAC,EAAE,MAAM,EACnB,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,MAAM,CAQR"}
package/lib/resolve.js ADDED
@@ -0,0 +1,76 @@
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
+ import { lstatSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ /**
13
+ * Well-known Windows PowerShell install locations plus PATH entries, newest
14
+ * first. Explicitly parameterized (env) so resolution is a pure function of
15
+ * its inputs on every platform.
16
+ * @param env - the environment to probe; defaults to the process environment.
17
+ * @returns candidate `pwsh` executable paths in resolution order.
18
+ */
19
+ export function candidatePwshPaths(env = process.env) {
20
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files';
21
+ const systemRoot = env.SystemRoot ?? 'C:\\Windows';
22
+ const candidates = [
23
+ join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
24
+ ];
25
+ // Microsoft Store installs (and any user-added location) live on PATH;
26
+ // entries may carry surrounding quotes from `setx`-style definitions.
27
+ for (const entry of (env.PATH ?? '').split(';')) {
28
+ const trimmed = entry.trim().replace(/^"|"$/g, '');
29
+ if (trimmed.length === 0)
30
+ continue;
31
+ candidates.push(join(trimmed, 'pwsh.exe'));
32
+ }
33
+ // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
34
+ candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'));
35
+ return candidates;
36
+ }
37
+ /**
38
+ * Whether a candidate can be spawned. lstat opens the entry itself instead of
39
+ * following reparse points, so it sees the Store app execution alias where
40
+ * stat hits the target's ACL (EACCES); Node reports that alias as a symlink
41
+ * on current releases and as a plain file on older ones, and CreateProcess
42
+ * resolves either shape. A real directory never matches.
43
+ */
44
+ function candidateExists(candidate) {
45
+ try {
46
+ const stat = lstatSync(candidate);
47
+ return stat.isFile() || stat.isSymbolicLink();
48
+ }
49
+ catch {
50
+ // ENOENT (the candidate vanished between listing and probing) is the only
51
+ // expected failure; any other error names an unspawnable path, so false
52
+ // is the safe answer for it too.
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Resolve the pwsh executable this executor spawns.
58
+ * @param configured - an explicit `pwshPath` config value, trusted as-is.
59
+ * @param env - the environment to probe on Windows; defaults to the process environment.
60
+ * @param platform - the platform to resolve for; defaults to the process platform.
61
+ * @returns the first existing well-known location on Windows (PowerShell 7
62
+ * install, a PATH entry such as the Microsoft Store install, then Windows
63
+ * PowerShell 5.1), else `pwsh` for PATH resolution.
64
+ */
65
+ export function resolvePwshPath(configured, env = process.env, platform = process.platform) {
66
+ if (configured !== undefined && configured.length > 0)
67
+ return configured;
68
+ if (platform === 'win32') {
69
+ for (const candidate of candidatePwshPaths(env)) {
70
+ if (candidateExists(candidate))
71
+ return candidate;
72
+ }
73
+ }
74
+ return 'pwsh';
75
+ }
76
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACnC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAyB,OAAO,CAAC,GAAG;IACrE,MAAM,YAAY,GAAG,GAAG,CAAC,YAAY,IAAI,mBAAmB,CAAA;IAC5D,MAAM,UAAU,GAAG,GAAG,CAAC,UAAU,IAAI,aAAa,CAAA;IAClD,MAAM,UAAU,GAAG;QACjB,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,GAAG,EAAE,UAAU,CAAC;KAClD,CAAA;IACD,uEAAuE;IACvE,sEAAsE;IACtE,KAAK,MAAM,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAChD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;QAClD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAQ;QAClC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC,CAAA;IAC5C,CAAC;IACD,2EAA2E;IAC3E,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAA;IAC5F,OAAO,UAAU,CAAA;AACnB,CAAC;AAED;;;;;;GAMG;AACH,SAAS,eAAe,CAAC,SAAiB;IACxC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,SAAS,CAAC,SAAS,CAAC,CAAA;QACjC,OAAO,IAAI,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,cAAc,EAAE,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,0EAA0E;QAC1E,wEAAwE;QACxE,iCAAiC;QACjC,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAC7B,UAAmB,EACnB,MAAyB,OAAO,CAAC,GAAG,EACpC,WAA4B,OAAO,CAAC,QAAQ;IAE5C,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,UAAU,CAAA;IACxE,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QACzB,KAAK,MAAM,SAAS,IAAI,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;YAChD,IAAI,eAAe,CAAC,SAAS,CAAC;gBAAE,OAAO,SAAS,CAAA;QAClD,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -0,0 +1,61 @@
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
+ * Largest value `setTimeout` can express as a 32-bit signed milliseconds
11
+ * delay. Anything larger would wrap to a near-immediate fire. Inlined to
12
+ * mirror Node's `TIMEOUT_MAX` and to keep the dependency surface small.
13
+ */
14
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
15
+ /**
16
+ * Clamp a per-call value against a configured range. The default applies
17
+ * first; the maximum caps any explicit caller-supplied value.
18
+ * @param value - the requested value (may be undefined).
19
+ * @param defaultValue - the value used when none is requested.
20
+ * @param maxValue - the upper bound for explicit values.
21
+ * @param name - label prefixed to thrown errors.
22
+ * @returns a positive finite value within [1, maxValue].
23
+ * @throws Error when neither default nor requested value is a positive finite
24
+ * number, or when maxValue itself is invalid.
25
+ */
26
+ export declare function clampTimeout(value: number | undefined, defaultValue: number, maxValue: number, name: string): number;
27
+ /** A symbol tagged on the AbortSignal's reason so the timeout reason is identifiable. */
28
+ export declare const TIMEOUT_REASON: unique symbol;
29
+ /**
30
+ * Combine a caller's optional AbortSignal with a millisecond timeout into a
31
+ * single controller. Either side aborts the controller; disposal clears the
32
+ * timer and removes the upstream listener, so the result is safe to discard
33
+ * at end of scope via the `using` declaration.
34
+ */
35
+ export interface DeadlineHandle {
36
+ readonly signal: AbortSignal;
37
+ [Symbol.dispose](): void;
38
+ }
39
+ /**
40
+ * Build a deadline fused from the caller's signal and the executor's
41
+ * timeout. The returned controller aborts on whichever fires first; a
42
+ * successful command observes `signal.aborted === false`. The timeout
43
+ * reason is tagged so callers can tell timeout apart from upstream
44
+ * cancellation.
45
+ * @param signal - caller's upstream signal; may be undefined.
46
+ * @param timeoutMs - milliseconds until the timeout branch aborts.
47
+ * @param reason - human-readable label for the timeout reason; only used
48
+ * for tagging, never surfaced to the user.
49
+ */
50
+ export declare function deadline(signal: AbortSignal | undefined, timeoutMs: number, reason: string): DeadlineHandle;
51
+ /**
52
+ * Return the tagged reason if the controller aborted because of the timeout
53
+ * branch (or upstream cancellation that mirrored it). Other abort reasons
54
+ * resolve as the caller's own cancellation; undefined means the controller
55
+ * is still live.
56
+ * @param signal - a controller's signal after a wait.
57
+ * @param expected - the label passed to {@link deadline}; matched against
58
+ * the symbol tag, so the timeout reason identifies itself.
59
+ */
60
+ export declare function timeoutOf(signal: AbortSignal, _expected: string): symbol | undefined;
61
+ //# sourceMappingURL=timeout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeout.d.ts","sourceRoot":"","sources":["../src/timeout.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAAgB,CAAA;AAE/C;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACX,MAAM,CASR;AAED,yFAAyF;AACzF,eAAO,MAAM,cAAc,eAAoD,CAAA;AAE/E;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;IAC5B,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CACtB,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GACb,cAAc,CAwBhB;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIpF"}
package/lib/timeout.js ADDED
@@ -0,0 +1,92 @@
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
+ * Largest value `setTimeout` can express as a 32-bit signed milliseconds
11
+ * delay. Anything larger would wrap to a near-immediate fire. Inlined to
12
+ * mirror Node's `TIMEOUT_MAX` and to keep the dependency surface small.
13
+ */
14
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
15
+ /**
16
+ * Clamp a per-call value against a configured range. The default applies
17
+ * first; the maximum caps any explicit caller-supplied value.
18
+ * @param value - the requested value (may be undefined).
19
+ * @param defaultValue - the value used when none is requested.
20
+ * @param maxValue - the upper bound for explicit values.
21
+ * @param name - label prefixed to thrown errors.
22
+ * @returns a positive finite value within [1, maxValue].
23
+ * @throws Error when neither default nor requested value is a positive finite
24
+ * number, or when maxValue itself is invalid.
25
+ */
26
+ export function clampTimeout(value, defaultValue, maxValue, name) {
27
+ if (!Number.isFinite(maxValue) || maxValue <= 0) {
28
+ throw new Error(`${name}: maxValue must be a positive finite number`);
29
+ }
30
+ const candidate = value ?? defaultValue;
31
+ if (!Number.isFinite(candidate) || candidate <= 0) {
32
+ throw new Error(`${name}: value must be a positive finite number`);
33
+ }
34
+ return Math.min(candidate, maxValue);
35
+ }
36
+ /** A symbol tagged on the AbortSignal's reason so the timeout reason is identifiable. */
37
+ export const TIMEOUT_REASON = Symbol.for('@bo-agent/pwsh-local/TIMEOUT_REASON');
38
+ /**
39
+ * Build a deadline fused from the caller's signal and the executor's
40
+ * timeout. The returned controller aborts on whichever fires first; a
41
+ * successful command observes `signal.aborted === false`. The timeout
42
+ * reason is tagged so callers can tell timeout apart from upstream
43
+ * cancellation.
44
+ * @param signal - caller's upstream signal; may be undefined.
45
+ * @param timeoutMs - milliseconds until the timeout branch aborts.
46
+ * @param reason - human-readable label for the timeout reason; only used
47
+ * for tagging, never surfaced to the user.
48
+ */
49
+ export function deadline(signal, timeoutMs, reason) {
50
+ const controller = new AbortController();
51
+ const onAbort = () => controller.abort(signal?.reason);
52
+ if (signal !== undefined) {
53
+ if (signal.aborted) {
54
+ controller.abort(signal.reason);
55
+ }
56
+ else {
57
+ signal.addEventListener('abort', onAbort, { once: true });
58
+ }
59
+ }
60
+ // `setTimeout` cannot express delays above MAX_TIMER_DELAY_MS without
61
+ // wrapping; cap the request so the timeout branch actually fires when
62
+ // promised.
63
+ const safeTimeout = Math.min(timeoutMs, MAX_TIMER_DELAY_MS);
64
+ const timer = setTimeout(() => {
65
+ controller.abort(TIMEOUT_REASON);
66
+ }, safeTimeout);
67
+ return {
68
+ signal: controller.signal,
69
+ [Symbol.dispose]() {
70
+ clearTimeout(timer);
71
+ if (signal !== undefined)
72
+ signal.removeEventListener('abort', onAbort);
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Return the tagged reason if the controller aborted because of the timeout
78
+ * branch (or upstream cancellation that mirrored it). Other abort reasons
79
+ * resolve as the caller's own cancellation; undefined means the controller
80
+ * is still live.
81
+ * @param signal - a controller's signal after a wait.
82
+ * @param expected - the label passed to {@link deadline}; matched against
83
+ * the symbol tag, so the timeout reason identifies itself.
84
+ */
85
+ export function timeoutOf(signal, _expected) {
86
+ if (!signal.aborted)
87
+ return undefined;
88
+ if (signal.reason === TIMEOUT_REASON)
89
+ return TIMEOUT_REASON;
90
+ return undefined;
91
+ }
92
+ //# sourceMappingURL=timeout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeout.js","sourceRoot":"","sources":["../src/timeout.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,aAAa,CAAA;AAE/C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,YAAY,CAC1B,KAAyB,EACzB,YAAoB,EACpB,QAAgB,EAChB,IAAY;IAEZ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,CAAC,EAAE,CAAC;QAChD,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,6CAA6C,CAAC,CAAA;IACvE,CAAC;IACD,MAAM,SAAS,GAAG,KAAK,IAAI,YAAY,CAAA;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,IAAI,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,0CAA0C,CAAC,CAAA;IACpE,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;AACtC,CAAC;AAED,yFAAyF;AACzF,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,GAAG,CAAC,qCAAqC,CAAC,CAAA;AAa/E;;;;;;;;;;GAUG;AACH,MAAM,UAAU,QAAQ,CACtB,MAA+B,EAC/B,SAAiB,EACjB,MAAc;IAEd,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAA;IACxC,MAAM,OAAO,GAAG,GAAS,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5D,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;QACjC,CAAC;aAAM,CAAC;YACN,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;QAC3D,CAAC;IACH,CAAC;IACD,sEAAsE;IACtE,sEAAsE;IACtE,YAAY;IACZ,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,kBAAkB,CAAC,CAAA;IAC3D,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;QAC5B,UAAU,CAAC,KAAK,CAAC,cAAc,CAAC,CAAA;IAClC,CAAC,EAAE,WAAW,CAAC,CAAA;IACf,OAAO;QACL,MAAM,EAAE,UAAU,CAAC,MAAM;QACzB,CAAC,MAAM,CAAC,OAAO,CAAC;YACd,YAAY,CAAC,KAAK,CAAC,CAAA;YACnB,IAAI,MAAM,KAAK,SAAS;gBAAE,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QACxE,CAAC;KACF,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CAAC,MAAmB,EAAE,SAAiB;IAC9D,IAAI,CAAC,MAAM,CAAC,OAAO;QAAE,OAAO,SAAS,CAAA;IACrC,IAAI,MAAM,CAAC,MAAM,KAAK,cAAc;QAAE,OAAO,cAAc,CAAA;IAC3D,OAAO,SAAS,CAAA;AAClB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@bo-agent/pwsh-local",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "./lib/index.js",
6
+ "types": "./lib/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "import": "./lib/index.js"
11
+ },
12
+ "./resolve": {
13
+ "types": "./lib/resolve.d.ts",
14
+ "import": "./lib/resolve.js"
15
+ }
16
+ },
17
+ "dependencies": {
18
+ "cordis": "4.0.0-rc.10",
19
+ "schemastery": "3.18.0",
20
+ "@bo-agent/inline": "0.0.2",
21
+ "@bo-agent/shell": "0.0.2",
22
+ "@bo-agent/subprocess": "0.0.2"
23
+ },
24
+ "license": "MIT"
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Local PowerShell Service Provider for the pwsh capability seam. Each
3
+ * command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command
4
+ * <command>` in a managed process spawned through `ctx.subprocess`; the
5
+ * executor owns command defaulting, deadlines and cause classification, the
6
+ * model-friendly terminal environment, and the model-facing stdout/stderr
7
+ * merge for background reads.
8
+ *
9
+ * The command string is passed as ONE argv element to `-Command`: PowerShell
10
+ * itself parses the text, and no intermediate shell exists, so there is no
11
+ * shell-quoting layer to escape (the `bash -c` string domain has no
12
+ * equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
13
+ *
14
+ * @module @bo-agent/pwsh-local
15
+ */
16
+
17
+ /* jscpd:ignore-start -- this executor mirrors bash-local call-for-call by
18
+ design (see upstream README), so the two import the same seam surface */
19
+ import { Context } from 'cordis'
20
+ import z from 'schemastery'
21
+ import { ShellExecutor } from '@bo-agent/shell'
22
+ import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@bo-agent/shell'
23
+ import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@bo-agent/subprocess'
24
+ import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from './timeout.ts'
25
+ /* jscpd:ignore-end */
26
+ import { resolvePwshPath } from './resolve.ts'
27
+
28
+ /* jscpd:ignore-start -- deliberate call-for-call mirror of bash-local (pwsh-tool-and-executor). */
29
+ /**
30
+ * Model-friendly environment overrides for PowerShell: disable colors and
31
+ * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
32
+ * deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
33
+ */
34
+ export const ENV_OVERRIDES = {
35
+ NO_COLOR: '1',
36
+ PAGER: 'cat',
37
+ GIT_PAGER: 'cat',
38
+ } as const
39
+
40
+ /**
41
+ * UTF-8 output pinning prepended to every command. The subprocess collector
42
+ * decodes output bytes as UTF-8, but Windows PowerShell 5.1 (the last-resort
43
+ * executable fallback) writes the console/OEM code page by default, which
44
+ * garbles non-ASCII output; pwsh 7 defaults to UTF-8 and is unaffected. The
45
+ * statements ride on line 1 after `; ` separators so PowerShell error line
46
+ * numbers stay accurate.
47
+ *
48
+ * The type-creation (`[System.Text.UTF8Encoding]::new(...)`) is restricted in
49
+ * PowerShell's Constrained Language mode (Windows AppLocker / restricted
50
+ * token). When this executor runs under a Windows ACL restricted token,
51
+ * that mode is active and the construct errors; the `LanguageMode` check
52
+ * gates the assignment so the rest of the user's command still runs. Full
53
+ * LanguageMode runs hit the assignment unchanged.
54
+ */
55
+ export const ENCODING_PREAMBLE =
56
+ "if ($ExecutionContext.SessionState.LanguageMode -ne 'ConstrainedLanguage') { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false) }; "
57
+
58
+ /** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
59
+ const DEFAULT_GRACE_MS = 3_000
60
+
61
+ /** Default per-stream spill cap (the `maxSpillBytes` config). */
62
+ const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
63
+
64
+ /** Plugin config (all optional — `static Config` supplies the defaults). */
65
+ export interface Config {
66
+ /** Default working directory for commands (default: process.cwd()). */
67
+ cwd?: string
68
+ /** Default foreground timeout in milliseconds. */
69
+ timeoutMs?: number
70
+ /** Upper bound for per-call timeout overrides. */
71
+ maxTimeoutMs?: number
72
+ /** Per-stream in-memory output cap; overflow spills to a temp file. */
73
+ maxOutputBytes?: number
74
+ /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
75
+ maxSpillBytes?: number
76
+ /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
77
+ graceMs?: number
78
+ /**
79
+ * Explicit pwsh executable. When omitted, well-known Windows install
80
+ * locations and PATH entries are probed in order (PowerShell 7 install,
81
+ * PATH entries such as the Microsoft Store install, then Windows
82
+ * PowerShell 5.1), falling back to a bare `pwsh` resolved through PATH.
83
+ */
84
+ pwshPath?: string
85
+ }
86
+
87
+ /** The shape after schemastery applied the defaults (cwd/pwshPath get defaults). */
88
+ type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
89
+
90
+ // Resolution lives in its own dependency-free module so callers and the
91
+ // executor share one definition of which executable the package will use.
92
+ export { candidatePwshPaths, resolvePwshPath } from './resolve.ts'
93
+
94
+ /** Project a settled collect-mode reader into the final CollectedOutput shape. */
95
+ function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
96
+ const read = reader.readFrom(0)
97
+ return {
98
+ text: read.text,
99
+ truncated: read.lossy,
100
+ ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
101
+ }
102
+ }
103
+
104
+ function assertPositiveFinite(name: string, value: number): void {
105
+ if (!Number.isFinite(value) || value <= 0) {
106
+ throw new Error(`pwsh-local: ${name} must be a positive finite number`)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Reject a resolved section this executor could not run with. The schema
112
+ * expresses neither "positive and finite" nor the timer bound `graceMs` has to
113
+ * fit, so a stored value is refused where it is written instead of failing at
114
+ * the next command.
115
+ * @param config - the resolved section, schema-valid by construction.
116
+ * @throws Error naming the field that cannot be used.
117
+ */
118
+ export function assertServiceablePwshConfig(config: Config): void {
119
+ const resolved = config as ResolvedConfig
120
+ assertPositiveFinite('timeoutMs', resolved.timeoutMs)
121
+ assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
122
+ assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
123
+ assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
124
+ assertPositiveFinite('graceMs', resolved.graceMs)
125
+ if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
126
+ throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
132
+ * files, and process-tree termination are the subprocess service's mechanics;
133
+ * this executor supplies their configured budgets per spawn.
134
+ */
135
+ export class PwshLocalExecutor extends ShellExecutor {
136
+ static inject = ['subprocess']
137
+
138
+ static Config: z<Config> = z.object({
139
+ cwd: z.string().default(process.cwd()),
140
+ timeoutMs: z.number().default(120_000),
141
+ maxTimeoutMs: z.number().default(600_000),
142
+ maxOutputBytes: z.number().default(64_000),
143
+ maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
144
+ graceMs: z.number().default(DEFAULT_GRACE_MS),
145
+ pwshPath: z.string().default(''),
146
+ })
147
+
148
+ /** Validated config (schemastery applied the defaults before construction). */
149
+ private readonly config: ResolvedConfig
150
+
151
+ /** The pwsh executable resolved from the current config. */
152
+ private readonly resolvedPwshPath: string
153
+
154
+ /** The pwsh executable every command runs through. */
155
+ get pwshPath(): string {
156
+ return this.resolvedPwshPath
157
+ }
158
+
159
+ constructor(ctx: Context, config: Config) {
160
+ super(ctx)
161
+ // Schemastery fills these fields before construction; the type does not encode that step.
162
+ const entry = config as ResolvedConfig
163
+ assertServiceablePwshConfig(entry)
164
+ this.config = entry
165
+ // Default pwshPath to platform resolution: probe well-known Windows
166
+ // installs, then fall back to a bare `pwsh` resolved through PATH.
167
+ this.resolvedPwshPath = resolvePwshPath(entry.pwshPath ?? '')
168
+ }
169
+
170
+ /**
171
+ * Resolve a request into a fully-specified spec: fill `workdir` from
172
+ * `config.cwd` (else `process.cwd()`), and `timeoutMs` from
173
+ * `config.timeoutMs`, capped at `config.maxTimeoutMs`.
174
+ */
175
+ resolve(request: ShellExecRequest): ShellExecSpec {
176
+ const timeoutMs = clampTimeout(
177
+ request.timeoutMs,
178
+ this.config.timeoutMs,
179
+ this.config.maxTimeoutMs,
180
+ 'pwsh-local: request.timeoutMs',
181
+ )
182
+ const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
183
+ assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
184
+ return {
185
+ command: request.command,
186
+ workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
187
+ timeoutMs,
188
+ stdoutMaxBytes,
189
+ ...request.signal ? { signal: request.signal } : {},
190
+ ...request.stdin !== undefined ? { stdin: request.stdin } : {},
191
+ ...request.env !== undefined ? { env: request.env } : {},
192
+ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
193
+ // Carry a sandbox policy through verbatim: this executor never
194
+ // confines, so the field is inert here (the seam contract) — a
195
+ // sandboxing subclass overrides resolve() to stamp its default instead.
196
+ sandboxPolicy: request.sandboxPolicy,
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The pwsh invocation argv for one resolved spec — the argv-level seam a
202
+ * confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of
203
+ * bash-local's `runArgv`/`startArgv` hooks; see `@bo-agent/pwsh-sandbox`).
204
+ */
205
+ protected argv(spec: ShellExecSpec): string[] {
206
+ return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
207
+ }
208
+
209
+ /** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
210
+ private spawnSpec(
211
+ spec: ShellExecSpec,
212
+ stdoutMaxBytes: number,
213
+ signal: AbortSignal | undefined,
214
+ argv: readonly string[],
215
+ ): SubprocessSpawnSpec {
216
+ const collect = (maxBytes: number): SubprocessCollect =>
217
+ ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
218
+ return {
219
+ argv: [...argv],
220
+ cwd: spec.workdir,
221
+ stdio: {
222
+ stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
223
+ stdout: collect(stdoutMaxBytes),
224
+ stderr: collect(this.config.maxOutputBytes),
225
+ },
226
+ graceMs: this.config.graceMs,
227
+ signal,
228
+ env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
229
+ }
230
+ }
231
+
232
+ /** The collect-mode readers the executor itself requested (present by construction). */
233
+ private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
234
+ const { stdout, stderr } = handle.collected
235
+ /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
236
+ if (stdout === undefined || stderr === undefined) {
237
+ throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream')
238
+ }
239
+ /* v8 ignore stop */
240
+ return { stdout, stderr }
241
+ }
242
+
243
+ async run(spec: ShellExecSpec): Promise<ShellRunResult> {
244
+ return this.runArgv(spec, this.argv(spec))
245
+ }
246
+
247
+ /** Foreground run of an exact argv (the confining subclass re-wraps it). */
248
+ protected async runArgv(spec: ShellExecSpec, argv: readonly string[]): Promise<ShellRunResult> {
249
+ // One deadline combines timeout and upstream cancellation; disposal clears its timer.
250
+ using d = deadline(spec.signal, spec.timeoutMs, 'PWSH_TIMEOUT')
251
+ const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv))
252
+ const outcome = await handle.done
253
+ const collected = PwshLocalExecutor.collected(handle)
254
+ // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
255
+ const timedOut = timeoutOf(d.signal, 'PWSH_TIMEOUT') !== undefined
256
+ const aborted = d.signal.aborted && !timedOut
257
+ return {
258
+ ...outcome,
259
+ timedOut,
260
+ aborted,
261
+ timeoutMs: spec.timeoutMs,
262
+ stdout: finalOutput(collected.stdout),
263
+ stderr: finalOutput(collected.stderr),
264
+ }
265
+ }
266
+
267
+ start(spec: ShellExecSpec): ShellProcess {
268
+ return this.startArgv(spec, this.argv(spec))
269
+ }
270
+
271
+ /** Background start of an exact argv (the confining subclass re-wraps it). */
272
+ protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
273
+ // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
274
+ const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
275
+ const collected = PwshLocalExecutor.collected(running)
276
+
277
+ // A spawn failure produces no process output, so the subprocess service has nothing
278
+ // to buffer; the note is delivered exactly once through the read path.
279
+ let spawnFailureNote: string | undefined
280
+ const consumeSpawnFailure = (): string => {
281
+ const note = spawnFailureNote ?? ''
282
+ spawnFailureNote = undefined
283
+ return note
284
+ }
285
+
286
+ let stdoutOffset = 0
287
+ let stderrOffset = 0
288
+ const proc: ShellProcess = {
289
+ status: 'running',
290
+ exitCode: null,
291
+ signal: null,
292
+ done: running.done.then((outcome) => {
293
+ // Any signal termination is killed, including a command signaling itself.
294
+ if (proc.status === 'running') {
295
+ proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
296
+ }
297
+ proc.exitCode = outcome.exitCode
298
+ proc.signal = outcome.signal
299
+ this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
300
+ }, (error: unknown) => {
301
+ // Background spawn failures settle as killed and surface through the read path.
302
+ proc.status = 'killed'
303
+ spawnFailureNote = `spawn failed: ${String(error)}`
304
+ this.onProcessDone(proc, spawnFailureNote, true, error)
305
+ }),
306
+ readOutput: (): ShellProcessRead => {
307
+ const out = collected.stdout.readFrom(stdoutOffset)
308
+ const err = collected.stderr.readFrom(stderrOffset)
309
+ stdoutOffset = out.nextOffset
310
+ stderrOffset = err.nextOffset
311
+
312
+ // A failed spawn never produced process output, so the note and real
313
+ // stderr text are mutually exclusive.
314
+ const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
315
+ // Single newline between sections: stdout chunks usually end with one
316
+ // already; add it only when missing.
317
+ const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
318
+ const delta = out.text
319
+ + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
320
+ return {
321
+ delta,
322
+ lossy: out.lossy || err.lossy,
323
+ ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
324
+ ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
325
+ }
326
+ },
327
+ kill: (): boolean => {
328
+ if (proc.status !== 'running') return false
329
+ proc.status = 'killed'
330
+ running.terminate()
331
+ return true
332
+ },
333
+ }
334
+ return proc
335
+ }
336
+
337
+ /**
338
+ * Settlement hook for subclasses that attach execution facts to a process.
339
+ * The base implementation is intentionally empty. Mirrored from
340
+ * `bash-local` (whose sandboxing subclass consumes the same hook); the
341
+ * pwsh-confining consumer is `@bo-agent/pwsh-sandbox`.
342
+ * @param _proc - the settled process handle.
343
+ * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
344
+ * @param _spawnFailed - whether the spawn rejected before any process existed.
345
+ * @param _spawnError - the spawn rejection, when `_spawnFailed`.
346
+ */
347
+ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
348
+ }
349
+ /* jscpd:ignore-end */
350
+
351
+ export default PwshLocalExecutor