dsh-plugin-pwsh7 0.1.0 → 0.1.1

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/lib/paths.mjs CHANGED
@@ -1,327 +1,327 @@
1
- // lib/paths.mjs — synchronous PowerShell 7 detection and router config IO.
2
- //
3
- // Windows-only semantics: paths are handled with path.win32 so an injected fake environment
4
- // behaves the same regardless of the host OS the tests run on. Only Node builtins are used.
5
- //
6
- // Public contract:
7
- // ROUTER_DIR_NAME, DSH_DIR_NAME, CONFIG_FILE_NAME
8
- // routerRoot, configFile
9
- // candidatePwshPaths, discoveredPortablePwshPaths
10
- // findPwsh, isPowerShell7, probePwsh
11
- // readRouterConfig, writeRouterConfig
12
- //
13
- // Extra exports are lower-level helpers used by install.mjs and the node:test suites.
14
-
15
- import fs from 'node:fs';
16
- import os from 'node:os';
17
- import path from 'node:path';
18
- import { execFile } from 'node:child_process';
19
-
20
- const W = path.win32;
21
-
22
- export const ROUTER_DIR_NAME = 'pwsh7';
23
- export const DSH_DIR_NAME = 'dsh';
24
- export const CONFIG_FILE_NAME = 'config.json';
25
-
26
- const DEFAULT_PROBE_TIMEOUT_MS = 8000;
27
-
28
- // ---------------------------------------------------------------------------
29
- // Paths and config file location
30
- // ---------------------------------------------------------------------------
31
-
32
- function localAppData(env) {
33
- const fromEnv = env.LOCALAPPDATA ?? env.LocalAppData ?? env.localappdata;
34
- if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
35
- const home = env.USERPROFILE ?? env.UserProfile ?? env.HOME ?? os.homedir();
36
- return W.join(home, 'AppData', 'Local');
37
- }
38
-
39
- export function routerRoot(env = process.env) {
40
- return W.join(localAppData(env), DSH_DIR_NAME, ROUTER_DIR_NAME);
41
- }
42
-
43
- export function configFile(env = process.env) {
44
- return W.join(routerRoot(env), CONFIG_FILE_NAME);
45
- }
46
-
47
- // ---------------------------------------------------------------------------
48
- // Candidate list (first existing wins)
49
- // ---------------------------------------------------------------------------
50
-
51
- function pushUnique(list, value, seen) {
52
- if (typeof value !== 'string') return;
53
- const trimmed = value.trim().replace(/^"(.*)"$/, '$1');
54
- if (!trimmed) return;
55
- const normalised = W.normalize(trimmed);
56
- const key = normalised.toLowerCase();
57
- if (seen.has(key)) return;
58
- seen.add(key);
59
- list.push(normalised);
60
- }
61
-
62
- function pathEntries(env) {
63
- const raw = env.PATH ?? env.Path ?? env.path;
64
- if (typeof raw !== 'string' || !raw) return [];
65
- return raw
66
- .split(W.delimiter)
67
- .map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
68
- .filter(Boolean);
69
- }
70
-
71
- function readInstalledConfig(env) {
72
- const config = readRouterConfig(env);
73
- return config && typeof config === 'object' ? config : undefined;
74
- }
75
-
76
- function compareVersionDesc(a, b) {
77
- const pa = String(a).split(/[.-]/).map((part) => Number(part) || 0);
78
- const pb = String(b).split(/[.-]/).map((part) => Number(part) || 0);
79
- const length = Math.max(pa.length, pb.length);
80
- for (let index = 0; index < length; index += 1) {
81
- const diff = (pb[index] ?? 0) - (pa[index] ?? 0);
82
- if (diff !== 0) return diff;
83
- }
84
- return 0;
85
- }
86
-
87
- /** <routerRoot>\powershell7\<version>\pwsh.exe for each versioned portable install, newest first. */
88
- export function discoveredPortablePwshPaths(env = process.env) {
89
- const base = W.join(routerRoot(env), 'powershell7');
90
- let entries;
91
- try {
92
- entries = fs.readdirSync(base, { withFileTypes: true });
93
- } catch {
94
- return [];
95
- }
96
- const versions = entries
97
- .filter((entry) => entry.isDirectory())
98
- .map((entry) => entry.name)
99
- .sort(compareVersionDesc);
100
- return versions.map((version) => W.join(base, version, 'pwsh.exe'));
101
- }
102
-
103
- /**
104
- * Candidate pwsh.exe paths, in contract order: DSH_PWSH_PATH, config pwshPath,
105
- * %ProgramFiles%\PowerShell\7, <routerRoot>\powershell7, %LOCALAPPDATA%\Programs\PowerShell\7,
106
- * every PATH entry, the Store alias, %ProgramFiles(x86)%\PowerShell\7, then versioned portable
107
- * installs discovered on disk.
108
- */
109
- export function candidatePwshPaths(env = process.env) {
110
- const list = [];
111
- const seen = new Set();
112
- const config = readInstalledConfig(env);
113
- const appData = localAppData(env);
114
- const programFiles = env.ProgramFiles ?? env.PROGRAMFILES ?? 'C:\\Program Files';
115
- const programFilesX86 =
116
- env['ProgramFiles(x86)'] ??
117
- env['PROGRAMFILES(X86)'] ??
118
- env['ProgramFiles(X86)'] ??
119
- 'C:\\Program Files (x86)';
120
-
121
- pushUnique(list, env.DSH_PWSH_PATH, seen);
122
- if (config) pushUnique(list, config.pwshPath, seen);
123
- pushUnique(list, W.join(programFiles, 'PowerShell', '7', 'pwsh.exe'), seen);
124
- pushUnique(list, W.join(routerRoot(env), 'powershell7', 'pwsh.exe'), seen);
125
- pushUnique(list, W.join(appData, 'Programs', 'PowerShell', '7', 'pwsh.exe'), seen);
126
- for (const entry of pathEntries(env)) pushUnique(list, W.join(entry, 'pwsh.exe'), seen);
127
- pushUnique(list, W.join(appData, 'Microsoft', 'WindowsApps', 'pwsh.exe'), seen);
128
- pushUnique(list, W.join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'), seen);
129
- for (const discovered of discoveredPortablePwshPaths(env)) pushUnique(list, discovered, seen);
130
-
131
- return list;
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Existence checks
136
- // ---------------------------------------------------------------------------
137
-
138
- /** True when `target` is a regular file, or a symlink/reparse alias that may not stat as a file. */
139
- export function fileOrSymlinkExists(target) {
140
- if (typeof target !== 'string' || !target.trim()) return false;
141
- try {
142
- return fs.statSync(target).isFile();
143
- } catch {
144
- // fall through to lstat (e.g. a Store app-execution alias)
145
- }
146
- try {
147
- return fs.lstatSync(target).isSymbolicLink();
148
- } catch {
149
- return false;
150
- }
151
- }
152
-
153
- /** First candidate that exists as a file/symlink. */
154
- export function findPwsh(options = {}) {
155
- const env = options.env ?? process.env;
156
- for (const candidate of candidatePwshPaths(env)) {
157
- if (fileOrSymlinkExists(candidate)) return { path: candidate };
158
- }
159
- return undefined;
160
- }
161
-
162
- // ---------------------------------------------------------------------------
163
- // Router config JSON
164
- // ---------------------------------------------------------------------------
165
-
166
- /**
167
- * Read %LOCALAPPDATA%\dsh\pwsh7\config.json; undefined when missing or invalid.
168
- * Only `pwshPath` / `installed.pwsh` are meaningful to this module; unknown keys are left
169
- * untouched by writeRouterConfig callers and never required here.
170
- */
171
- export function readRouterConfig(env = process.env) {
172
- try {
173
- const data = JSON.parse(fs.readFileSync(configFile(env), 'utf8'));
174
- return data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
175
- } catch {
176
- return undefined;
177
- }
178
- }
179
-
180
- /** Atomic-ish JSON write (temp file + rename, direct-write fallback). Sets `version`/`updatedAt`. */
181
- export function writeRouterConfig(config, env = process.env) {
182
- if (!config || typeof config !== 'object' || Array.isArray(config)) {
183
- throw new TypeError('writeRouterConfig(config): config must be a plain object');
184
- }
185
- const root = routerRoot(env);
186
- fs.mkdirSync(root, { recursive: true });
187
- const file = configFile(env);
188
- const payload = { ...config };
189
- if (payload.version === undefined) payload.version = 1;
190
- payload.updatedAt = new Date().toISOString();
191
- const json = `${JSON.stringify(payload, null, 2)}\n`;
192
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
193
- try {
194
- fs.writeFileSync(temp, json, 'utf8');
195
- try {
196
- fs.renameSync(temp, file);
197
- } catch {
198
- fs.writeFileSync(file, json, 'utf8');
199
- try {
200
- fs.unlinkSync(temp);
201
- } catch {
202
- // best effort cleanup
203
- }
204
- }
205
- } finally {
206
- if (fs.existsSync(temp)) {
207
- try {
208
- fs.unlinkSync(temp);
209
- } catch {
210
- // best effort cleanup
211
- }
212
- }
213
- }
214
- }
215
-
216
- // ---------------------------------------------------------------------------
217
- // Async version probes (child_process only, no shell interpolation)
218
- // ---------------------------------------------------------------------------
219
-
220
- /** Default runner: execFile with timeout, no shell, injectable `run` in tests. */
221
- export function execFileProbe(file, args, options = {}) {
222
- return new Promise((resolve) => {
223
- let settled = false;
224
- const finish = (value) => {
225
- if (settled) return;
226
- settled = true;
227
- resolve(value);
228
- };
229
- try {
230
- execFile(
231
- file,
232
- args,
233
- {
234
- timeout: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
235
- windowsHide: true,
236
- encoding: 'utf8',
237
- maxBuffer: 4 * 1024 * 1024,
238
- env: options.env ?? process.env,
239
- signal: options.signal,
240
- },
241
- (error, stdout, stderr) => {
242
- finish({
243
- ok: !error,
244
- code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
245
- stdout: stdout ?? '',
246
- stderr: stderr ?? '',
247
- error,
248
- });
249
- },
250
- );
251
- } catch (error) {
252
- finish({ ok: false, code: 1, stdout: '', stderr: String(error?.message ?? error), error });
253
- }
254
- });
255
- }
256
-
257
- /** Parse `$PSVersionTable.PSVersion.Major` output (tolerant of extra lines). */
258
- export function parsePwshMajor(output) {
259
- const text = String(output ?? '');
260
- const standalone = /^\s*(\d+)\s*$/m.exec(text);
261
- if (standalone) return Number(standalone[1]);
262
- const version = parsePwshVersion(text);
263
- return version ? Number(version.split('.')[0]) : undefined;
264
- }
265
-
266
- /** Parse the first x.y[.z] version token from command output. */
267
- export function parsePwshVersion(output) {
268
- const match = /(\d+\.\d+(?:\.\d+){0,2})/.exec(String(output ?? ''));
269
- return match ? match[1] : undefined;
270
- }
271
-
272
- const PWSH_MAJOR_ARGS = [
273
- '-NoLogo',
274
- '-NoProfile',
275
- '-NonInteractive',
276
- '-Command',
277
- '$PSVersionTable.PSVersion.Major',
278
- ];
279
- const PWSH_VERSION_ARGS = [
280
- '-NoLogo',
281
- '-NoProfile',
282
- '-NonInteractive',
283
- '-Command',
284
- '$PSVersionTable.PSVersion.ToString()',
285
- ];
286
-
287
- /** True when `pwshPath` runs and reports a PowerShell major version >= 7. Never throws. */
288
- export async function isPowerShell7(pwshPath, options = {}) {
289
- if (typeof pwshPath !== 'string' || !pwshPath.trim()) return false;
290
- const run = options.run ?? execFileProbe;
291
- try {
292
- const result = await run(pwshPath, PWSH_MAJOR_ARGS, {
293
- env: options.env,
294
- signal: options.signal,
295
- timeoutMs: options.timeoutMs,
296
- });
297
- if (!result?.ok) return false;
298
- const major = parsePwshMajor(result.stdout);
299
- return typeof major === 'number' && Number.isFinite(major) && major >= 7;
300
- } catch {
301
- return false;
302
- }
303
- }
304
-
305
- /** Probe a pwsh.exe: { ok, version, output }. Never throws. */
306
- export async function probePwsh(pwshPath, options = {}) {
307
- if (typeof pwshPath !== 'string' || !pwshPath.trim()) {
308
- return { ok: false, version: undefined, output: '' };
309
- }
310
- const run = options.run ?? execFileProbe;
311
- try {
312
- const result = await run(pwshPath, PWSH_VERSION_ARGS, {
313
- env: options.env,
314
- signal: options.signal,
315
- timeoutMs: options.timeoutMs,
316
- });
317
- const output = [result?.stdout, result?.stderr]
318
- .filter((part) => typeof part === 'string' && part.trim())
319
- .join('\n')
320
- .trim();
321
- const version = parsePwshVersion(result?.stdout) ?? parsePwshVersion(output);
322
- const major = version ? Number(version.split('.')[0]) : undefined;
323
- return { ok: Boolean(result?.ok) && major >= 7, version, output };
324
- } catch (error) {
325
- return { ok: false, version: undefined, output: String(error?.message ?? error) };
326
- }
327
- }
1
+ // lib/paths.mjs — synchronous PowerShell 7 detection and router config IO.
2
+ //
3
+ // Windows-only semantics: paths are handled with path.win32 so an injected fake environment
4
+ // behaves the same regardless of the host OS the tests run on. Only Node builtins are used.
5
+ //
6
+ // Public contract:
7
+ // ROUTER_DIR_NAME, DSH_DIR_NAME, CONFIG_FILE_NAME
8
+ // routerRoot, configFile
9
+ // candidatePwshPaths, discoveredPortablePwshPaths
10
+ // findPwsh, isPowerShell7, probePwsh
11
+ // readRouterConfig, writeRouterConfig
12
+ //
13
+ // Extra exports are lower-level helpers used by install.mjs and the node:test suites.
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { execFile } from 'node:child_process';
19
+
20
+ const W = path.win32;
21
+
22
+ export const ROUTER_DIR_NAME = 'pwsh7';
23
+ export const DSH_DIR_NAME = 'dsh';
24
+ export const CONFIG_FILE_NAME = 'config.json';
25
+
26
+ const DEFAULT_PROBE_TIMEOUT_MS = 8000;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Paths and config file location
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function localAppData(env) {
33
+ const fromEnv = env.LOCALAPPDATA ?? env.LocalAppData ?? env.localappdata;
34
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
35
+ const home = env.USERPROFILE ?? env.UserProfile ?? env.HOME ?? os.homedir();
36
+ return W.join(home, 'AppData', 'Local');
37
+ }
38
+
39
+ export function routerRoot(env = process.env) {
40
+ return W.join(localAppData(env), DSH_DIR_NAME, ROUTER_DIR_NAME);
41
+ }
42
+
43
+ export function configFile(env = process.env) {
44
+ return W.join(routerRoot(env), CONFIG_FILE_NAME);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Candidate list (first existing wins)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function pushUnique(list, value, seen) {
52
+ if (typeof value !== 'string') return;
53
+ const trimmed = value.trim().replace(/^"(.*)"$/, '$1');
54
+ if (!trimmed) return;
55
+ const normalised = W.normalize(trimmed);
56
+ const key = normalised.toLowerCase();
57
+ if (seen.has(key)) return;
58
+ seen.add(key);
59
+ list.push(normalised);
60
+ }
61
+
62
+ function pathEntries(env) {
63
+ const raw = env.PATH ?? env.Path ?? env.path;
64
+ if (typeof raw !== 'string' || !raw) return [];
65
+ return raw
66
+ .split(W.delimiter)
67
+ .map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
68
+ .filter(Boolean);
69
+ }
70
+
71
+ function readInstalledConfig(env) {
72
+ const config = readRouterConfig(env);
73
+ return config && typeof config === 'object' ? config : undefined;
74
+ }
75
+
76
+ function compareVersionDesc(a, b) {
77
+ const pa = String(a).split(/[.-]/).map((part) => Number(part) || 0);
78
+ const pb = String(b).split(/[.-]/).map((part) => Number(part) || 0);
79
+ const length = Math.max(pa.length, pb.length);
80
+ for (let index = 0; index < length; index += 1) {
81
+ const diff = (pb[index] ?? 0) - (pa[index] ?? 0);
82
+ if (diff !== 0) return diff;
83
+ }
84
+ return 0;
85
+ }
86
+
87
+ /** <routerRoot>\powershell7\<version>\pwsh.exe for each versioned portable install, newest first. */
88
+ export function discoveredPortablePwshPaths(env = process.env) {
89
+ const base = W.join(routerRoot(env), 'powershell7');
90
+ let entries;
91
+ try {
92
+ entries = fs.readdirSync(base, { withFileTypes: true });
93
+ } catch {
94
+ return [];
95
+ }
96
+ const versions = entries
97
+ .filter((entry) => entry.isDirectory())
98
+ .map((entry) => entry.name)
99
+ .sort(compareVersionDesc);
100
+ return versions.map((version) => W.join(base, version, 'pwsh.exe'));
101
+ }
102
+
103
+ /**
104
+ * Candidate pwsh.exe paths, in contract order: DSH_PWSH_PATH, config pwshPath,
105
+ * %ProgramFiles%\PowerShell\7, <routerRoot>\powershell7, %LOCALAPPDATA%\Programs\PowerShell\7,
106
+ * every PATH entry, the Store alias, %ProgramFiles(x86)%\PowerShell\7, then versioned portable
107
+ * installs discovered on disk.
108
+ */
109
+ export function candidatePwshPaths(env = process.env) {
110
+ const list = [];
111
+ const seen = new Set();
112
+ const config = readInstalledConfig(env);
113
+ const appData = localAppData(env);
114
+ const programFiles = env.ProgramFiles ?? env.PROGRAMFILES ?? 'C:\\Program Files';
115
+ const programFilesX86 =
116
+ env['ProgramFiles(x86)'] ??
117
+ env['PROGRAMFILES(X86)'] ??
118
+ env['ProgramFiles(X86)'] ??
119
+ 'C:\\Program Files (x86)';
120
+
121
+ pushUnique(list, env.DSH_PWSH_PATH, seen);
122
+ if (config) pushUnique(list, config.pwshPath, seen);
123
+ pushUnique(list, W.join(programFiles, 'PowerShell', '7', 'pwsh.exe'), seen);
124
+ pushUnique(list, W.join(routerRoot(env), 'powershell7', 'pwsh.exe'), seen);
125
+ pushUnique(list, W.join(appData, 'Programs', 'PowerShell', '7', 'pwsh.exe'), seen);
126
+ for (const entry of pathEntries(env)) pushUnique(list, W.join(entry, 'pwsh.exe'), seen);
127
+ pushUnique(list, W.join(appData, 'Microsoft', 'WindowsApps', 'pwsh.exe'), seen);
128
+ pushUnique(list, W.join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'), seen);
129
+ for (const discovered of discoveredPortablePwshPaths(env)) pushUnique(list, discovered, seen);
130
+
131
+ return list;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Existence checks
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** True when `target` is a regular file, or a symlink/reparse alias that may not stat as a file. */
139
+ export function fileOrSymlinkExists(target) {
140
+ if (typeof target !== 'string' || !target.trim()) return false;
141
+ try {
142
+ return fs.statSync(target).isFile();
143
+ } catch {
144
+ // fall through to lstat (e.g. a Store app-execution alias)
145
+ }
146
+ try {
147
+ return fs.lstatSync(target).isSymbolicLink();
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /** First candidate that exists as a file/symlink. */
154
+ export function findPwsh(options = {}) {
155
+ const env = options.env ?? process.env;
156
+ for (const candidate of candidatePwshPaths(env)) {
157
+ if (fileOrSymlinkExists(candidate)) return { path: candidate };
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Router config JSON
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Read %LOCALAPPDATA%\dsh\pwsh7\config.json; undefined when missing or invalid.
168
+ * Only `pwshPath` / `installed.pwsh` are meaningful to this module; unknown keys are left
169
+ * untouched by writeRouterConfig callers and never required here.
170
+ */
171
+ export function readRouterConfig(env = process.env) {
172
+ try {
173
+ const data = JSON.parse(fs.readFileSync(configFile(env), 'utf8'));
174
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ /** Atomic-ish JSON write (temp file + rename, direct-write fallback). Sets `version`/`updatedAt`. */
181
+ export function writeRouterConfig(config, env = process.env) {
182
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
183
+ throw new TypeError('writeRouterConfig(config): config must be a plain object');
184
+ }
185
+ const root = routerRoot(env);
186
+ fs.mkdirSync(root, { recursive: true });
187
+ const file = configFile(env);
188
+ const payload = { ...config };
189
+ if (payload.version === undefined) payload.version = 1;
190
+ payload.updatedAt = new Date().toISOString();
191
+ const json = `${JSON.stringify(payload, null, 2)}\n`;
192
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
193
+ try {
194
+ fs.writeFileSync(temp, json, 'utf8');
195
+ try {
196
+ fs.renameSync(temp, file);
197
+ } catch {
198
+ fs.writeFileSync(file, json, 'utf8');
199
+ try {
200
+ fs.unlinkSync(temp);
201
+ } catch {
202
+ // best effort cleanup
203
+ }
204
+ }
205
+ } finally {
206
+ if (fs.existsSync(temp)) {
207
+ try {
208
+ fs.unlinkSync(temp);
209
+ } catch {
210
+ // best effort cleanup
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Async version probes (child_process only, no shell interpolation)
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /** Default runner: execFile with timeout, no shell, injectable `run` in tests. */
221
+ export function execFileProbe(file, args, options = {}) {
222
+ return new Promise((resolve) => {
223
+ let settled = false;
224
+ const finish = (value) => {
225
+ if (settled) return;
226
+ settled = true;
227
+ resolve(value);
228
+ };
229
+ try {
230
+ execFile(
231
+ file,
232
+ args,
233
+ {
234
+ timeout: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
235
+ windowsHide: true,
236
+ encoding: 'utf8',
237
+ maxBuffer: 4 * 1024 * 1024,
238
+ env: options.env ?? process.env,
239
+ signal: options.signal,
240
+ },
241
+ (error, stdout, stderr) => {
242
+ finish({
243
+ ok: !error,
244
+ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
245
+ stdout: stdout ?? '',
246
+ stderr: stderr ?? '',
247
+ error,
248
+ });
249
+ },
250
+ );
251
+ } catch (error) {
252
+ finish({ ok: false, code: 1, stdout: '', stderr: String(error?.message ?? error), error });
253
+ }
254
+ });
255
+ }
256
+
257
+ /** Parse `$PSVersionTable.PSVersion.Major` output (tolerant of extra lines). */
258
+ export function parsePwshMajor(output) {
259
+ const text = String(output ?? '');
260
+ const standalone = /^\s*(\d+)\s*$/m.exec(text);
261
+ if (standalone) return Number(standalone[1]);
262
+ const version = parsePwshVersion(text);
263
+ return version ? Number(version.split('.')[0]) : undefined;
264
+ }
265
+
266
+ /** Parse the first x.y[.z] version token from command output. */
267
+ export function parsePwshVersion(output) {
268
+ const match = /(\d+\.\d+(?:\.\d+){0,2})/.exec(String(output ?? ''));
269
+ return match ? match[1] : undefined;
270
+ }
271
+
272
+ const PWSH_MAJOR_ARGS = [
273
+ '-NoLogo',
274
+ '-NoProfile',
275
+ '-NonInteractive',
276
+ '-Command',
277
+ '$PSVersionTable.PSVersion.Major',
278
+ ];
279
+ const PWSH_VERSION_ARGS = [
280
+ '-NoLogo',
281
+ '-NoProfile',
282
+ '-NonInteractive',
283
+ '-Command',
284
+ '$PSVersionTable.PSVersion.ToString()',
285
+ ];
286
+
287
+ /** True when `pwshPath` runs and reports a PowerShell major version >= 7. Never throws. */
288
+ export async function isPowerShell7(pwshPath, options = {}) {
289
+ if (typeof pwshPath !== 'string' || !pwshPath.trim()) return false;
290
+ const run = options.run ?? execFileProbe;
291
+ try {
292
+ const result = await run(pwshPath, PWSH_MAJOR_ARGS, {
293
+ env: options.env,
294
+ signal: options.signal,
295
+ timeoutMs: options.timeoutMs,
296
+ });
297
+ if (!result?.ok) return false;
298
+ const major = parsePwshMajor(result.stdout);
299
+ return typeof major === 'number' && Number.isFinite(major) && major >= 7;
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ /** Probe a pwsh.exe: { ok, version, output }. Never throws. */
306
+ export async function probePwsh(pwshPath, options = {}) {
307
+ if (typeof pwshPath !== 'string' || !pwshPath.trim()) {
308
+ return { ok: false, version: undefined, output: '' };
309
+ }
310
+ const run = options.run ?? execFileProbe;
311
+ try {
312
+ const result = await run(pwshPath, PWSH_VERSION_ARGS, {
313
+ env: options.env,
314
+ signal: options.signal,
315
+ timeoutMs: options.timeoutMs,
316
+ });
317
+ const output = [result?.stdout, result?.stderr]
318
+ .filter((part) => typeof part === 'string' && part.trim())
319
+ .join('\n')
320
+ .trim();
321
+ const version = parsePwshVersion(result?.stdout) ?? parsePwshVersion(output);
322
+ const major = version ? Number(version.split('.')[0]) : undefined;
323
+ return { ok: Boolean(result?.ok) && major >= 7, version, output };
324
+ } catch (error) {
325
+ return { ok: false, version: undefined, output: String(error?.message ?? error) };
326
+ }
327
+ }