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/CHANGELOG.md +33 -0
- package/LICENSE +21 -21
- package/README.md +219 -186
- package/README.zh.md +208 -176
- package/cordis.patch.yml +16 -16
- package/icon.svg +28 -28
- package/index.js +126 -126
- package/lib/executors/pwsh.mjs +20 -20
- package/lib/install.mjs +978 -978
- package/lib/paths.mjs +327 -327
- package/lib/shipped.mjs +115 -115
- package/lib/state.mjs +129 -129
- package/lib/status.mjs +187 -187
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +62 -60
package/lib/install.mjs
CHANGED
|
@@ -1,978 +1,978 @@
|
|
|
1
|
-
// lib/install.mjs — per-user portable installer for PowerShell 7.
|
|
2
|
-
//
|
|
3
|
-
// Everything is user-scoped: files land under <routerRoot> (default
|
|
4
|
-
// %LOCALAPPDATA%\dsh\pwsh7), downloads under <routerRoot>\tmp, archives are extracted with
|
|
5
|
-
// the Windows bsdtar (tar.exe). No administrator rights are ever requested. When a portable
|
|
6
|
-
// install is impossible the result carries the exact manual command instead of hanging on UAC.
|
|
7
|
-
//
|
|
8
|
-
// Public contract:
|
|
9
|
-
// USER_AGENT, PWSH_API_LATEST, PWSH_RELEASES_PAGE, WINGET_PWSH_ID
|
|
10
|
-
// ensurePwsh({ env, root, method, onProgress, signal }) -> { ok, result, notes, aborted }
|
|
11
|
-
// result: { ok, component: 'pwsh', method, path?, version?, message, url?, bytes?,
|
|
12
|
-
// durationMs }
|
|
13
|
-
// installPowerShell7(options) -> Result (same shape as above)
|
|
14
|
-
// resolvePwsh7Release(options) -> { tag, version, name, url, size? }
|
|
15
|
-
// selectPwsh7Asset(release) -> asset descriptor | undefined
|
|
16
|
-
// parsePwsh7ZipName(name) -> { version } | undefined
|
|
17
|
-
// downloadFile(url, destination, options) -> { url, bytes, totalBytes? }
|
|
18
|
-
// listArchiveEntries / extractArchive / extractArchiveWithLayout /
|
|
19
|
-
// detectSingleTopLevelDirectory / verifyArchiveMagic / resolveTarPath
|
|
20
|
-
// resolveWingetPath / wingetAvailable / wingetInstallArgs / wingetInstallManualCommand /
|
|
21
|
-
// manualInstallInstructions / looksLikeElevationRequired / throwIfAborted
|
|
22
|
-
//
|
|
23
|
-
// Extra pure helpers are internal but exported so test/install.test.mjs can exercise them.
|
|
24
|
-
// Only Node builtins and global fetch are used; there are no @deepseek-ai imports.
|
|
25
|
-
|
|
26
|
-
import fs from 'node:fs';
|
|
27
|
-
import os from 'node:os';
|
|
28
|
-
import path from 'node:path';
|
|
29
|
-
import { execFile } from 'node:child_process';
|
|
30
|
-
import { Readable, Transform } from 'node:stream';
|
|
31
|
-
import { pipeline } from 'node:stream/promises';
|
|
32
|
-
|
|
33
|
-
import {
|
|
34
|
-
routerRoot,
|
|
35
|
-
readRouterConfig,
|
|
36
|
-
writeRouterConfig,
|
|
37
|
-
findPwsh,
|
|
38
|
-
probePwsh,
|
|
39
|
-
fileOrSymlinkExists,
|
|
40
|
-
} from './paths.mjs';
|
|
41
|
-
|
|
42
|
-
const W = path.win32;
|
|
43
|
-
|
|
44
|
-
export const USER_AGENT = 'dsh-plugin-pwsh7';
|
|
45
|
-
export const PWSH_API_LATEST = 'https://api.github.com/repos/PowerShell/PowerShell/releases/latest';
|
|
46
|
-
export const PWSH_RELEASES_PAGE = 'https://github.com/PowerShell/PowerShell/releases/latest';
|
|
47
|
-
export const WINGET_PWSH_ID = 'Microsoft.PowerShell';
|
|
48
|
-
|
|
49
|
-
const PWSH7_MIN_DOWNLOAD_BYTES = 5 * 1024 * 1024;
|
|
50
|
-
const DOWNLOAD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
51
|
-
const EXTRACT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
52
|
-
const WINGET_TIMEOUT_MS = 20 * 60 * 1000;
|
|
53
|
-
const LISTING_TIMEOUT_MS = 2 * 60 * 1000;
|
|
54
|
-
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Small shared helpers
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
function nowIso() {
|
|
60
|
-
return new Date().toISOString();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function abortError(signal) {
|
|
64
|
-
const reason = signal?.reason;
|
|
65
|
-
const error = new Error(
|
|
66
|
-
reason instanceof Error && reason.message ? reason.message : 'operation aborted',
|
|
67
|
-
);
|
|
68
|
-
error.name = 'AbortError';
|
|
69
|
-
return error;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function ensureAbortError(error, signal) {
|
|
73
|
-
if (error?.name === 'AbortError') return error;
|
|
74
|
-
if (signal?.aborted) return abortError(signal);
|
|
75
|
-
return error;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function throwIfAborted(signal) {
|
|
79
|
-
if (signal?.aborted) throw abortError(signal);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function tail(text, limit = 600) {
|
|
83
|
-
const value = String(text ?? '').trim();
|
|
84
|
-
if (!value) return '';
|
|
85
|
-
return value.length <= limit ? value : `…${value.slice(-limit)}`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function removeQuietly(target) {
|
|
89
|
-
if (typeof target !== 'string' || !target.trim()) return;
|
|
90
|
-
try {
|
|
91
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
92
|
-
} catch {
|
|
93
|
-
// best-effort cleanup; never fail an install because of leftover temp files
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function linkedSignal(signal, timeoutMs) {
|
|
98
|
-
const controller = new AbortController();
|
|
99
|
-
const forward = () => controller.abort(abortError(signal));
|
|
100
|
-
if (signal) {
|
|
101
|
-
if (signal.aborted) controller.abort(abortError(signal));
|
|
102
|
-
else signal.addEventListener('abort', forward, { once: true });
|
|
103
|
-
}
|
|
104
|
-
let timer;
|
|
105
|
-
if (timeoutMs > 0 && !controller.signal.aborted) {
|
|
106
|
-
timer = setTimeout(
|
|
107
|
-
() => controller.abort(new Error(`operation timed out after ${timeoutMs} ms`)),
|
|
108
|
-
timeoutMs,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return {
|
|
112
|
-
signal: controller.signal,
|
|
113
|
-
cleanup() {
|
|
114
|
-
if (timer) clearTimeout(timer);
|
|
115
|
-
if (signal) signal.removeEventListener('abort', forward);
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function fetchWithTimeout(fetchImpl, url, options = {}) {
|
|
121
|
-
const linked = linkedSignal(options.signal, options.timeoutMs ?? LISTING_TIMEOUT_MS);
|
|
122
|
-
try {
|
|
123
|
-
const response = await fetchImpl(url, {
|
|
124
|
-
headers: options.headers ?? {},
|
|
125
|
-
redirect: options.redirect ?? 'follow',
|
|
126
|
-
signal: linked.signal,
|
|
127
|
-
});
|
|
128
|
-
return response;
|
|
129
|
-
} catch (error) {
|
|
130
|
-
throw ensureAbortError(error, options.signal);
|
|
131
|
-
} finally {
|
|
132
|
-
linked.cleanup();
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function runCommand(file, args, options = {}) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
let settled = false;
|
|
139
|
-
const finish = (value) => {
|
|
140
|
-
if (settled) return;
|
|
141
|
-
settled = true;
|
|
142
|
-
resolve(value);
|
|
143
|
-
};
|
|
144
|
-
try {
|
|
145
|
-
execFile(
|
|
146
|
-
file,
|
|
147
|
-
args,
|
|
148
|
-
{
|
|
149
|
-
timeout: options.timeoutMs ?? EXTRACT_TIMEOUT_MS,
|
|
150
|
-
windowsHide: true,
|
|
151
|
-
encoding: 'utf8',
|
|
152
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
153
|
-
env: options.env ?? process.env,
|
|
154
|
-
cwd: options.cwd,
|
|
155
|
-
signal: options.signal,
|
|
156
|
-
},
|
|
157
|
-
(error, stdout, stderr) => {
|
|
158
|
-
finish({
|
|
159
|
-
ok: !error,
|
|
160
|
-
code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
|
|
161
|
-
stdout: stdout ?? '',
|
|
162
|
-
stderr: stderr ?? '',
|
|
163
|
-
error,
|
|
164
|
-
});
|
|
165
|
-
},
|
|
166
|
-
);
|
|
167
|
-
} catch (error) {
|
|
168
|
-
finish({ ok: false, code: 1, stdout: '', stderr: String(error?.message ?? error), error });
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function fetchJson(fetchImpl, url, options = {}) {
|
|
174
|
-
const response = await fetchWithTimeout(fetchImpl, url, options);
|
|
175
|
-
if (!response?.ok) throw new Error(`HTTP ${response?.status ?? '?'} for ${url}`);
|
|
176
|
-
return response.json();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function headersFor(env, extra = {}) {
|
|
180
|
-
return { 'User-Agent': USER_AGENT, ...extra };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ---------------------------------------------------------------------------
|
|
184
|
-
// Config persistence (inside the router root only)
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
|
|
187
|
-
function updateRouterConfig(env, patch) {
|
|
188
|
-
const current = readRouterConfig(env) ?? {};
|
|
189
|
-
const installed =
|
|
190
|
-
current.installed && typeof current.installed === 'object' ? { ...current.installed } : {};
|
|
191
|
-
if (patch.installed) {
|
|
192
|
-
for (const [component, value] of Object.entries(patch.installed)) {
|
|
193
|
-
installed[component] = { ...(installed[component] ?? {}), ...value };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const next = { ...current, ...patch, version: current.version ?? 1 };
|
|
197
|
-
next.installed = installed;
|
|
198
|
-
writeRouterConfig(next, env);
|
|
199
|
-
return next;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function persistPwshConfig(env, { path: pwshPath, version, method, at = nowIso() }) {
|
|
203
|
-
const entry = { path: pwshPath, method, at };
|
|
204
|
-
if (version) entry.version = version;
|
|
205
|
-
return updateRouterConfig(env, { pwshPath, installed: { pwsh: entry } });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
// Asset name parsing and selection (pure — unit tested)
|
|
210
|
-
// ---------------------------------------------------------------------------
|
|
211
|
-
|
|
212
|
-
/** Parse a PowerShell win-x64 portable zip name into { version } or undefined. */
|
|
213
|
-
export function parsePwsh7ZipName(name) {
|
|
214
|
-
const match = /^PowerShell-(.+)-win-x64\.zip$/i.exec(String(name ?? '').trim());
|
|
215
|
-
return match ? { version: match[1] } : undefined;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Pick the win-x64 portable zip asset out of a GitHub release JSON document. */
|
|
219
|
-
export function selectPwsh7Asset(release) {
|
|
220
|
-
if (!release || typeof release !== 'object' || !Array.isArray(release.assets)) return undefined;
|
|
221
|
-
const tag = typeof release.tag_name === 'string' ? release.tag_name.trim() : '';
|
|
222
|
-
const version = tag.replace(/^v/i, '');
|
|
223
|
-
const matches = release.assets.filter((asset) => {
|
|
224
|
-
if (!asset || typeof asset.name !== 'string') return false;
|
|
225
|
-
return /^PowerShell-.+-win-x64\.zip$/i.test(asset.name);
|
|
226
|
-
});
|
|
227
|
-
if (!matches.length) return undefined;
|
|
228
|
-
const expectedName = version ? `PowerShell-${version}-win-x64.zip` : '';
|
|
229
|
-
const asset =
|
|
230
|
-
(expectedName && matches.find((item) => item.name.toLowerCase() === expectedName.toLowerCase())) ||
|
|
231
|
-
matches[0];
|
|
232
|
-
const parsed = parsePwsh7ZipName(asset.name);
|
|
233
|
-
return {
|
|
234
|
-
tag,
|
|
235
|
-
version: parsed?.version ?? version,
|
|
236
|
-
name: asset.name,
|
|
237
|
-
url:
|
|
238
|
-
asset.browser_download_url ??
|
|
239
|
-
`https://github.com/PowerShell/PowerShell/releases/download/${tag}/${asset.name}`,
|
|
240
|
-
size: typeof asset.size === 'number' ? asset.size : undefined,
|
|
241
|
-
asset,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Detect a single wrapper directory in archive entries (e.g. an archive rooted at "pwsh-root/").
|
|
247
|
-
* Returns the directory name when every entry lives under exactly one directory, otherwise
|
|
248
|
-
* undefined (files at the archive root, or several top-level entries). PowerShell portable zips
|
|
249
|
-
* are flat, so they normally return undefined and are extracted without stripping.
|
|
250
|
-
*/
|
|
251
|
-
export function detectSingleTopLevelDirectory(entries) {
|
|
252
|
-
const segments = new Set();
|
|
253
|
-
let sawEntry = false;
|
|
254
|
-
for (const entry of entries ?? []) {
|
|
255
|
-
if (typeof entry !== 'string') continue;
|
|
256
|
-
const cleaned = entry.replace(/^\.\//, '').replace(/\r$/, '');
|
|
257
|
-
if (!cleaned) continue;
|
|
258
|
-
sawEntry = true;
|
|
259
|
-
const isDirectoryEntry = /[\\/]$/.test(cleaned);
|
|
260
|
-
const withoutTrailing = cleaned.replace(/[\\/]+$/, '');
|
|
261
|
-
if (!withoutTrailing) continue;
|
|
262
|
-
const slash = withoutTrailing.search(/[\\/]/);
|
|
263
|
-
if (slash === -1) {
|
|
264
|
-
if (isDirectoryEntry) {
|
|
265
|
-
segments.add(withoutTrailing);
|
|
266
|
-
if (segments.size > 1) return undefined;
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
// A plain file at the archive root: the archive has no wrapper directory.
|
|
270
|
-
return undefined;
|
|
271
|
-
}
|
|
272
|
-
segments.add(withoutTrailing.slice(0, slash));
|
|
273
|
-
if (segments.size > 1) return undefined;
|
|
274
|
-
}
|
|
275
|
-
return sawEntry && segments.size === 1 ? [...segments][0] : undefined;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ---------------------------------------------------------------------------
|
|
279
|
-
// Downloading / extracting
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
|
|
282
|
-
const ARCHIVE_MAGIC = {
|
|
283
|
-
zip: [0x50, 0x4b, 0x03, 0x04],
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
/** Check the leading magic bytes of a downloaded archive before extraction. */
|
|
287
|
-
export function verifyArchiveMagic(file, kind) {
|
|
288
|
-
const expected = ARCHIVE_MAGIC[kind];
|
|
289
|
-
if (!expected) throw new Error(`verifyArchiveMagic: unknown archive kind ${kind}`);
|
|
290
|
-
let fd;
|
|
291
|
-
try {
|
|
292
|
-
fd = fs.openSync(file, 'r');
|
|
293
|
-
const buffer = Buffer.alloc(expected.length);
|
|
294
|
-
const bytesRead = fs.readSync(fd, buffer, 0, expected.length, 0);
|
|
295
|
-
if (bytesRead !== expected.length) {
|
|
296
|
-
throw new Error(`${kind} archive ${file} is truncated (${bytesRead} header bytes)`);
|
|
297
|
-
}
|
|
298
|
-
const actual = [...buffer];
|
|
299
|
-
const matchesMagic = expected.every((byte, index) => actual[index] === byte);
|
|
300
|
-
if (!matchesMagic) {
|
|
301
|
-
throw new Error(
|
|
302
|
-
`${kind} archive ${file} has an unexpected signature [${actual.join(', ')}]`,
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
} finally {
|
|
306
|
-
if (fd !== undefined) {
|
|
307
|
-
try {
|
|
308
|
-
fs.closeSync(fd);
|
|
309
|
-
} catch {
|
|
310
|
-
// ignore
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Stream an HTTPS URL to `destination`, following redirects, calling onProgress(bytes, totalBytes)
|
|
318
|
-
* and honoring an AbortSignal. Throws AbortError when cancelled and removes the partial file.
|
|
319
|
-
*/
|
|
320
|
-
export async function downloadFile(url, destination, options = {}) {
|
|
321
|
-
const {
|
|
322
|
-
onProgress = () => {},
|
|
323
|
-
signal,
|
|
324
|
-
fetchImpl = globalThis.fetch,
|
|
325
|
-
headers = {},
|
|
326
|
-
timeoutMs = DOWNLOAD_TIMEOUT_MS,
|
|
327
|
-
minBytes = 1,
|
|
328
|
-
} = options;
|
|
329
|
-
throwIfAborted(signal);
|
|
330
|
-
if (typeof fetchImpl !== 'function') {
|
|
331
|
-
throw new Error('this runtime has no fetch implementation; cannot download the archive');
|
|
332
|
-
}
|
|
333
|
-
if (typeof destination !== 'string' || !destination.trim()) {
|
|
334
|
-
throw new TypeError('downloadFile(url, destination): destination is required');
|
|
335
|
-
}
|
|
336
|
-
fs.mkdirSync(W.dirname(destination), { recursive: true });
|
|
337
|
-
const linked = linkedSignal(signal, timeoutMs);
|
|
338
|
-
let received = 0;
|
|
339
|
-
try {
|
|
340
|
-
const response = await fetchImpl(url, {
|
|
341
|
-
headers,
|
|
342
|
-
redirect: 'follow',
|
|
343
|
-
signal: linked.signal,
|
|
344
|
-
});
|
|
345
|
-
if (!response || !response.ok) {
|
|
346
|
-
throw new Error(`download failed: HTTP ${response?.status ?? '?'} for ${url}`);
|
|
347
|
-
}
|
|
348
|
-
const contentLength = Number(response.headers?.get?.('content-length'));
|
|
349
|
-
const totalBytes =
|
|
350
|
-
Number.isFinite(contentLength) && contentLength > 0 ? contentLength : undefined;
|
|
351
|
-
if (!response.body) throw new Error(`download failed: empty response body for ${url}`);
|
|
352
|
-
const counter = new Transform({
|
|
353
|
-
transform(chunk, _encoding, callback) {
|
|
354
|
-
received += chunk.length;
|
|
355
|
-
try {
|
|
356
|
-
onProgress(received, totalBytes);
|
|
357
|
-
} catch {
|
|
358
|
-
// a faulty progress callback must never fail the download
|
|
359
|
-
}
|
|
360
|
-
callback(null, chunk);
|
|
361
|
-
},
|
|
362
|
-
});
|
|
363
|
-
const source = Readable.fromWeb(response.body);
|
|
364
|
-
await pipeline(source, counter, fs.createWriteStream(destination));
|
|
365
|
-
if (received < minBytes) {
|
|
366
|
-
throw new Error(
|
|
367
|
-
`download too small: ${received} bytes from ${url} (expected at least ${minBytes})`,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
return { url, bytes: received, totalBytes };
|
|
371
|
-
} catch (error) {
|
|
372
|
-
removeQuietly(destination);
|
|
373
|
-
throw ensureAbortError(error, signal);
|
|
374
|
-
} finally {
|
|
375
|
-
linked.cleanup();
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** Resolve the bsdtar to use; prefers the Windows system bsdtar over any tar on PATH. */
|
|
380
|
-
export function resolveTarPath(env = process.env) {
|
|
381
|
-
const override = env.DSH_PWSH7_TAR ?? env.DSH_TAR_PATH;
|
|
382
|
-
if (typeof override === 'string' && override.trim() && fileOrSymlinkExists(override.trim())) {
|
|
383
|
-
return override.trim();
|
|
384
|
-
}
|
|
385
|
-
const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? env.WINDIR ?? 'C:\\Windows';
|
|
386
|
-
const systemTar = W.join(systemRoot, 'System32', 'tar.exe');
|
|
387
|
-
if (fileOrSymlinkExists(systemTar)) return systemTar;
|
|
388
|
-
const entries = String(env.PATH ?? env.Path ?? '').split(W.delimiter);
|
|
389
|
-
for (const entry of entries) {
|
|
390
|
-
const directory = entry.trim().replace(/^"(.*)"$/, '$1');
|
|
391
|
-
if (!directory) continue;
|
|
392
|
-
const candidate = W.join(directory, 'tar.exe');
|
|
393
|
-
if (fileOrSymlinkExists(candidate)) return candidate;
|
|
394
|
-
}
|
|
395
|
-
return 'tar.exe';
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** List archive entries with `tar -tf`. Throws when tar fails. */
|
|
399
|
-
export async function listArchiveEntries(tarPath, archivePath, options = {}) {
|
|
400
|
-
const result = await runCommand(tarPath, ['-tf', archivePath], {
|
|
401
|
-
env: options.env,
|
|
402
|
-
signal: options.signal,
|
|
403
|
-
timeoutMs: options.timeoutMs ?? LISTING_TIMEOUT_MS,
|
|
404
|
-
});
|
|
405
|
-
if (!result.ok) {
|
|
406
|
-
throw new Error(
|
|
407
|
-
`could not list archive ${archivePath} with ${tarPath} (exit ${result.code}): ${
|
|
408
|
-
tail(result.stderr) || tail(result.stdout) || 'no output'
|
|
409
|
-
}`,
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
return result.stdout.split(/\r?\n/).filter((line) => line.length > 0);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/** Extract an archive with `tar.exe -xf`, optionally stripping a wrapper directory. */
|
|
416
|
-
export async function extractArchive(tarPath, archivePath, destination, options = {}) {
|
|
417
|
-
const stripComponents = Number.isInteger(options.stripComponents) ? options.stripComponents : 0;
|
|
418
|
-
throwIfAborted(options.signal);
|
|
419
|
-
fs.mkdirSync(destination, { recursive: true });
|
|
420
|
-
const args = [];
|
|
421
|
-
if (stripComponents > 0) args.push(`--strip-components=${stripComponents}`);
|
|
422
|
-
args.push('-xf', archivePath, '-C', destination);
|
|
423
|
-
const result = await runCommand(tarPath, args, {
|
|
424
|
-
env: options.env,
|
|
425
|
-
signal: options.signal,
|
|
426
|
-
timeoutMs: options.timeoutMs ?? EXTRACT_TIMEOUT_MS,
|
|
427
|
-
});
|
|
428
|
-
if (!result.ok) {
|
|
429
|
-
throw ensureAbortError(
|
|
430
|
-
new Error(
|
|
431
|
-
`archive extraction failed with ${tarPath} (exit ${result.code}): ${
|
|
432
|
-
tail(result.stderr) || tail(result.stdout) || 'no output'
|
|
433
|
-
}`,
|
|
434
|
-
),
|
|
435
|
-
options.signal,
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
return { stripComponents };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Extract an archive, automatically stripping a single wrapper directory if the archive has one.
|
|
443
|
-
* The PowerShell portable zip is flat (files at the root), so it is extracted as-is.
|
|
444
|
-
*/
|
|
445
|
-
export async function extractArchiveWithLayout(tarPath, archivePath, destination, options = {}) {
|
|
446
|
-
const entries = await listArchiveEntries(tarPath, archivePath, options);
|
|
447
|
-
const topLevelDirectory = detectSingleTopLevelDirectory(entries);
|
|
448
|
-
await extractArchive(tarPath, archivePath, destination, {
|
|
449
|
-
...options,
|
|
450
|
-
stripComponents: topLevelDirectory ? 1 : 0,
|
|
451
|
-
});
|
|
452
|
-
return { topLevelDirectory, entryCount: entries.length };
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
// PowerShell 7 release resolution
|
|
457
|
-
// ---------------------------------------------------------------------------
|
|
458
|
-
|
|
459
|
-
function buildPwsh7AssetFromTag(tag) {
|
|
460
|
-
const version = tag.replace(/^v/i, '');
|
|
461
|
-
const name = `PowerShell-${version}-win-x64.zip`;
|
|
462
|
-
return {
|
|
463
|
-
tag,
|
|
464
|
-
version,
|
|
465
|
-
name,
|
|
466
|
-
url: `https://github.com/PowerShell/PowerShell/releases/download/${tag}/${name}`,
|
|
467
|
-
synthetic: true,
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
async function resolveLatestPwsh7TagViaRedirect(options = {}) {
|
|
472
|
-
const response = await fetchWithTimeout(options.fetchImpl, PWSH_RELEASES_PAGE, {
|
|
473
|
-
headers: headersFor(options.env),
|
|
474
|
-
signal: options.signal,
|
|
475
|
-
timeoutMs: 30_000,
|
|
476
|
-
redirect: 'manual',
|
|
477
|
-
});
|
|
478
|
-
const location = response?.headers?.get?.('location') ?? '';
|
|
479
|
-
const match = /\/releases\/tag\/(v\d[\w.-]*)/.exec(location);
|
|
480
|
-
if (!match) throw new Error('could not read the latest release tag from the GitHub redirect');
|
|
481
|
-
return match[1];
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Resolve the latest PowerShell 7 win-x64 portable zip:
|
|
486
|
-
* GitHub REST API first, release-page redirect as a rate-limit fallback.
|
|
487
|
-
*/
|
|
488
|
-
export async function resolvePwsh7Release(options = {}) {
|
|
489
|
-
const env = options.env ?? process.env;
|
|
490
|
-
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
491
|
-
const signal = options.signal;
|
|
492
|
-
throwIfAborted(signal);
|
|
493
|
-
if (typeof fetchImpl !== 'function') throw new Error('this runtime has no fetch implementation');
|
|
494
|
-
|
|
495
|
-
const headers = headersFor(env, { Accept: 'application/vnd.github+json' });
|
|
496
|
-
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
497
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
const release = await fetchJson(fetchImpl, PWSH_API_LATEST, {
|
|
501
|
-
headers,
|
|
502
|
-
signal,
|
|
503
|
-
timeoutMs: 60_000,
|
|
504
|
-
});
|
|
505
|
-
const asset = selectPwsh7Asset(release);
|
|
506
|
-
if (!asset) {
|
|
507
|
-
throw new Error(
|
|
508
|
-
`latest PowerShell release ${release?.tag_name ?? '?'} has no PowerShell-*-win-x64.zip asset`,
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
return asset;
|
|
512
|
-
} catch (error) {
|
|
513
|
-
if (error?.name === 'AbortError' || signal?.aborted) throw ensureAbortError(error, signal);
|
|
514
|
-
try {
|
|
515
|
-
const tag = await resolveLatestPwsh7TagViaRedirect({ fetchImpl, env, signal });
|
|
516
|
-
return buildPwsh7AssetFromTag(tag);
|
|
517
|
-
} catch (fallbackError) {
|
|
518
|
-
if (fallbackError?.name === 'AbortError') throw fallbackError;
|
|
519
|
-
throw new Error(
|
|
520
|
-
`could not resolve the latest PowerShell 7 release (${error?.message ?? error}). ` +
|
|
521
|
-
`Download the win-x64 zip manually from ${PWSH_RELEASES_PAGE} and extract it, ` +
|
|
522
|
-
`or install with winget: ${wingetInstallManualCommand()}`,
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// ---------------------------------------------------------------------------
|
|
529
|
-
// winget fallback
|
|
530
|
-
// ---------------------------------------------------------------------------
|
|
531
|
-
|
|
532
|
-
function pwshOnly(component) {
|
|
533
|
-
if (component === undefined || component === null || component === 'pwsh') return 'pwsh';
|
|
534
|
-
throw new TypeError(
|
|
535
|
-
`this installer only supports the pwsh component (got ${JSON.stringify(component)})`,
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/** Locate winget.exe without spawning anything. */
|
|
540
|
-
export function resolveWingetPath(env = process.env) {
|
|
541
|
-
const seen = new Set();
|
|
542
|
-
const candidates = [];
|
|
543
|
-
const push = (candidate) => {
|
|
544
|
-
if (typeof candidate !== 'string' || !candidate.trim()) return;
|
|
545
|
-
const key = candidate.toLowerCase();
|
|
546
|
-
if (seen.has(key)) return;
|
|
547
|
-
seen.add(key);
|
|
548
|
-
candidates.push(candidate);
|
|
549
|
-
};
|
|
550
|
-
push(env.DSH_WINGET_PATH);
|
|
551
|
-
const pathValue = env.PATH ?? env.Path ?? env.path ?? '';
|
|
552
|
-
for (const entry of String(pathValue).split(W.delimiter)) {
|
|
553
|
-
const directory = entry.trim().replace(/^"(.*)"$/, '$1');
|
|
554
|
-
if (directory) push(W.join(directory, 'winget.exe'));
|
|
555
|
-
}
|
|
556
|
-
const appData =
|
|
557
|
-
env.LOCALAPPDATA ?? W.join(env.USERPROFILE ?? os.homedir(), 'AppData', 'Local');
|
|
558
|
-
push(W.join(appData, 'Microsoft', 'WindowsApps', 'winget.exe'));
|
|
559
|
-
return candidates.find((candidate) => fileOrSymlinkExists(candidate));
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/** True when winget.exe can be found for this environment. */
|
|
563
|
-
export function wingetAvailable(env = process.env) {
|
|
564
|
-
return Boolean(resolveWingetPath(env));
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/** winget install argument vector (PowerShell 7 only). */
|
|
568
|
-
export function wingetInstallArgs(component = 'pwsh') {
|
|
569
|
-
pwshOnly(component);
|
|
570
|
-
return [
|
|
571
|
-
'install',
|
|
572
|
-
'--id',
|
|
573
|
-
WINGET_PWSH_ID,
|
|
574
|
-
'-e',
|
|
575
|
-
'--silent',
|
|
576
|
-
'--accept-package-agreements',
|
|
577
|
-
'--accept-source-agreements',
|
|
578
|
-
'--disable-interactivity',
|
|
579
|
-
];
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** Exact command a user can run manually (elevated) when per-user installation is impossible. */
|
|
583
|
-
export function wingetInstallManualCommand(component = 'pwsh') {
|
|
584
|
-
pwshOnly(component);
|
|
585
|
-
return `winget install --id ${WINGET_PWSH_ID} -e --silent --accept-package-agreements --accept-source-agreements`;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/** Heuristic: winget output that means the install needs administrator elevation. */
|
|
589
|
-
export function looksLikeElevationRequired(text) {
|
|
590
|
-
const value = String(text ?? '');
|
|
591
|
-
if (!value) return false;
|
|
592
|
-
return (
|
|
593
|
-
/requires?\s+(administrator|elevation|admin)/i.test(value) ||
|
|
594
|
-
/requested\s+operation\s+requires\s+elevation/i.test(value) ||
|
|
595
|
-
/0x800704c7/i.test(value) ||
|
|
596
|
-
/需要提升|需要管理员|管理员权限/.test(value) ||
|
|
597
|
-
/uac|user account control/i.test(value)
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
export function manualInstallInstructions(component = 'pwsh') {
|
|
602
|
-
pwshOnly(component);
|
|
603
|
-
return (
|
|
604
|
-
`Install PowerShell 7 manually: download the win-x64 zip from ${PWSH_RELEASES_PAGE}, ` +
|
|
605
|
-
`or run from an elevated terminal: ${wingetInstallManualCommand()}`
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
async function installViaWinget(options = {}) {
|
|
610
|
-
const env = options.env ?? process.env;
|
|
611
|
-
const signal = options.signal;
|
|
612
|
-
throwIfAborted(signal);
|
|
613
|
-
const wingetPath = resolveWingetPath(env);
|
|
614
|
-
if (!wingetPath) {
|
|
615
|
-
return {
|
|
616
|
-
ok: false,
|
|
617
|
-
kind: 'unavailable',
|
|
618
|
-
message: `winget is not available on this system. ${manualInstallInstructions()}`,
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
const args = wingetInstallArgs();
|
|
622
|
-
const result = await runCommand(wingetPath, args, {
|
|
623
|
-
env,
|
|
624
|
-
signal,
|
|
625
|
-
timeoutMs: options.timeoutMs ?? WINGET_TIMEOUT_MS,
|
|
626
|
-
});
|
|
627
|
-
if (signal?.aborted || result.error?.name === 'AbortError') {
|
|
628
|
-
throw abortError(signal);
|
|
629
|
-
}
|
|
630
|
-
const combined = [result.stdout, result.stderr, result.error?.message]
|
|
631
|
-
.filter((part) => typeof part === 'string' && part.trim())
|
|
632
|
-
.join('\n')
|
|
633
|
-
.trim();
|
|
634
|
-
if (result.ok) return { ok: true, kind: 'installed', output: combined, command: wingetPath };
|
|
635
|
-
if (looksLikeElevationRequired(combined)) {
|
|
636
|
-
return {
|
|
637
|
-
ok: false,
|
|
638
|
-
kind: 'elevation',
|
|
639
|
-
message:
|
|
640
|
-
`automatic installation would require administrator elevation, so nothing was changed. ` +
|
|
641
|
-
`Open an elevated terminal (PowerShell 7 or cmd) and run:\n ${wingetInstallManualCommand()}\n` +
|
|
642
|
-
`Then reopen the app or rerun the install.`,
|
|
643
|
-
output: combined,
|
|
644
|
-
command: wingetPath,
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
return {
|
|
648
|
-
ok: false,
|
|
649
|
-
kind: 'failed',
|
|
650
|
-
message:
|
|
651
|
-
`winget install failed (exit ${result.code}): ${tail(combined) || 'no output'}. ` +
|
|
652
|
-
manualInstallInstructions(),
|
|
653
|
-
output: combined,
|
|
654
|
-
command: wingetPath,
|
|
655
|
-
};
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// ---------------------------------------------------------------------------
|
|
659
|
-
// PowerShell 7 installer
|
|
660
|
-
// ---------------------------------------------------------------------------
|
|
661
|
-
|
|
662
|
-
function baseResult(method, startedAt) {
|
|
663
|
-
return { ok: false, component: 'pwsh', method, durationMs: Date.now() - startedAt };
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Install PowerShell 7 per-user.
|
|
668
|
-
* opts: { env, root, method = 'auto'|'portable'|'winget', onProgress, signal, fetchImpl,
|
|
669
|
-
* tarPath, extractImpl, timeouts, wingetTimeoutMs, force, version, url, probePwsh }
|
|
670
|
-
* -> Result { ok, component: 'pwsh', method, path, version, message, url?, bytes?, durationMs }
|
|
671
|
-
*/
|
|
672
|
-
export async function installPowerShell7(options = {}) {
|
|
673
|
-
const startedAt = Date.now();
|
|
674
|
-
const env = options.env ?? process.env;
|
|
675
|
-
const root = options.root ?? routerRoot(env);
|
|
676
|
-
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
677
|
-
const signal = options.signal;
|
|
678
|
-
const method = options.method ?? 'auto';
|
|
679
|
-
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
680
|
-
const probeImpl = options.probePwsh ?? probePwsh;
|
|
681
|
-
const extractImpl = options.extractImpl ?? extractArchiveWithLayout;
|
|
682
|
-
const tarPath = options.tarPath ?? resolveTarPath(env);
|
|
683
|
-
|
|
684
|
-
const fail = (message, extra = {}) => ({
|
|
685
|
-
...baseResult(method, startedAt),
|
|
686
|
-
message,
|
|
687
|
-
...extra,
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
throwIfAborted(signal);
|
|
691
|
-
|
|
692
|
-
try {
|
|
693
|
-
// 1. Reuse a working installation and only write config.
|
|
694
|
-
if (!options.force) {
|
|
695
|
-
const existing = findPwsh({ env });
|
|
696
|
-
if (existing) {
|
|
697
|
-
const probe = await probeImpl(existing.path, { env, signal });
|
|
698
|
-
if (probe.ok) {
|
|
699
|
-
persistPwshConfig(env, { path: existing.path, version: probe.version, method: 'existing' });
|
|
700
|
-
return {
|
|
701
|
-
ok: true,
|
|
702
|
-
component: 'pwsh',
|
|
703
|
-
method: 'existing',
|
|
704
|
-
path: existing.path,
|
|
705
|
-
version: probe.version,
|
|
706
|
-
message: `using existing PowerShell ${probe.version ?? '(version unknown)'} at ${existing.path}`,
|
|
707
|
-
durationMs: Date.now() - startedAt,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// 2. winget-only strategy.
|
|
714
|
-
if (method === 'winget') {
|
|
715
|
-
const winget = await installViaWinget({
|
|
716
|
-
env,
|
|
717
|
-
signal,
|
|
718
|
-
timeoutMs: options.wingetTimeoutMs,
|
|
719
|
-
});
|
|
720
|
-
if (!winget.ok) {
|
|
721
|
-
return fail(winget.message, { method: 'winget', output: winget.output });
|
|
722
|
-
}
|
|
723
|
-
const located = findPwsh({ env });
|
|
724
|
-
const probe = located ? await probeImpl(located.path, { env, signal }) : { ok: false };
|
|
725
|
-
if (located && probe.ok) {
|
|
726
|
-
persistPwshConfig(env, { path: located.path, version: probe.version, method: 'winget' });
|
|
727
|
-
return {
|
|
728
|
-
ok: true,
|
|
729
|
-
component: 'pwsh',
|
|
730
|
-
method: 'winget',
|
|
731
|
-
path: located.path,
|
|
732
|
-
version: probe.version,
|
|
733
|
-
message: `installed PowerShell ${probe.version} via winget at ${located.path}`,
|
|
734
|
-
durationMs: Date.now() - startedAt,
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
return fail(
|
|
738
|
-
`winget reported success but no PowerShell 7 executable could be verified. ` +
|
|
739
|
-
`Open a new terminal so PATH refreshes, then rerun the install or status. ${
|
|
740
|
-
winget.output ? `winget output: ${tail(winget.output)}` : ''
|
|
741
|
-
}`,
|
|
742
|
-
{ method: 'winget', output: winget.output },
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// 3. Portable zip first for 'portable' and 'auto'.
|
|
747
|
-
let portableError;
|
|
748
|
-
let portableUrl = options.url;
|
|
749
|
-
try {
|
|
750
|
-
const installed = await installPowerShell7Portable({
|
|
751
|
-
env,
|
|
752
|
-
root,
|
|
753
|
-
method,
|
|
754
|
-
onProgress,
|
|
755
|
-
signal,
|
|
756
|
-
fetchImpl,
|
|
757
|
-
tarPath,
|
|
758
|
-
probeImpl,
|
|
759
|
-
extractImpl,
|
|
760
|
-
startedAt,
|
|
761
|
-
force: options.force,
|
|
762
|
-
version: options.version,
|
|
763
|
-
url: options.url,
|
|
764
|
-
});
|
|
765
|
-
return installed;
|
|
766
|
-
} catch (error) {
|
|
767
|
-
if (error?.name === 'AbortError' || signal?.aborted) throw ensureAbortError(error, signal);
|
|
768
|
-
portableError = error;
|
|
769
|
-
if (error?.url) portableUrl = error.url;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
if (method === 'portable') {
|
|
773
|
-
return fail(`portable PowerShell 7 install failed: ${portableError?.message ?? portableError}`, {
|
|
774
|
-
url: portableUrl ?? PWSH_RELEASES_PAGE,
|
|
775
|
-
hint: manualInstallInstructions(),
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// 4. 'auto' falls back to winget.
|
|
780
|
-
const winget = await installViaWinget({
|
|
781
|
-
env,
|
|
782
|
-
signal,
|
|
783
|
-
timeoutMs: options.wingetTimeoutMs,
|
|
784
|
-
});
|
|
785
|
-
if (!winget.ok) {
|
|
786
|
-
return fail(
|
|
787
|
-
`portable install failed (${portableError?.message ?? portableError}); ` +
|
|
788
|
-
`winget fallback also failed: ${winget.message}`,
|
|
789
|
-
{ method: winget.kind === 'unavailable' ? 'portable' : 'winget', output: winget.output },
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
const located = findPwsh({ env });
|
|
793
|
-
const probe = located ? await probeImpl(located.path, { env, signal }) : { ok: false };
|
|
794
|
-
if (located && probe.ok) {
|
|
795
|
-
persistPwshConfig(env, { path: located.path, version: probe.version, method: 'winget' });
|
|
796
|
-
return {
|
|
797
|
-
ok: true,
|
|
798
|
-
component: 'pwsh',
|
|
799
|
-
method: 'winget',
|
|
800
|
-
path: located.path,
|
|
801
|
-
version: probe.version,
|
|
802
|
-
message:
|
|
803
|
-
`portable install failed (${portableError?.message ?? portableError}); ` +
|
|
804
|
-
`installed PowerShell ${probe.version} via winget at ${located.path}`,
|
|
805
|
-
durationMs: Date.now() - startedAt,
|
|
806
|
-
};
|
|
807
|
-
}
|
|
808
|
-
return fail(
|
|
809
|
-
`portable install failed (${portableError?.message ?? portableError}) and the winget fallback ` +
|
|
810
|
-
`could not verify a PowerShell 7 executable. Open a new terminal and rerun, or install manually. ` +
|
|
811
|
-
manualInstallInstructions(),
|
|
812
|
-
{ method: 'winget', output: winget.output },
|
|
813
|
-
);
|
|
814
|
-
} catch (error) {
|
|
815
|
-
if (error?.name === 'AbortError' || signal?.aborted) throw ensureAbortError(error, signal);
|
|
816
|
-
return fail(error?.message ?? String(error), { hint: manualInstallInstructions() });
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
async function installPowerShell7Portable(context) {
|
|
821
|
-
const {
|
|
822
|
-
env,
|
|
823
|
-
root,
|
|
824
|
-
onProgress,
|
|
825
|
-
signal,
|
|
826
|
-
fetchImpl,
|
|
827
|
-
tarPath,
|
|
828
|
-
probeImpl,
|
|
829
|
-
extractImpl,
|
|
830
|
-
startedAt,
|
|
831
|
-
force,
|
|
832
|
-
version,
|
|
833
|
-
url,
|
|
834
|
-
} = context;
|
|
835
|
-
|
|
836
|
-
let release;
|
|
837
|
-
if (url) {
|
|
838
|
-
const name = W.basename(new URL(url).pathname);
|
|
839
|
-
const parsed = parsePwsh7ZipName(name);
|
|
840
|
-
release = { url, name, version: version ?? parsed?.version ?? 'custom' };
|
|
841
|
-
} else {
|
|
842
|
-
release = await resolvePwsh7Release({ env, fetchImpl, signal });
|
|
843
|
-
}
|
|
844
|
-
if (!release?.version || release.version === 'custom') {
|
|
845
|
-
throw new Error(`could not determine the PowerShell 7 version to install from ${release?.url}`);
|
|
846
|
-
}
|
|
847
|
-
throwIfAborted(signal);
|
|
848
|
-
|
|
849
|
-
const targetRoot = W.join(root, 'powershell7', release.version);
|
|
850
|
-
const pwshExe = W.join(targetRoot, 'pwsh.exe');
|
|
851
|
-
const tmpDir = W.join(root, 'tmp');
|
|
852
|
-
const archiveName = release.name ?? `PowerShell-${release.version}-win-x64.zip`;
|
|
853
|
-
const archivePath = W.join(tmpDir, archiveName);
|
|
854
|
-
let bytes;
|
|
855
|
-
let downloaded = false;
|
|
856
|
-
|
|
857
|
-
try {
|
|
858
|
-
if (force || !fileOrSymlinkExists(pwshExe)) {
|
|
859
|
-
const download = await downloadFile(release.url, archivePath, {
|
|
860
|
-
onProgress,
|
|
861
|
-
signal,
|
|
862
|
-
fetchImpl,
|
|
863
|
-
headers: headersFor(env),
|
|
864
|
-
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
|
865
|
-
minBytes: PWSH7_MIN_DOWNLOAD_BYTES,
|
|
866
|
-
});
|
|
867
|
-
bytes = download.bytes;
|
|
868
|
-
downloaded = true;
|
|
869
|
-
verifyArchiveMagic(archivePath, 'zip');
|
|
870
|
-
fs.mkdirSync(targetRoot, { recursive: true });
|
|
871
|
-
await extractImpl(tarPath, archivePath, targetRoot, {
|
|
872
|
-
env,
|
|
873
|
-
signal,
|
|
874
|
-
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
} catch (error) {
|
|
878
|
-
const wrapped = ensureAbortError(error, signal);
|
|
879
|
-
if (wrapped?.name !== 'AbortError') wrapped.url = release.url;
|
|
880
|
-
throw wrapped;
|
|
881
|
-
} finally {
|
|
882
|
-
if (downloaded) removeQuietly(archivePath);
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (!fileOrSymlinkExists(pwshExe)) {
|
|
886
|
-
throw new Error(
|
|
887
|
-
`pwsh.exe was not found after extracting ${archiveName} into ${targetRoot}; ` +
|
|
888
|
-
`the archive layout may have changed`,
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
const probe = await probeImpl(pwshExe, { env, signal });
|
|
892
|
-
if (!probe.ok) {
|
|
893
|
-
throw new Error(
|
|
894
|
-
`extracted pwsh.exe at ${pwshExe} failed verification: ${
|
|
895
|
-
tail(probe.output) || 'no version output'
|
|
896
|
-
}`,
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
const installedVersion = probe.version ?? release.version;
|
|
900
|
-
persistPwshConfig(env, { path: pwshExe, version: installedVersion, method: 'portable' });
|
|
901
|
-
return {
|
|
902
|
-
ok: true,
|
|
903
|
-
component: 'pwsh',
|
|
904
|
-
method: 'portable',
|
|
905
|
-
path: pwshExe,
|
|
906
|
-
version: installedVersion,
|
|
907
|
-
url: release.url,
|
|
908
|
-
bytes,
|
|
909
|
-
message: `installed PowerShell ${installedVersion} at ${pwshExe}`,
|
|
910
|
-
durationMs: Date.now() - startedAt,
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// ---------------------------------------------------------------------------
|
|
915
|
-
// Orchestrator
|
|
916
|
-
// ---------------------------------------------------------------------------
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* Ensure PowerShell 7 is available, per-user.
|
|
920
|
-
* -> { ok, result, notes, aborted }
|
|
921
|
-
* result is always present: a Result { ok, component: 'pwsh', method, path?, version?,
|
|
922
|
-
* message, url?, bytes?, durationMs }. `aborted` is true when the signal cancelled the work.
|
|
923
|
-
*/
|
|
924
|
-
export async function ensurePwsh(options = {}) {
|
|
925
|
-
const {
|
|
926
|
-
env = process.env,
|
|
927
|
-
root = routerRoot(env),
|
|
928
|
-
method = 'auto',
|
|
929
|
-
onProgress = () => {},
|
|
930
|
-
signal,
|
|
931
|
-
...rest
|
|
932
|
-
} = options;
|
|
933
|
-
if (!['auto', 'portable', 'winget'].includes(method)) {
|
|
934
|
-
throw new TypeError(`ensurePwsh: unsupported method ${JSON.stringify(method)}`);
|
|
935
|
-
}
|
|
936
|
-
const notes = [];
|
|
937
|
-
if (signal?.aborted) {
|
|
938
|
-
const result = {
|
|
939
|
-
ok: false,
|
|
940
|
-
component: 'pwsh',
|
|
941
|
-
method,
|
|
942
|
-
message: 'aborted before installation started',
|
|
943
|
-
durationMs: 0,
|
|
944
|
-
};
|
|
945
|
-
return { ok: false, aborted: true, result, notes: ['pwsh: aborted'] };
|
|
946
|
-
}
|
|
947
|
-
notes.push(`pwsh: installing (method: ${method})`);
|
|
948
|
-
let result;
|
|
949
|
-
try {
|
|
950
|
-
// `...rest` forwards test seams (fetchImpl, extractImpl, probePwsh, ...) to the installer.
|
|
951
|
-
result = await installPowerShell7({ env, root, method, onProgress, signal, ...rest });
|
|
952
|
-
} catch (error) {
|
|
953
|
-
if (error?.name === 'AbortError' || signal?.aborted) {
|
|
954
|
-
result = {
|
|
955
|
-
ok: false,
|
|
956
|
-
component: 'pwsh',
|
|
957
|
-
method,
|
|
958
|
-
message: 'aborted',
|
|
959
|
-
durationMs: 0,
|
|
960
|
-
};
|
|
961
|
-
notes.push('pwsh: aborted');
|
|
962
|
-
return { ok: false, aborted: true, result, notes };
|
|
963
|
-
}
|
|
964
|
-
result = {
|
|
965
|
-
ok: false,
|
|
966
|
-
component: 'pwsh',
|
|
967
|
-
method,
|
|
968
|
-
message: error?.message ?? String(error),
|
|
969
|
-
durationMs: 0,
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
notes.push(
|
|
973
|
-
result.ok
|
|
974
|
-
? `pwsh: ok (${result.method}${result.version ? `, ${result.version}` : ''})`
|
|
975
|
-
: `pwsh: failed — ${result.message}`,
|
|
976
|
-
);
|
|
977
|
-
return { ok: result.ok === true, result, notes, aborted: false };
|
|
978
|
-
}
|
|
1
|
+
// lib/install.mjs — per-user portable installer for PowerShell 7.
|
|
2
|
+
//
|
|
3
|
+
// Everything is user-scoped: files land under <routerRoot> (default
|
|
4
|
+
// %LOCALAPPDATA%\dsh\pwsh7), downloads under <routerRoot>\tmp, archives are extracted with
|
|
5
|
+
// the Windows bsdtar (tar.exe). No administrator rights are ever requested. When a portable
|
|
6
|
+
// install is impossible the result carries the exact manual command instead of hanging on UAC.
|
|
7
|
+
//
|
|
8
|
+
// Public contract:
|
|
9
|
+
// USER_AGENT, PWSH_API_LATEST, PWSH_RELEASES_PAGE, WINGET_PWSH_ID
|
|
10
|
+
// ensurePwsh({ env, root, method, onProgress, signal }) -> { ok, result, notes, aborted }
|
|
11
|
+
// result: { ok, component: 'pwsh', method, path?, version?, message, url?, bytes?,
|
|
12
|
+
// durationMs }
|
|
13
|
+
// installPowerShell7(options) -> Result (same shape as above)
|
|
14
|
+
// resolvePwsh7Release(options) -> { tag, version, name, url, size? }
|
|
15
|
+
// selectPwsh7Asset(release) -> asset descriptor | undefined
|
|
16
|
+
// parsePwsh7ZipName(name) -> { version } | undefined
|
|
17
|
+
// downloadFile(url, destination, options) -> { url, bytes, totalBytes? }
|
|
18
|
+
// listArchiveEntries / extractArchive / extractArchiveWithLayout /
|
|
19
|
+
// detectSingleTopLevelDirectory / verifyArchiveMagic / resolveTarPath
|
|
20
|
+
// resolveWingetPath / wingetAvailable / wingetInstallArgs / wingetInstallManualCommand /
|
|
21
|
+
// manualInstallInstructions / looksLikeElevationRequired / throwIfAborted
|
|
22
|
+
//
|
|
23
|
+
// Extra pure helpers are internal but exported so test/install.test.mjs can exercise them.
|
|
24
|
+
// Only Node builtins and global fetch are used; there are no @deepseek-ai imports.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import { Readable, Transform } from 'node:stream';
|
|
31
|
+
import { pipeline } from 'node:stream/promises';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
routerRoot,
|
|
35
|
+
readRouterConfig,
|
|
36
|
+
writeRouterConfig,
|
|
37
|
+
findPwsh,
|
|
38
|
+
probePwsh,
|
|
39
|
+
fileOrSymlinkExists,
|
|
40
|
+
} from './paths.mjs';
|
|
41
|
+
|
|
42
|
+
const W = path.win32;
|
|
43
|
+
|
|
44
|
+
export const USER_AGENT = 'dsh-plugin-pwsh7';
|
|
45
|
+
export const PWSH_API_LATEST = 'https://api.github.com/repos/PowerShell/PowerShell/releases/latest';
|
|
46
|
+
export const PWSH_RELEASES_PAGE = 'https://github.com/PowerShell/PowerShell/releases/latest';
|
|
47
|
+
export const WINGET_PWSH_ID = 'Microsoft.PowerShell';
|
|
48
|
+
|
|
49
|
+
const PWSH7_MIN_DOWNLOAD_BYTES = 5 * 1024 * 1024;
|
|
50
|
+
const DOWNLOAD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
51
|
+
const EXTRACT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
52
|
+
const WINGET_TIMEOUT_MS = 20 * 60 * 1000;
|
|
53
|
+
const LISTING_TIMEOUT_MS = 2 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Small shared helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function nowIso() {
|
|
60
|
+
return new Date().toISOString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function abortError(signal) {
|
|
64
|
+
const reason = signal?.reason;
|
|
65
|
+
const error = new Error(
|
|
66
|
+
reason instanceof Error && reason.message ? reason.message : 'operation aborted',
|
|
67
|
+
);
|
|
68
|
+
error.name = 'AbortError';
|
|
69
|
+
return error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureAbortError(error, signal) {
|
|
73
|
+
if (error?.name === 'AbortError') return error;
|
|
74
|
+
if (signal?.aborted) return abortError(signal);
|
|
75
|
+
return error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function throwIfAborted(signal) {
|
|
79
|
+
if (signal?.aborted) throw abortError(signal);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tail(text, limit = 600) {
|
|
83
|
+
const value = String(text ?? '').trim();
|
|
84
|
+
if (!value) return '';
|
|
85
|
+
return value.length <= limit ? value : `…${value.slice(-limit)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function removeQuietly(target) {
|
|
89
|
+
if (typeof target !== 'string' || !target.trim()) return;
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
92
|
+
} catch {
|
|
93
|
+
// best-effort cleanup; never fail an install because of leftover temp files
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function linkedSignal(signal, timeoutMs) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const forward = () => controller.abort(abortError(signal));
|
|
100
|
+
if (signal) {
|
|
101
|
+
if (signal.aborted) controller.abort(abortError(signal));
|
|
102
|
+
else signal.addEventListener('abort', forward, { once: true });
|
|
103
|
+
}
|
|
104
|
+
let timer;
|
|
105
|
+
if (timeoutMs > 0 && !controller.signal.aborted) {
|
|
106
|
+
timer = setTimeout(
|
|
107
|
+
() => controller.abort(new Error(`operation timed out after ${timeoutMs} ms`)),
|
|
108
|
+
timeoutMs,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
cleanup() {
|
|
114
|
+
if (timer) clearTimeout(timer);
|
|
115
|
+
if (signal) signal.removeEventListener('abort', forward);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchWithTimeout(fetchImpl, url, options = {}) {
|
|
121
|
+
const linked = linkedSignal(options.signal, options.timeoutMs ?? LISTING_TIMEOUT_MS);
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetchImpl(url, {
|
|
124
|
+
headers: options.headers ?? {},
|
|
125
|
+
redirect: options.redirect ?? 'follow',
|
|
126
|
+
signal: linked.signal,
|
|
127
|
+
});
|
|
128
|
+
return response;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw ensureAbortError(error, options.signal);
|
|
131
|
+
} finally {
|
|
132
|
+
linked.cleanup();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function runCommand(file, args, options = {}) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
let settled = false;
|
|
139
|
+
const finish = (value) => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
resolve(value);
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
execFile(
|
|
146
|
+
file,
|
|
147
|
+
args,
|
|
148
|
+
{
|
|
149
|
+
timeout: options.timeoutMs ?? EXTRACT_TIMEOUT_MS,
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
153
|
+
env: options.env ?? process.env,
|
|
154
|
+
cwd: options.cwd,
|
|
155
|
+
signal: options.signal,
|
|
156
|
+
},
|
|
157
|
+
(error, stdout, stderr) => {
|
|
158
|
+
finish({
|
|
159
|
+
ok: !error,
|
|
160
|
+
code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
|
|
161
|
+
stdout: stdout ?? '',
|
|
162
|
+
stderr: stderr ?? '',
|
|
163
|
+
error,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
finish({ ok: false, code: 1, stdout: '', stderr: String(error?.message ?? error), error });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fetchJson(fetchImpl, url, options = {}) {
|
|
174
|
+
const response = await fetchWithTimeout(fetchImpl, url, options);
|
|
175
|
+
if (!response?.ok) throw new Error(`HTTP ${response?.status ?? '?'} for ${url}`);
|
|
176
|
+
return response.json();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function headersFor(env, extra = {}) {
|
|
180
|
+
return { 'User-Agent': USER_AGENT, ...extra };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Config persistence (inside the router root only)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function updateRouterConfig(env, patch) {
|
|
188
|
+
const current = readRouterConfig(env) ?? {};
|
|
189
|
+
const installed =
|
|
190
|
+
current.installed && typeof current.installed === 'object' ? { ...current.installed } : {};
|
|
191
|
+
if (patch.installed) {
|
|
192
|
+
for (const [component, value] of Object.entries(patch.installed)) {
|
|
193
|
+
installed[component] = { ...(installed[component] ?? {}), ...value };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const next = { ...current, ...patch, version: current.version ?? 1 };
|
|
197
|
+
next.installed = installed;
|
|
198
|
+
writeRouterConfig(next, env);
|
|
199
|
+
return next;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function persistPwshConfig(env, { path: pwshPath, version, method, at = nowIso() }) {
|
|
203
|
+
const entry = { path: pwshPath, method, at };
|
|
204
|
+
if (version) entry.version = version;
|
|
205
|
+
return updateRouterConfig(env, { pwshPath, installed: { pwsh: entry } });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Asset name parsing and selection (pure — unit tested)
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** Parse a PowerShell win-x64 portable zip name into { version } or undefined. */
|
|
213
|
+
export function parsePwsh7ZipName(name) {
|
|
214
|
+
const match = /^PowerShell-(.+)-win-x64\.zip$/i.exec(String(name ?? '').trim());
|
|
215
|
+
return match ? { version: match[1] } : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Pick the win-x64 portable zip asset out of a GitHub release JSON document. */
|
|
219
|
+
export function selectPwsh7Asset(release) {
|
|
220
|
+
if (!release || typeof release !== 'object' || !Array.isArray(release.assets)) return undefined;
|
|
221
|
+
const tag = typeof release.tag_name === 'string' ? release.tag_name.trim() : '';
|
|
222
|
+
const version = tag.replace(/^v/i, '');
|
|
223
|
+
const matches = release.assets.filter((asset) => {
|
|
224
|
+
if (!asset || typeof asset.name !== 'string') return false;
|
|
225
|
+
return /^PowerShell-.+-win-x64\.zip$/i.test(asset.name);
|
|
226
|
+
});
|
|
227
|
+
if (!matches.length) return undefined;
|
|
228
|
+
const expectedName = version ? `PowerShell-${version}-win-x64.zip` : '';
|
|
229
|
+
const asset =
|
|
230
|
+
(expectedName && matches.find((item) => item.name.toLowerCase() === expectedName.toLowerCase())) ||
|
|
231
|
+
matches[0];
|
|
232
|
+
const parsed = parsePwsh7ZipName(asset.name);
|
|
233
|
+
return {
|
|
234
|
+
tag,
|
|
235
|
+
version: parsed?.version ?? version,
|
|
236
|
+
name: asset.name,
|
|
237
|
+
url:
|
|
238
|
+
asset.browser_download_url ??
|
|
239
|
+
`https://github.com/PowerShell/PowerShell/releases/download/${tag}/${asset.name}`,
|
|
240
|
+
size: typeof asset.size === 'number' ? asset.size : undefined,
|
|
241
|
+
asset,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Detect a single wrapper directory in archive entries (e.g. an archive rooted at "pwsh-root/").
|
|
247
|
+
* Returns the directory name when every entry lives under exactly one directory, otherwise
|
|
248
|
+
* undefined (files at the archive root, or several top-level entries). PowerShell portable zips
|
|
249
|
+
* are flat, so they normally return undefined and are extracted without stripping.
|
|
250
|
+
*/
|
|
251
|
+
export function detectSingleTopLevelDirectory(entries) {
|
|
252
|
+
const segments = new Set();
|
|
253
|
+
let sawEntry = false;
|
|
254
|
+
for (const entry of entries ?? []) {
|
|
255
|
+
if (typeof entry !== 'string') continue;
|
|
256
|
+
const cleaned = entry.replace(/^\.\//, '').replace(/\r$/, '');
|
|
257
|
+
if (!cleaned) continue;
|
|
258
|
+
sawEntry = true;
|
|
259
|
+
const isDirectoryEntry = /[\\/]$/.test(cleaned);
|
|
260
|
+
const withoutTrailing = cleaned.replace(/[\\/]+$/, '');
|
|
261
|
+
if (!withoutTrailing) continue;
|
|
262
|
+
const slash = withoutTrailing.search(/[\\/]/);
|
|
263
|
+
if (slash === -1) {
|
|
264
|
+
if (isDirectoryEntry) {
|
|
265
|
+
segments.add(withoutTrailing);
|
|
266
|
+
if (segments.size > 1) return undefined;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// A plain file at the archive root: the archive has no wrapper directory.
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
segments.add(withoutTrailing.slice(0, slash));
|
|
273
|
+
if (segments.size > 1) return undefined;
|
|
274
|
+
}
|
|
275
|
+
return sawEntry && segments.size === 1 ? [...segments][0] : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Downloading / extracting
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
const ARCHIVE_MAGIC = {
|
|
283
|
+
zip: [0x50, 0x4b, 0x03, 0x04],
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** Check the leading magic bytes of a downloaded archive before extraction. */
|
|
287
|
+
export function verifyArchiveMagic(file, kind) {
|
|
288
|
+
const expected = ARCHIVE_MAGIC[kind];
|
|
289
|
+
if (!expected) throw new Error(`verifyArchiveMagic: unknown archive kind ${kind}`);
|
|
290
|
+
let fd;
|
|
291
|
+
try {
|
|
292
|
+
fd = fs.openSync(file, 'r');
|
|
293
|
+
const buffer = Buffer.alloc(expected.length);
|
|
294
|
+
const bytesRead = fs.readSync(fd, buffer, 0, expected.length, 0);
|
|
295
|
+
if (bytesRead !== expected.length) {
|
|
296
|
+
throw new Error(`${kind} archive ${file} is truncated (${bytesRead} header bytes)`);
|
|
297
|
+
}
|
|
298
|
+
const actual = [...buffer];
|
|
299
|
+
const matchesMagic = expected.every((byte, index) => actual[index] === byte);
|
|
300
|
+
if (!matchesMagic) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`${kind} archive ${file} has an unexpected signature [${actual.join(', ')}]`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
if (fd !== undefined) {
|
|
307
|
+
try {
|
|
308
|
+
fs.closeSync(fd);
|
|
309
|
+
} catch {
|
|
310
|
+
// ignore
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Stream an HTTPS URL to `destination`, following redirects, calling onProgress(bytes, totalBytes)
|
|
318
|
+
* and honoring an AbortSignal. Throws AbortError when cancelled and removes the partial file.
|
|
319
|
+
*/
|
|
320
|
+
export async function downloadFile(url, destination, options = {}) {
|
|
321
|
+
const {
|
|
322
|
+
onProgress = () => {},
|
|
323
|
+
signal,
|
|
324
|
+
fetchImpl = globalThis.fetch,
|
|
325
|
+
headers = {},
|
|
326
|
+
timeoutMs = DOWNLOAD_TIMEOUT_MS,
|
|
327
|
+
minBytes = 1,
|
|
328
|
+
} = options;
|
|
329
|
+
throwIfAborted(signal);
|
|
330
|
+
if (typeof fetchImpl !== 'function') {
|
|
331
|
+
throw new Error('this runtime has no fetch implementation; cannot download the archive');
|
|
332
|
+
}
|
|
333
|
+
if (typeof destination !== 'string' || !destination.trim()) {
|
|
334
|
+
throw new TypeError('downloadFile(url, destination): destination is required');
|
|
335
|
+
}
|
|
336
|
+
fs.mkdirSync(W.dirname(destination), { recursive: true });
|
|
337
|
+
const linked = linkedSignal(signal, timeoutMs);
|
|
338
|
+
let received = 0;
|
|
339
|
+
try {
|
|
340
|
+
const response = await fetchImpl(url, {
|
|
341
|
+
headers,
|
|
342
|
+
redirect: 'follow',
|
|
343
|
+
signal: linked.signal,
|
|
344
|
+
});
|
|
345
|
+
if (!response || !response.ok) {
|
|
346
|
+
throw new Error(`download failed: HTTP ${response?.status ?? '?'} for ${url}`);
|
|
347
|
+
}
|
|
348
|
+
const contentLength = Number(response.headers?.get?.('content-length'));
|
|
349
|
+
const totalBytes =
|
|
350
|
+
Number.isFinite(contentLength) && contentLength > 0 ? contentLength : undefined;
|
|
351
|
+
if (!response.body) throw new Error(`download failed: empty response body for ${url}`);
|
|
352
|
+
const counter = new Transform({
|
|
353
|
+
transform(chunk, _encoding, callback) {
|
|
354
|
+
received += chunk.length;
|
|
355
|
+
try {
|
|
356
|
+
onProgress(received, totalBytes);
|
|
357
|
+
} catch {
|
|
358
|
+
// a faulty progress callback must never fail the download
|
|
359
|
+
}
|
|
360
|
+
callback(null, chunk);
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const source = Readable.fromWeb(response.body);
|
|
364
|
+
await pipeline(source, counter, fs.createWriteStream(destination));
|
|
365
|
+
if (received < minBytes) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`download too small: ${received} bytes from ${url} (expected at least ${minBytes})`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return { url, bytes: received, totalBytes };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
removeQuietly(destination);
|
|
373
|
+
throw ensureAbortError(error, signal);
|
|
374
|
+
} finally {
|
|
375
|
+
linked.cleanup();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Resolve the bsdtar to use; prefers the Windows system bsdtar over any tar on PATH. */
|
|
380
|
+
export function resolveTarPath(env = process.env) {
|
|
381
|
+
const override = env.DSH_PWSH7_TAR ?? env.DSH_TAR_PATH;
|
|
382
|
+
if (typeof override === 'string' && override.trim() && fileOrSymlinkExists(override.trim())) {
|
|
383
|
+
return override.trim();
|
|
384
|
+
}
|
|
385
|
+
const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? env.WINDIR ?? 'C:\\Windows';
|
|
386
|
+
const systemTar = W.join(systemRoot, 'System32', 'tar.exe');
|
|
387
|
+
if (fileOrSymlinkExists(systemTar)) return systemTar;
|
|
388
|
+
const entries = String(env.PATH ?? env.Path ?? '').split(W.delimiter);
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
const directory = entry.trim().replace(/^"(.*)"$/, '$1');
|
|
391
|
+
if (!directory) continue;
|
|
392
|
+
const candidate = W.join(directory, 'tar.exe');
|
|
393
|
+
if (fileOrSymlinkExists(candidate)) return candidate;
|
|
394
|
+
}
|
|
395
|
+
return 'tar.exe';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** List archive entries with `tar -tf`. Throws when tar fails. */
|
|
399
|
+
export async function listArchiveEntries(tarPath, archivePath, options = {}) {
|
|
400
|
+
const result = await runCommand(tarPath, ['-tf', archivePath], {
|
|
401
|
+
env: options.env,
|
|
402
|
+
signal: options.signal,
|
|
403
|
+
timeoutMs: options.timeoutMs ?? LISTING_TIMEOUT_MS,
|
|
404
|
+
});
|
|
405
|
+
if (!result.ok) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`could not list archive ${archivePath} with ${tarPath} (exit ${result.code}): ${
|
|
408
|
+
tail(result.stderr) || tail(result.stdout) || 'no output'
|
|
409
|
+
}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return result.stdout.split(/\r?\n/).filter((line) => line.length > 0);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Extract an archive with `tar.exe -xf`, optionally stripping a wrapper directory. */
|
|
416
|
+
export async function extractArchive(tarPath, archivePath, destination, options = {}) {
|
|
417
|
+
const stripComponents = Number.isInteger(options.stripComponents) ? options.stripComponents : 0;
|
|
418
|
+
throwIfAborted(options.signal);
|
|
419
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
420
|
+
const args = [];
|
|
421
|
+
if (stripComponents > 0) args.push(`--strip-components=${stripComponents}`);
|
|
422
|
+
args.push('-xf', archivePath, '-C', destination);
|
|
423
|
+
const result = await runCommand(tarPath, args, {
|
|
424
|
+
env: options.env,
|
|
425
|
+
signal: options.signal,
|
|
426
|
+
timeoutMs: options.timeoutMs ?? EXTRACT_TIMEOUT_MS,
|
|
427
|
+
});
|
|
428
|
+
if (!result.ok) {
|
|
429
|
+
throw ensureAbortError(
|
|
430
|
+
new Error(
|
|
431
|
+
`archive extraction failed with ${tarPath} (exit ${result.code}): ${
|
|
432
|
+
tail(result.stderr) || tail(result.stdout) || 'no output'
|
|
433
|
+
}`,
|
|
434
|
+
),
|
|
435
|
+
options.signal,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return { stripComponents };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Extract an archive, automatically stripping a single wrapper directory if the archive has one.
|
|
443
|
+
* The PowerShell portable zip is flat (files at the root), so it is extracted as-is.
|
|
444
|
+
*/
|
|
445
|
+
export async function extractArchiveWithLayout(tarPath, archivePath, destination, options = {}) {
|
|
446
|
+
const entries = await listArchiveEntries(tarPath, archivePath, options);
|
|
447
|
+
const topLevelDirectory = detectSingleTopLevelDirectory(entries);
|
|
448
|
+
await extractArchive(tarPath, archivePath, destination, {
|
|
449
|
+
...options,
|
|
450
|
+
stripComponents: topLevelDirectory ? 1 : 0,
|
|
451
|
+
});
|
|
452
|
+
return { topLevelDirectory, entryCount: entries.length };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// PowerShell 7 release resolution
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
function buildPwsh7AssetFromTag(tag) {
|
|
460
|
+
const version = tag.replace(/^v/i, '');
|
|
461
|
+
const name = `PowerShell-${version}-win-x64.zip`;
|
|
462
|
+
return {
|
|
463
|
+
tag,
|
|
464
|
+
version,
|
|
465
|
+
name,
|
|
466
|
+
url: `https://github.com/PowerShell/PowerShell/releases/download/${tag}/${name}`,
|
|
467
|
+
synthetic: true,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function resolveLatestPwsh7TagViaRedirect(options = {}) {
|
|
472
|
+
const response = await fetchWithTimeout(options.fetchImpl, PWSH_RELEASES_PAGE, {
|
|
473
|
+
headers: headersFor(options.env),
|
|
474
|
+
signal: options.signal,
|
|
475
|
+
timeoutMs: 30_000,
|
|
476
|
+
redirect: 'manual',
|
|
477
|
+
});
|
|
478
|
+
const location = response?.headers?.get?.('location') ?? '';
|
|
479
|
+
const match = /\/releases\/tag\/(v\d[\w.-]*)/.exec(location);
|
|
480
|
+
if (!match) throw new Error('could not read the latest release tag from the GitHub redirect');
|
|
481
|
+
return match[1];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Resolve the latest PowerShell 7 win-x64 portable zip:
|
|
486
|
+
* GitHub REST API first, release-page redirect as a rate-limit fallback.
|
|
487
|
+
*/
|
|
488
|
+
export async function resolvePwsh7Release(options = {}) {
|
|
489
|
+
const env = options.env ?? process.env;
|
|
490
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
491
|
+
const signal = options.signal;
|
|
492
|
+
throwIfAborted(signal);
|
|
493
|
+
if (typeof fetchImpl !== 'function') throw new Error('this runtime has no fetch implementation');
|
|
494
|
+
|
|
495
|
+
const headers = headersFor(env, { Accept: 'application/vnd.github+json' });
|
|
496
|
+
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
497
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
const release = await fetchJson(fetchImpl, PWSH_API_LATEST, {
|
|
501
|
+
headers,
|
|
502
|
+
signal,
|
|
503
|
+
timeoutMs: 60_000,
|
|
504
|
+
});
|
|
505
|
+
const asset = selectPwsh7Asset(release);
|
|
506
|
+
if (!asset) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`latest PowerShell release ${release?.tag_name ?? '?'} has no PowerShell-*-win-x64.zip asset`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return asset;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error?.name === 'AbortError' || signal?.aborted) throw ensureAbortError(error, signal);
|
|
514
|
+
try {
|
|
515
|
+
const tag = await resolveLatestPwsh7TagViaRedirect({ fetchImpl, env, signal });
|
|
516
|
+
return buildPwsh7AssetFromTag(tag);
|
|
517
|
+
} catch (fallbackError) {
|
|
518
|
+
if (fallbackError?.name === 'AbortError') throw fallbackError;
|
|
519
|
+
throw new Error(
|
|
520
|
+
`could not resolve the latest PowerShell 7 release (${error?.message ?? error}). ` +
|
|
521
|
+
`Download the win-x64 zip manually from ${PWSH_RELEASES_PAGE} and extract it, ` +
|
|
522
|
+
`or install with winget: ${wingetInstallManualCommand()}`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// winget fallback
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
function pwshOnly(component) {
|
|
533
|
+
if (component === undefined || component === null || component === 'pwsh') return 'pwsh';
|
|
534
|
+
throw new TypeError(
|
|
535
|
+
`this installer only supports the pwsh component (got ${JSON.stringify(component)})`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Locate winget.exe without spawning anything. */
|
|
540
|
+
export function resolveWingetPath(env = process.env) {
|
|
541
|
+
const seen = new Set();
|
|
542
|
+
const candidates = [];
|
|
543
|
+
const push = (candidate) => {
|
|
544
|
+
if (typeof candidate !== 'string' || !candidate.trim()) return;
|
|
545
|
+
const key = candidate.toLowerCase();
|
|
546
|
+
if (seen.has(key)) return;
|
|
547
|
+
seen.add(key);
|
|
548
|
+
candidates.push(candidate);
|
|
549
|
+
};
|
|
550
|
+
push(env.DSH_WINGET_PATH);
|
|
551
|
+
const pathValue = env.PATH ?? env.Path ?? env.path ?? '';
|
|
552
|
+
for (const entry of String(pathValue).split(W.delimiter)) {
|
|
553
|
+
const directory = entry.trim().replace(/^"(.*)"$/, '$1');
|
|
554
|
+
if (directory) push(W.join(directory, 'winget.exe'));
|
|
555
|
+
}
|
|
556
|
+
const appData =
|
|
557
|
+
env.LOCALAPPDATA ?? W.join(env.USERPROFILE ?? os.homedir(), 'AppData', 'Local');
|
|
558
|
+
push(W.join(appData, 'Microsoft', 'WindowsApps', 'winget.exe'));
|
|
559
|
+
return candidates.find((candidate) => fileOrSymlinkExists(candidate));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** True when winget.exe can be found for this environment. */
|
|
563
|
+
export function wingetAvailable(env = process.env) {
|
|
564
|
+
return Boolean(resolveWingetPath(env));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** winget install argument vector (PowerShell 7 only). */
|
|
568
|
+
export function wingetInstallArgs(component = 'pwsh') {
|
|
569
|
+
pwshOnly(component);
|
|
570
|
+
return [
|
|
571
|
+
'install',
|
|
572
|
+
'--id',
|
|
573
|
+
WINGET_PWSH_ID,
|
|
574
|
+
'-e',
|
|
575
|
+
'--silent',
|
|
576
|
+
'--accept-package-agreements',
|
|
577
|
+
'--accept-source-agreements',
|
|
578
|
+
'--disable-interactivity',
|
|
579
|
+
];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Exact command a user can run manually (elevated) when per-user installation is impossible. */
|
|
583
|
+
export function wingetInstallManualCommand(component = 'pwsh') {
|
|
584
|
+
pwshOnly(component);
|
|
585
|
+
return `winget install --id ${WINGET_PWSH_ID} -e --silent --accept-package-agreements --accept-source-agreements`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Heuristic: winget output that means the install needs administrator elevation. */
|
|
589
|
+
export function looksLikeElevationRequired(text) {
|
|
590
|
+
const value = String(text ?? '');
|
|
591
|
+
if (!value) return false;
|
|
592
|
+
return (
|
|
593
|
+
/requires?\s+(administrator|elevation|admin)/i.test(value) ||
|
|
594
|
+
/requested\s+operation\s+requires\s+elevation/i.test(value) ||
|
|
595
|
+
/0x800704c7/i.test(value) ||
|
|
596
|
+
/需要提升|需要管理员|管理员权限/.test(value) ||
|
|
597
|
+
/uac|user account control/i.test(value)
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function manualInstallInstructions(component = 'pwsh') {
|
|
602
|
+
pwshOnly(component);
|
|
603
|
+
return (
|
|
604
|
+
`Install PowerShell 7 manually: download the win-x64 zip from ${PWSH_RELEASES_PAGE}, ` +
|
|
605
|
+
`or run from an elevated terminal: ${wingetInstallManualCommand()}`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function installViaWinget(options = {}) {
|
|
610
|
+
const env = options.env ?? process.env;
|
|
611
|
+
const signal = options.signal;
|
|
612
|
+
throwIfAborted(signal);
|
|
613
|
+
const wingetPath = resolveWingetPath(env);
|
|
614
|
+
if (!wingetPath) {
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
kind: 'unavailable',
|
|
618
|
+
message: `winget is not available on this system. ${manualInstallInstructions()}`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const args = wingetInstallArgs();
|
|
622
|
+
const result = await runCommand(wingetPath, args, {
|
|
623
|
+
env,
|
|
624
|
+
signal,
|
|
625
|
+
timeoutMs: options.timeoutMs ?? WINGET_TIMEOUT_MS,
|
|
626
|
+
});
|
|
627
|
+
if (signal?.aborted || result.error?.name === 'AbortError') {
|
|
628
|
+
throw abortError(signal);
|
|
629
|
+
}
|
|
630
|
+
const combined = [result.stdout, result.stderr, result.error?.message]
|
|
631
|
+
.filter((part) => typeof part === 'string' && part.trim())
|
|
632
|
+
.join('\n')
|
|
633
|
+
.trim();
|
|
634
|
+
if (result.ok) return { ok: true, kind: 'installed', output: combined, command: wingetPath };
|
|
635
|
+
if (looksLikeElevationRequired(combined)) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
kind: 'elevation',
|
|
639
|
+
message:
|
|
640
|
+
`automatic installation would require administrator elevation, so nothing was changed. ` +
|
|
641
|
+
`Open an elevated terminal (PowerShell 7 or cmd) and run:\n ${wingetInstallManualCommand()}\n` +
|
|
642
|
+
`Then reopen the app or rerun the install.`,
|
|
643
|
+
output: combined,
|
|
644
|
+
command: wingetPath,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
ok: false,
|
|
649
|
+
kind: 'failed',
|
|
650
|
+
message:
|
|
651
|
+
`winget install failed (exit ${result.code}): ${tail(combined) || 'no output'}. ` +
|
|
652
|
+
manualInstallInstructions(),
|
|
653
|
+
output: combined,
|
|
654
|
+
command: wingetPath,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// PowerShell 7 installer
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
function baseResult(method, startedAt) {
|
|
663
|
+
return { ok: false, component: 'pwsh', method, durationMs: Date.now() - startedAt };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Install PowerShell 7 per-user.
|
|
668
|
+
* opts: { env, root, method = 'auto'|'portable'|'winget', onProgress, signal, fetchImpl,
|
|
669
|
+
* tarPath, extractImpl, timeouts, wingetTimeoutMs, force, version, url, probePwsh }
|
|
670
|
+
* -> Result { ok, component: 'pwsh', method, path, version, message, url?, bytes?, durationMs }
|
|
671
|
+
*/
|
|
672
|
+
export async function installPowerShell7(options = {}) {
|
|
673
|
+
const startedAt = Date.now();
|
|
674
|
+
const env = options.env ?? process.env;
|
|
675
|
+
const root = options.root ?? routerRoot(env);
|
|
676
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
677
|
+
const signal = options.signal;
|
|
678
|
+
const method = options.method ?? 'auto';
|
|
679
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
680
|
+
const probeImpl = options.probePwsh ?? probePwsh;
|
|
681
|
+
const extractImpl = options.extractImpl ?? extractArchiveWithLayout;
|
|
682
|
+
const tarPath = options.tarPath ?? resolveTarPath(env);
|
|
683
|
+
|
|
684
|
+
const fail = (message, extra = {}) => ({
|
|
685
|
+
...baseResult(method, startedAt),
|
|
686
|
+
message,
|
|
687
|
+
...extra,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
throwIfAborted(signal);
|
|
691
|
+
|
|
692
|
+
try {
|
|
693
|
+
// 1. Reuse a working installation and only write config.
|
|
694
|
+
if (!options.force) {
|
|
695
|
+
const existing = findPwsh({ env });
|
|
696
|
+
if (existing) {
|
|
697
|
+
const probe = await probeImpl(existing.path, { env, signal });
|
|
698
|
+
if (probe.ok) {
|
|
699
|
+
persistPwshConfig(env, { path: existing.path, version: probe.version, method: 'existing' });
|
|
700
|
+
return {
|
|
701
|
+
ok: true,
|
|
702
|
+
component: 'pwsh',
|
|
703
|
+
method: 'existing',
|
|
704
|
+
path: existing.path,
|
|
705
|
+
version: probe.version,
|
|
706
|
+
message: `using existing PowerShell ${probe.version ?? '(version unknown)'} at ${existing.path}`,
|
|
707
|
+
durationMs: Date.now() - startedAt,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// 2. winget-only strategy.
|
|
714
|
+
if (method === 'winget') {
|
|
715
|
+
const winget = await installViaWinget({
|
|
716
|
+
env,
|
|
717
|
+
signal,
|
|
718
|
+
timeoutMs: options.wingetTimeoutMs,
|
|
719
|
+
});
|
|
720
|
+
if (!winget.ok) {
|
|
721
|
+
return fail(winget.message, { method: 'winget', output: winget.output });
|
|
722
|
+
}
|
|
723
|
+
const located = findPwsh({ env });
|
|
724
|
+
const probe = located ? await probeImpl(located.path, { env, signal }) : { ok: false };
|
|
725
|
+
if (located && probe.ok) {
|
|
726
|
+
persistPwshConfig(env, { path: located.path, version: probe.version, method: 'winget' });
|
|
727
|
+
return {
|
|
728
|
+
ok: true,
|
|
729
|
+
component: 'pwsh',
|
|
730
|
+
method: 'winget',
|
|
731
|
+
path: located.path,
|
|
732
|
+
version: probe.version,
|
|
733
|
+
message: `installed PowerShell ${probe.version} via winget at ${located.path}`,
|
|
734
|
+
durationMs: Date.now() - startedAt,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
return fail(
|
|
738
|
+
`winget reported success but no PowerShell 7 executable could be verified. ` +
|
|
739
|
+
`Open a new terminal so PATH refreshes, then rerun the install or status. ${
|
|
740
|
+
winget.output ? `winget output: ${tail(winget.output)}` : ''
|
|
741
|
+
}`,
|
|
742
|
+
{ method: 'winget', output: winget.output },
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 3. Portable zip first for 'portable' and 'auto'.
|
|
747
|
+
let portableError;
|
|
748
|
+
let portableUrl = options.url;
|
|
749
|
+
try {
|
|
750
|
+
const installed = await installPowerShell7Portable({
|
|
751
|
+
env,
|
|
752
|
+
root,
|
|
753
|
+
method,
|
|
754
|
+
onProgress,
|
|
755
|
+
signal,
|
|
756
|
+
fetchImpl,
|
|
757
|
+
tarPath,
|
|
758
|
+
probeImpl,
|
|
759
|
+
extractImpl,
|
|
760
|
+
startedAt,
|
|
761
|
+
force: options.force,
|
|
762
|
+
version: options.version,
|
|
763
|
+
url: options.url,
|
|
764
|
+
});
|
|
765
|
+
return installed;
|
|
766
|
+
} catch (error) {
|
|
767
|
+
if (error?.name === 'AbortError' || signal?.aborted) throw ensureAbortError(error, signal);
|
|
768
|
+
portableError = error;
|
|
769
|
+
if (error?.url) portableUrl = error.url;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (method === 'portable') {
|
|
773
|
+
return fail(`portable PowerShell 7 install failed: ${portableError?.message ?? portableError}`, {
|
|
774
|
+
url: portableUrl ?? PWSH_RELEASES_PAGE,
|
|
775
|
+
hint: manualInstallInstructions(),
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// 4. 'auto' falls back to winget.
|
|
780
|
+
const winget = await installViaWinget({
|
|
781
|
+
env,
|
|
782
|
+
signal,
|
|
783
|
+
timeoutMs: options.wingetTimeoutMs,
|
|
784
|
+
});
|
|
785
|
+
if (!winget.ok) {
|
|
786
|
+
return fail(
|
|
787
|
+
`portable install failed (${portableError?.message ?? portableError}); ` +
|
|
788
|
+
`winget fallback also failed: ${winget.message}`,
|
|
789
|
+
{ method: winget.kind === 'unavailable' ? 'portable' : 'winget', output: winget.output },
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
const located = findPwsh({ env });
|
|
793
|
+
const probe = located ? await probeImpl(located.path, { env, signal }) : { ok: false };
|
|
794
|
+
if (located && probe.ok) {
|
|
795
|
+
persistPwshConfig(env, { path: located.path, version: probe.version, method: 'winget' });
|
|
796
|
+
return {
|
|
797
|
+
ok: true,
|
|
798
|
+
component: 'pwsh',
|
|
799
|
+
method: 'winget',
|
|
800
|
+
path: located.path,
|
|
801
|
+
version: probe.version,
|
|
802
|
+
message:
|
|
803
|
+
`portable install failed (${portableError?.message ?? portableError}); ` +
|
|
804
|
+
`installed PowerShell ${probe.version} via winget at ${located.path}`,
|
|
805
|
+
durationMs: Date.now() - startedAt,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
return fail(
|
|
809
|
+
`portable install failed (${portableError?.message ?? portableError}) and the winget fallback ` +
|
|
810
|
+
`could not verify a PowerShell 7 executable. Open a new terminal and rerun, or install manually. ` +
|
|
811
|
+
manualInstallInstructions(),
|
|
812
|
+
{ method: 'winget', output: winget.output },
|
|
813
|
+
);
|
|
814
|
+
} catch (error) {
|
|
815
|
+
if (error?.name === 'AbortError' || signal?.aborted) throw ensureAbortError(error, signal);
|
|
816
|
+
return fail(error?.message ?? String(error), { hint: manualInstallInstructions() });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function installPowerShell7Portable(context) {
|
|
821
|
+
const {
|
|
822
|
+
env,
|
|
823
|
+
root,
|
|
824
|
+
onProgress,
|
|
825
|
+
signal,
|
|
826
|
+
fetchImpl,
|
|
827
|
+
tarPath,
|
|
828
|
+
probeImpl,
|
|
829
|
+
extractImpl,
|
|
830
|
+
startedAt,
|
|
831
|
+
force,
|
|
832
|
+
version,
|
|
833
|
+
url,
|
|
834
|
+
} = context;
|
|
835
|
+
|
|
836
|
+
let release;
|
|
837
|
+
if (url) {
|
|
838
|
+
const name = W.basename(new URL(url).pathname);
|
|
839
|
+
const parsed = parsePwsh7ZipName(name);
|
|
840
|
+
release = { url, name, version: version ?? parsed?.version ?? 'custom' };
|
|
841
|
+
} else {
|
|
842
|
+
release = await resolvePwsh7Release({ env, fetchImpl, signal });
|
|
843
|
+
}
|
|
844
|
+
if (!release?.version || release.version === 'custom') {
|
|
845
|
+
throw new Error(`could not determine the PowerShell 7 version to install from ${release?.url}`);
|
|
846
|
+
}
|
|
847
|
+
throwIfAborted(signal);
|
|
848
|
+
|
|
849
|
+
const targetRoot = W.join(root, 'powershell7', release.version);
|
|
850
|
+
const pwshExe = W.join(targetRoot, 'pwsh.exe');
|
|
851
|
+
const tmpDir = W.join(root, 'tmp');
|
|
852
|
+
const archiveName = release.name ?? `PowerShell-${release.version}-win-x64.zip`;
|
|
853
|
+
const archivePath = W.join(tmpDir, archiveName);
|
|
854
|
+
let bytes;
|
|
855
|
+
let downloaded = false;
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
if (force || !fileOrSymlinkExists(pwshExe)) {
|
|
859
|
+
const download = await downloadFile(release.url, archivePath, {
|
|
860
|
+
onProgress,
|
|
861
|
+
signal,
|
|
862
|
+
fetchImpl,
|
|
863
|
+
headers: headersFor(env),
|
|
864
|
+
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
|
865
|
+
minBytes: PWSH7_MIN_DOWNLOAD_BYTES,
|
|
866
|
+
});
|
|
867
|
+
bytes = download.bytes;
|
|
868
|
+
downloaded = true;
|
|
869
|
+
verifyArchiveMagic(archivePath, 'zip');
|
|
870
|
+
fs.mkdirSync(targetRoot, { recursive: true });
|
|
871
|
+
await extractImpl(tarPath, archivePath, targetRoot, {
|
|
872
|
+
env,
|
|
873
|
+
signal,
|
|
874
|
+
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
} catch (error) {
|
|
878
|
+
const wrapped = ensureAbortError(error, signal);
|
|
879
|
+
if (wrapped?.name !== 'AbortError') wrapped.url = release.url;
|
|
880
|
+
throw wrapped;
|
|
881
|
+
} finally {
|
|
882
|
+
if (downloaded) removeQuietly(archivePath);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (!fileOrSymlinkExists(pwshExe)) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`pwsh.exe was not found after extracting ${archiveName} into ${targetRoot}; ` +
|
|
888
|
+
`the archive layout may have changed`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
const probe = await probeImpl(pwshExe, { env, signal });
|
|
892
|
+
if (!probe.ok) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`extracted pwsh.exe at ${pwshExe} failed verification: ${
|
|
895
|
+
tail(probe.output) || 'no version output'
|
|
896
|
+
}`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
const installedVersion = probe.version ?? release.version;
|
|
900
|
+
persistPwshConfig(env, { path: pwshExe, version: installedVersion, method: 'portable' });
|
|
901
|
+
return {
|
|
902
|
+
ok: true,
|
|
903
|
+
component: 'pwsh',
|
|
904
|
+
method: 'portable',
|
|
905
|
+
path: pwshExe,
|
|
906
|
+
version: installedVersion,
|
|
907
|
+
url: release.url,
|
|
908
|
+
bytes,
|
|
909
|
+
message: `installed PowerShell ${installedVersion} at ${pwshExe}`,
|
|
910
|
+
durationMs: Date.now() - startedAt,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// Orchestrator
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Ensure PowerShell 7 is available, per-user.
|
|
920
|
+
* -> { ok, result, notes, aborted }
|
|
921
|
+
* result is always present: a Result { ok, component: 'pwsh', method, path?, version?,
|
|
922
|
+
* message, url?, bytes?, durationMs }. `aborted` is true when the signal cancelled the work.
|
|
923
|
+
*/
|
|
924
|
+
export async function ensurePwsh(options = {}) {
|
|
925
|
+
const {
|
|
926
|
+
env = process.env,
|
|
927
|
+
root = routerRoot(env),
|
|
928
|
+
method = 'auto',
|
|
929
|
+
onProgress = () => {},
|
|
930
|
+
signal,
|
|
931
|
+
...rest
|
|
932
|
+
} = options;
|
|
933
|
+
if (!['auto', 'portable', 'winget'].includes(method)) {
|
|
934
|
+
throw new TypeError(`ensurePwsh: unsupported method ${JSON.stringify(method)}`);
|
|
935
|
+
}
|
|
936
|
+
const notes = [];
|
|
937
|
+
if (signal?.aborted) {
|
|
938
|
+
const result = {
|
|
939
|
+
ok: false,
|
|
940
|
+
component: 'pwsh',
|
|
941
|
+
method,
|
|
942
|
+
message: 'aborted before installation started',
|
|
943
|
+
durationMs: 0,
|
|
944
|
+
};
|
|
945
|
+
return { ok: false, aborted: true, result, notes: ['pwsh: aborted'] };
|
|
946
|
+
}
|
|
947
|
+
notes.push(`pwsh: installing (method: ${method})`);
|
|
948
|
+
let result;
|
|
949
|
+
try {
|
|
950
|
+
// `...rest` forwards test seams (fetchImpl, extractImpl, probePwsh, ...) to the installer.
|
|
951
|
+
result = await installPowerShell7({ env, root, method, onProgress, signal, ...rest });
|
|
952
|
+
} catch (error) {
|
|
953
|
+
if (error?.name === 'AbortError' || signal?.aborted) {
|
|
954
|
+
result = {
|
|
955
|
+
ok: false,
|
|
956
|
+
component: 'pwsh',
|
|
957
|
+
method,
|
|
958
|
+
message: 'aborted',
|
|
959
|
+
durationMs: 0,
|
|
960
|
+
};
|
|
961
|
+
notes.push('pwsh: aborted');
|
|
962
|
+
return { ok: false, aborted: true, result, notes };
|
|
963
|
+
}
|
|
964
|
+
result = {
|
|
965
|
+
ok: false,
|
|
966
|
+
component: 'pwsh',
|
|
967
|
+
method,
|
|
968
|
+
message: error?.message ?? String(error),
|
|
969
|
+
durationMs: 0,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
notes.push(
|
|
973
|
+
result.ok
|
|
974
|
+
? `pwsh: ok (${result.method}${result.version ? `, ${result.version}` : ''})`
|
|
975
|
+
: `pwsh: failed — ${result.message}`,
|
|
976
|
+
);
|
|
977
|
+
return { ok: result.ok === true, result, notes, aborted: false };
|
|
978
|
+
}
|