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.
@@ -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
+ }