dsh-gitbash-shell 0.29.2 → 0.30.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 +101 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +606 -0
- package/src/index.js +88 -0
- package/src/tool-runner.js +337 -0
- package/src/toolchain.js +418 -0
package/src/toolchain.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell — winget toolchain catalog (v0.30.0).
|
|
3
|
+
*
|
|
4
|
+
* The settings card can install and upgrade a FIXED catalog of terminal tools
|
|
5
|
+
* through winget. Four properties are load-bearing and smoke-enforced:
|
|
6
|
+
*
|
|
7
|
+
* 1. THE SOURCE IS PINNED. Every argv carries `--source winget`, so a package
|
|
8
|
+
* can only come from Microsoft's official community repository
|
|
9
|
+
* (https://cdn.winget.microsoft.com/cache; manifests live in
|
|
10
|
+
* github.com/microsoft/winget-pkgs and are read into this file's
|
|
11
|
+
* `publisher` / `source` columns by hand). The Microsoft Store source is
|
|
12
|
+
* never consulted and a user-added third-party source can never be
|
|
13
|
+
* substituted for a catalog id.
|
|
14
|
+
* 2. THE ID IS EXACT. Every argv carries `--exact`, so a catalog id resolves
|
|
15
|
+
* to that one manifest or to nothing — never to a fuzzy lookalike.
|
|
16
|
+
* 3. THE CATALOG IS CLOSED. There is no free-text package input anywhere in
|
|
17
|
+
* the card: the only ids that can reach winget are the constants below.
|
|
18
|
+
* winget additionally verifies the SHA256 recorded in each manifest
|
|
19
|
+
* (all 22 entries carry one).
|
|
20
|
+
* 4. PROVENANCE IS SHOWN, NOT ASSERTED. Each entry carries the manifest's
|
|
21
|
+
* Publisher field and the installer URL's host, and the card renders them,
|
|
22
|
+
* so the user can see where a package comes from instead of trusting a
|
|
23
|
+
* claim.
|
|
24
|
+
*
|
|
25
|
+
* `admin: true` marks the two entries whose manifests say `Scope: machine`
|
|
26
|
+
* (winget must elevate, so Windows shows a UAC prompt the user has to accept —
|
|
27
|
+
* the install blocks until they do). The card pre-checks only non-admin
|
|
28
|
+
* entries and labels these two explicitly.
|
|
29
|
+
*
|
|
30
|
+
* Everything here is pure, and every filesystem/process fact arrives through
|
|
31
|
+
* `io`, so the whole module is unit-testable off-Windows.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The ONLY source any argv may name. */
|
|
35
|
+
export const WINGET_SOURCE = 'winget'
|
|
36
|
+
|
|
37
|
+
/** Shown to the user next to the source column. */
|
|
38
|
+
export const WINGET_SOURCE_NOTE = 'winget 官方源 · 包定义在 microsoft/winget-pkgs(微软审核) · 安装时校验 SHA256'
|
|
39
|
+
|
|
40
|
+
/** Arguments every winget call this plugin makes carries, in a fixed order. */
|
|
41
|
+
export const WINGET_COMMON_ARGS = [
|
|
42
|
+
'--exact',
|
|
43
|
+
'--source', WINGET_SOURCE,
|
|
44
|
+
'--accept-package-agreements',
|
|
45
|
+
'--accept-source-agreements',
|
|
46
|
+
'--disable-interactivity',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Card sections, in render order. */
|
|
50
|
+
export const TOOL_GROUPS = ['base', 'cli', 'managed', 'admin']
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The closed catalog. `cmd` is the command the tool installs (the portable
|
|
54
|
+
* alias winget creates in %LOCALAPPDATA%\Microsoft\WinGet\Links, which is
|
|
55
|
+
* already on PATH on a machine that has ever used winget), and is what the
|
|
56
|
+
* PATH scan looks for. `publisher` and `source` are transcribed from each
|
|
57
|
+
* package's manifest in microsoft/winget-pkgs.
|
|
58
|
+
*/
|
|
59
|
+
export const TOOLCHAIN = [
|
|
60
|
+
// ── base: commands Git for Windows does not ship ──────────────────────────
|
|
61
|
+
{ id: 'make', cmd: 'make', ids: ['ezwinports.make'], group: 'base', admin: false,
|
|
62
|
+
publisher: 'ezwinports', source: 'downloads.sourceforge.net/project/ezwinports',
|
|
63
|
+
note: 'Eli Zaretskii 维护的 GNU Make 官方 Windows 构建(33 位,64 位系统上正常运行)' },
|
|
64
|
+
{ id: 'wget', cmd: 'wget', ids: ['JernejSimoncic.Wget'], group: 'base', admin: false,
|
|
65
|
+
publisher: 'Jernej Simoncic', source: 'eternallybored.org',
|
|
66
|
+
note: 'GNU wget 官方认可的 Windows 构建站(GNU 官网链接至此)' },
|
|
67
|
+
{ id: 'yq', cmd: 'yq', ids: ['MikeFarah.yq'], group: 'base', admin: false,
|
|
68
|
+
publisher: 'Mike Farah', source: 'github.com/mikefarah/yq' },
|
|
69
|
+
{ id: 'xtree', cmd: 'xtree', ids: ['Excelano.xtree'], group: 'base', admin: false,
|
|
70
|
+
publisher: 'Excelano LLC', source: 'github.com/excelano/xfiles',
|
|
71
|
+
note: '现代 tree 替代(命令名是 xtree);经典 tree 需要管理员,见下' },
|
|
72
|
+
|
|
73
|
+
// ── cli: modern command-line tools Git Bash does not ship ─────────────────
|
|
74
|
+
{ id: 'bat', cmd: 'bat', ids: ['sharkdp.bat'], group: 'cli', admin: false,
|
|
75
|
+
publisher: 'David Peter', source: 'github.com/sharkdp/bat' },
|
|
76
|
+
{ id: 'fzf', cmd: 'fzf', ids: ['junegunn.fzf'], group: 'cli', admin: false,
|
|
77
|
+
publisher: 'Junegunn Choi', source: 'github.com/junegunn/fzf' },
|
|
78
|
+
{ id: 'delta', cmd: 'delta', ids: ['dandavison.delta'], group: 'cli', admin: false,
|
|
79
|
+
publisher: 'Dan Davison', source: 'github.com/dandavison/delta' },
|
|
80
|
+
{ id: 'zoxide', cmd: 'zoxide', ids: ['ajeetdsouza.zoxide'], group: 'cli', admin: false,
|
|
81
|
+
publisher: "Ajeet D'Souza", source: 'github.com/ajeetdsouza/zoxide' },
|
|
82
|
+
{ id: 'dust', cmd: 'dust', ids: ['bootandy.dust'], group: 'cli', admin: false,
|
|
83
|
+
publisher: 'andy.boot', source: 'github.com/bootandy/dust' },
|
|
84
|
+
{ id: 'procs', cmd: 'procs', ids: ['dalance.procs'], group: 'cli', admin: false,
|
|
85
|
+
publisher: 'dalance', source: 'github.com/dalance/procs' },
|
|
86
|
+
{ id: 'xh', cmd: 'xh', ids: ['ducaale.xh'], group: 'cli', admin: false,
|
|
87
|
+
publisher: 'ducaale', source: 'github.com/ducaale/xh' },
|
|
88
|
+
{ id: 'just', cmd: 'just', ids: ['Casey.Just'], group: 'cli', admin: false,
|
|
89
|
+
publisher: 'Casey Rodarmor', source: 'github.com/casey/just' },
|
|
90
|
+
{ id: 'shellcheck', cmd: 'shellcheck', ids: ['koalaman.shellcheck'], group: 'cli', admin: false,
|
|
91
|
+
publisher: 'Vidar Holen', source: 'github.com/koalaman/shellcheck' },
|
|
92
|
+
{ id: 'hexyl', cmd: 'hexyl', ids: ['sharkdp.hexyl'], group: 'cli', admin: false,
|
|
93
|
+
publisher: 'David Peter', source: 'github.com/sharkdp/hexyl' },
|
|
94
|
+
{ id: 'tldr', cmd: 'tldr', ids: ['dbrgn.tealdeer'], group: 'cli', admin: false,
|
|
95
|
+
publisher: 'Danilo Bargen', source: 'github.com/tealdeer-rs/tealdeer' },
|
|
96
|
+
{ id: 'jj', cmd: 'jj', ids: ['jj-vcs.jj'], group: 'cli', admin: false,
|
|
97
|
+
publisher: 'jj-vcs', source: 'github.com/jj-vcs/jj' },
|
|
98
|
+
{ id: 'gdu', cmd: 'gdu', ids: ['dundee.gdu'], group: 'cli', admin: false,
|
|
99
|
+
publisher: 'Daniel Milde', source: 'github.com/dundee/gdu' },
|
|
100
|
+
|
|
101
|
+
// ── managed: already present on many machines; here for version control ──
|
|
102
|
+
{ id: 'jq', cmd: 'jq', ids: ['jqlang.jq'], group: 'managed', admin: false,
|
|
103
|
+
publisher: 'jqlang', source: 'github.com/jqlang/jq' },
|
|
104
|
+
// MEASURED: this machine has BurntSushi.ripgrep.GNU installed (the link
|
|
105
|
+
// target under WinGet/Packages names it), while the MSVC build is a SEPARATE
|
|
106
|
+
// package id. Both count as "ripgrep is already here", or the card would
|
|
107
|
+
// offer to install a second copy beside the existing one. The first id is
|
|
108
|
+
// the one a fresh install uses.
|
|
109
|
+
{ id: 'rg', cmd: 'rg', ids: ['BurntSushi.ripgrep.MSVC', 'BurntSushi.ripgrep.GNU'], group: 'managed', admin: false,
|
|
110
|
+
publisher: 'BurntSushi', source: 'github.com/BurntSushi/ripgrep' },
|
|
111
|
+
{ id: 'fd', cmd: 'fd', ids: ['sharkdp.fd'], group: 'managed', admin: false,
|
|
112
|
+
publisher: 'David Peter', source: 'github.com/sharkdp/fd' },
|
|
113
|
+
|
|
114
|
+
// ── admin: Scope: machine in the manifest ⇒ winget elevates ⇒ UAC ────────
|
|
115
|
+
{ id: '7zip', cmd: '7z', ids: ['7zip.7zip'], group: 'admin', admin: true,
|
|
116
|
+
publisher: 'Igor Pavlov', source: 'www.7-zip.org',
|
|
117
|
+
note: 'Igor Pavlov 官方站;安装器要求管理员,会弹 UAC' },
|
|
118
|
+
{ id: 'tree', cmd: 'tree', ids: ['GnuWin32.Tree'], group: 'admin', admin: true,
|
|
119
|
+
publisher: 'GnuWin32', source: 'sourceforge.net/projects/gnuwin32',
|
|
120
|
+
note: '经典 tree 命令;GnuWin32 项目 1.5.2.2(2003 年版),安装器要求管理员' },
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/** One catalog entry by id, or undefined. */
|
|
124
|
+
export function toolById(id) {
|
|
125
|
+
return TOOLCHAIN.find((tool) => tool.id === id)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every entry whose group is in `groups` (unknown groups ignored). */
|
|
129
|
+
export function toolsInGroups(groups) {
|
|
130
|
+
const wanted = new Set(Array.isArray(groups) ? groups : [])
|
|
131
|
+
return TOOLCHAIN.filter((tool) => wanted.has(tool.group))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `winget install` argv for one catalog entry (argv array — never a shell
|
|
136
|
+
* string, and the id comes from the closed catalog, never from the request).
|
|
137
|
+
* A fresh install always uses the entry's FIRST id; the extra ids exist only to
|
|
138
|
+
* RECOGNIZE an existing install of a sibling build (see `rg`).
|
|
139
|
+
* @param {{ids: string[]}} tool - catalog entry.
|
|
140
|
+
* @returns {string[]}
|
|
141
|
+
*/
|
|
142
|
+
export function installArgv(tool) {
|
|
143
|
+
return ['install', '--id', tool.ids[0], ...WINGET_COMMON_ARGS, '--silent']
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `winget upgrade` argv for one catalog entry. `installedId` is the id the
|
|
148
|
+
* machine ACTUALLY has (from `winget export`), so upgrading a GNU-build ripgrep
|
|
149
|
+
* upgrades that package rather than installing the MSVC one beside it.
|
|
150
|
+
* Upgrading a current package is a no-op winget reports with its own exit code.
|
|
151
|
+
* @param {{ids: string[]}} tool - catalog entry.
|
|
152
|
+
* @param {string} [installedId] - the id winget reported as installed.
|
|
153
|
+
* @returns {string[]}
|
|
154
|
+
*/
|
|
155
|
+
export function upgradeArgv(tool, installedId) {
|
|
156
|
+
const id = typeof installedId === 'string' && installedId !== '' ? installedId : tool.ids[0]
|
|
157
|
+
return ['upgrade', '--id', id, ...WINGET_COMMON_ARGS, '--silent']
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `winget list --upgrade-available` argv (one call covers the whole catalog). */
|
|
161
|
+
export function listUpgradableArgv() {
|
|
162
|
+
return ['list', '--upgrade-available', '--source', WINGET_SOURCE, '--accept-source-agreements', '--disable-interactivity']
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `winget export` argv. THE authoritative inventory: it is JSON (immune to the
|
|
167
|
+
* localized, column-truncating tables `list` prints), it carries the exact
|
|
168
|
+
* package id the machine has, and `--include-versions` adds the installed
|
|
169
|
+
* version. `outputPath` must be a WINDOWS path — winget is a native binary and
|
|
170
|
+
* cannot open an MSYS `/tmp/...` spelling (measured).
|
|
171
|
+
* @param {string} outputPath - absolute Windows path for the JSON file.
|
|
172
|
+
* @returns {string[]}
|
|
173
|
+
*/
|
|
174
|
+
export function exportArgv(outputPath) {
|
|
175
|
+
return ['export', '-o', outputPath, '--include-versions', '--source', WINGET_SOURCE, '--accept-source-agreements', '--disable-interactivity']
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `winget --version` argv — the availability probe. */
|
|
179
|
+
export function wingetVersionArgv() {
|
|
180
|
+
return ['--version']
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read winget's own inventory JSON. Only the `winget` source is considered, so
|
|
185
|
+
* a package the user installed from the Microsoft Store can never make this
|
|
186
|
+
* plugin believe the community-repo package is present.
|
|
187
|
+
* @param {string} text - the file `winget export` wrote.
|
|
188
|
+
* @param {Array} [tools] - catalog entries (defaults to all).
|
|
189
|
+
* @returns {Map<string, { pkgId: string, version: string }>} catalog id → what winget has.
|
|
190
|
+
*/
|
|
191
|
+
export function parseExportJson(text, tools) {
|
|
192
|
+
const list = Array.isArray(tools) ? tools : TOOLCHAIN
|
|
193
|
+
const found = new Map()
|
|
194
|
+
let json
|
|
195
|
+
try { json = JSON.parse(typeof text === 'string' ? text : '') } catch { return found }
|
|
196
|
+
const sources = json && Array.isArray(json.Sources) ? json.Sources : []
|
|
197
|
+
const byId = new Map()
|
|
198
|
+
for (const source of sources) {
|
|
199
|
+
const name = source && source.SourceDetails && source.SourceDetails.Name
|
|
200
|
+
if (name !== WINGET_SOURCE) continue
|
|
201
|
+
for (const pkg of Array.isArray(source.Packages) ? source.Packages : []) {
|
|
202
|
+
const id = pkg && typeof pkg.PackageIdentifier === 'string' ? pkg.PackageIdentifier : ''
|
|
203
|
+
if (id === '') continue
|
|
204
|
+
byId.set(id.toLowerCase(), { pkgId: id, version: typeof pkg.Version === 'string' ? pkg.Version : '' })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const tool of list) {
|
|
208
|
+
for (const id of tool.ids) {
|
|
209
|
+
const hit = byId.get(id.toLowerCase())
|
|
210
|
+
if (hit) { found.set(tool.id, hit); break }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return found
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Which catalog commands the machine already has, and WHERE they came from.
|
|
218
|
+
* One `readdir` per PATH directory (not one `exists` per tool × directory ×
|
|
219
|
+
* extension), and every unreadable directory is skipped rather than fatal.
|
|
220
|
+
* First PATH hit wins, which is the same precedence the shell itself applies.
|
|
221
|
+
* @param {{pathDirs: () => string[], readdir: (dir: string) => string[]}} io
|
|
222
|
+
* @param {Array} tools - catalog entries to look for.
|
|
223
|
+
* @returns {Map<string, string>} cmd → the first resolving full path.
|
|
224
|
+
*/
|
|
225
|
+
export function scanCommands(io, tools) {
|
|
226
|
+
const list = Array.isArray(tools) ? tools : TOOLCHAIN
|
|
227
|
+
// "make.exe" / "make.cmd" / "make" all resolve the command `make`.
|
|
228
|
+
const wanted = new Map(list.map((tool) => [String(tool.cmd).toLowerCase(), tool.cmd]))
|
|
229
|
+
const found = new Map()
|
|
230
|
+
let dirs
|
|
231
|
+
try { dirs = io.pathDirs() } catch { return found }
|
|
232
|
+
for (const dir of Array.isArray(dirs) ? dirs : []) {
|
|
233
|
+
if (typeof dir !== 'string' || dir === '') continue
|
|
234
|
+
let entries
|
|
235
|
+
try { entries = io.readdir(dir) } catch { continue }
|
|
236
|
+
if (!Array.isArray(entries)) continue
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
if (typeof entry !== 'string') continue
|
|
239
|
+
const base = entry.replace(/\.[^./\\]+$/, '').toLowerCase()
|
|
240
|
+
const cmd = wanted.get(base)
|
|
241
|
+
if (cmd !== undefined && !found.has(cmd)) {
|
|
242
|
+
found.set(cmd, dir.replace(/[\\/]+$/, '') + '\\' + entry)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return found
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Which detected command paths winget itself owns. winget's portable installs
|
|
251
|
+
* are shimmed into `%LOCALAPPDATA%\Microsoft\WinGet\Links` (the link directory
|
|
252
|
+
* winget reports in `--info`), so a path under it is proof winget put the
|
|
253
|
+
* command there; anything else (LLVM, WinLibs, Visual Studio, a system copy)
|
|
254
|
+
* is somebody else's toolchain.
|
|
255
|
+
* @param {string} commandPath - full path of a resolving command.
|
|
256
|
+
* @returns {boolean}
|
|
257
|
+
*/
|
|
258
|
+
export function isWingetOwnedPath(commandPath) {
|
|
259
|
+
const path = String(commandPath || '').replace(/\\/g, '/').toLowerCase()
|
|
260
|
+
if (path === '') return false
|
|
261
|
+
return path.includes('/microsoft/winget/links/') || path.includes('/winget/packages/')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A short label for who provides a command, for the card's "provided by"
|
|
266
|
+
* column. Purely cosmetic — it never changes a decision.
|
|
267
|
+
* @param {string} commandPath - full path of a resolving command.
|
|
268
|
+
* @returns {string} provider label ('' when the path says nothing).
|
|
269
|
+
*/
|
|
270
|
+
export function describeProvider(commandPath) {
|
|
271
|
+
const path = String(commandPath || '').replace(/\\/g, '/')
|
|
272
|
+
if (path === '') return ''
|
|
273
|
+
const lower = path.toLowerCase()
|
|
274
|
+
if (lower.includes('/microsoft/winget/links/')) return 'winget'
|
|
275
|
+
if (lower.includes('/winlibs/')) return 'WinLibs'
|
|
276
|
+
if (/(^|\/)llvm(\/|$)/.test(lower)) return 'LLVM'
|
|
277
|
+
if (lower.includes('/visual studio/')) return 'Visual Studio'
|
|
278
|
+
if (lower.includes('/git/')) return 'Git for Windows'
|
|
279
|
+
if (lower.includes('/windowsapps/')) return 'Windows'
|
|
280
|
+
if (/^[a-z]:\/(windows|program files)/.test(lower)) return 'system'
|
|
281
|
+
// Last resort: the parent directory name, which is what the user recognizes.
|
|
282
|
+
const parts = path.split('/').filter(Boolean)
|
|
283
|
+
return parts.length >= 2 ? parts[parts.length - 2] : ''
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The four states a catalog row can be in. THE RULE THAT MATTERS: a command
|
|
288
|
+
* provided by somebody else's toolchain is `external`, which means this plugin
|
|
289
|
+
* neither installs nor upgrades it — a machine whose `make` came from WinLibs
|
|
290
|
+
* or whose LLVM ships its own copy must not end up with a second make, and an
|
|
291
|
+
* upgrade must never touch a toolchain winget does not own.
|
|
292
|
+
* @param {{ hasCommand: boolean, wingetOwned: boolean, upgradable: boolean }} facts
|
|
293
|
+
* @returns {'missing'|'external'|'managed'|'current'}
|
|
294
|
+
*/
|
|
295
|
+
export function classifyToolState(facts) {
|
|
296
|
+
const hasCommand = !!(facts && facts.hasCommand)
|
|
297
|
+
const wingetOwned = !!(facts && facts.wingetOwned)
|
|
298
|
+
const upgradable = !!(facts && facts.upgradable)
|
|
299
|
+
// winget's own inventory is checked FIRST: MEASURED, this machine has 7-Zip
|
|
300
|
+
// installed by winget while `7z` is not on PATH at all (the MSI does not add
|
|
301
|
+
// its directory). Deciding by command presence would call that "missing" and
|
|
302
|
+
// offer to install a package that is already there.
|
|
303
|
+
if (wingetOwned) return upgradable ? 'managed' : 'current'
|
|
304
|
+
if (hasCommand) return 'external'
|
|
305
|
+
return 'missing'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The rows a "check all" click selects: only what is genuinely absent. */
|
|
309
|
+
export function isActionable(state) {
|
|
310
|
+
return state === 'missing' || state === 'managed'
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Which catalog packages winget reports as having an upgrade available. The
|
|
315
|
+
* output is a LOCALIZED table whose ID column can be narrow, so this only runs
|
|
316
|
+
* for ids winget's export already proved it owns (see `parseExportJson`), where
|
|
317
|
+
* a miss is harmless. Every id variant of an entry is checked.
|
|
318
|
+
* @param {string} output - raw stdout+stderr of `winget list --upgrade-available`.
|
|
319
|
+
* @param {Array} [tools] - catalog entries (defaults to all).
|
|
320
|
+
* @returns {Set<string>} catalog ids with an available upgrade.
|
|
321
|
+
*/
|
|
322
|
+
export function parseUpgradable(output, tools) {
|
|
323
|
+
const list = Array.isArray(tools) ? tools : TOOLCHAIN
|
|
324
|
+
const text = typeof output === 'string' ? output : ''
|
|
325
|
+
const found = new Set()
|
|
326
|
+
for (const tool of list) {
|
|
327
|
+
if (tool.ids.some((id) => text.includes(id))) found.add(tool.id)
|
|
328
|
+
}
|
|
329
|
+
return found
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `winget list --id <missing> -e` exit code, MEASURED on this machine
|
|
334
|
+
* (winget 1.29.380): Node reports the full HRESULT 0x8A150014 = 2316632084,
|
|
335
|
+
* while a POSIX shell shows only its low byte (20). `isNoMatchExit` accepts
|
|
336
|
+
* both spellings so the verdict does not depend on which layer reports it.
|
|
337
|
+
*/
|
|
338
|
+
export const WINGET_NO_MATCH_EXIT = 0x8A150014
|
|
339
|
+
|
|
340
|
+
/** True when an exit code means "no installed package matches this id". */
|
|
341
|
+
export function isNoMatchExit(code) {
|
|
342
|
+
const value = Number(code)
|
|
343
|
+
if (!Number.isFinite(value)) return false
|
|
344
|
+
return value === WINGET_NO_MATCH_EXIT || (value & 0xFF) === (WINGET_NO_MATCH_EXIT & 0xFF)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `winget upgrade --id <current> -e` exit code, MEASURED in the same session
|
|
349
|
+
* (winget 1.29.380): winget reports "there is nothing newer" as a NON-ZERO exit
|
|
350
|
+
* and prints 找不到可用的升级 / "No applicable upgrade found". On a machine that
|
|
351
|
+
* is already current that is the NORMAL outcome of an upgrade round, so it must
|
|
352
|
+
* never surface as a failure — otherwise every healthy box cries wolf. Low byte
|
|
353
|
+
* is 0x2B, distinct from the no-match 0x14, so the two cannot collide.
|
|
354
|
+
*/
|
|
355
|
+
export const WINGET_NO_UPGRADE_EXIT = 0x8A15002B
|
|
356
|
+
|
|
357
|
+
/** True when an exit code means "this package is already current". */
|
|
358
|
+
export function isNoUpgradeExit(code) {
|
|
359
|
+
const value = Number(code)
|
|
360
|
+
if (!Number.isFinite(value)) return false
|
|
361
|
+
return value === WINGET_NO_UPGRADE_EXIT || (value & 0xFF) === (WINGET_NO_UPGRADE_EXIT & 0xFF)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Turn one winget run into a verdict the card can render. Kept deliberately
|
|
366
|
+
* small: a wrong "succeeded" is the only outcome that must never happen, so an
|
|
367
|
+
* unrecognized non-zero exit stays `failed` with winget's own text attached.
|
|
368
|
+
* @param {number} code - process exit code.
|
|
369
|
+
* @param {string} output - raw stdout+stderr.
|
|
370
|
+
* @returns {{ ok: boolean, kind: string, detail: string }}
|
|
371
|
+
*/
|
|
372
|
+
export function classifyWingetResult(code, output) {
|
|
373
|
+
const text = typeof output === 'string' ? output : ''
|
|
374
|
+
const lower = text.toLowerCase()
|
|
375
|
+
const detail = text.trim().split(/\r?\n/).filter(Boolean).slice(-4).join(' · ').slice(0, 600)
|
|
376
|
+
if (code === 0) return { ok: true, kind: 'ok', detail }
|
|
377
|
+
// The two MEASURED no-ops come FIRST and structurally, never by localized
|
|
378
|
+
// wording: both exits are non-zero yet mean "nothing to do" — which is
|
|
379
|
+
// exactly what a healthy, fully current machine reports.
|
|
380
|
+
if (isNoUpgradeExit(code)) return { ok: false, kind: 'up-to-date', detail }
|
|
381
|
+
if (isNoMatchExit(code)) return { ok: false, kind: 'not-found', detail }
|
|
382
|
+
// Elevation declined / impossible: the machine's UAC prompt was dismissed, or
|
|
383
|
+
// the call ran where no interactive user could accept it.
|
|
384
|
+
if (/0x80070005|access is denied|拒绝访问|elevat|管理员|uac|0x80073d02/i.test(text)) {
|
|
385
|
+
return { ok: false, kind: 'needs-admin', detail }
|
|
386
|
+
}
|
|
387
|
+
// The same verdicts again for wording without the codes (another winget build).
|
|
388
|
+
if (/no applicable upgrade|没有可用的升级|找不到可用的升级|already installed|已安装/i.test(text)) {
|
|
389
|
+
return { ok: false, kind: 'up-to-date', detail }
|
|
390
|
+
}
|
|
391
|
+
if (/no package found|找不到与输入条件匹配|未找到与输入/i.test(text)) {
|
|
392
|
+
return { ok: false, kind: 'not-found', detail }
|
|
393
|
+
}
|
|
394
|
+
if (/0x80072|0x80072ee2|timed out|超时|网络|network|failed to download|下载失败|0x8a15005e/i.test(lower)) {
|
|
395
|
+
return { ok: false, kind: 'network', detail }
|
|
396
|
+
}
|
|
397
|
+
return { ok: false, kind: 'failed', detail }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether winget itself is usable on this machine: the executable has to
|
|
402
|
+
* resolve AND answer `--version` (an App Execution Alias that exists but whose
|
|
403
|
+
* package was removed answers with an error, and that must not be reported as
|
|
404
|
+
* "winget available").
|
|
405
|
+
* @param {{ run: (file: string, args: string[]) => { code: number, output: string } }} io
|
|
406
|
+
* @returns {{ ok: boolean, version: string }}
|
|
407
|
+
*/
|
|
408
|
+
export function probeWinget(io) {
|
|
409
|
+
try {
|
|
410
|
+
const result = io.run('winget', wingetVersionArgv())
|
|
411
|
+
const text = String(result && result.output ? result.output : '')
|
|
412
|
+
const match = text.match(/v?(\d+\.\d+\.\d+)/)
|
|
413
|
+
if (result && result.code === 0 && match) return { ok: true, version: match[1] }
|
|
414
|
+
return { ok: false, version: '' }
|
|
415
|
+
} catch {
|
|
416
|
+
return { ok: false, version: '' }
|
|
417
|
+
}
|
|
418
|
+
}
|