dsh-gitbash-shell 0.42.3 → 0.43.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 +55 -0
- package/dsh.plugin.json +1 -1
- package/package.json +2 -2
- package/src/client.js +183 -3
- package/src/index.js +13 -1
- package/src/tool-runner.js +204 -28
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,61 @@
|
|
|
3
3
|
> 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
|
|
4
4
|
> 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
|
|
5
5
|
|
|
6
|
+
## [0.43.1] — 2026-10-23
|
|
7
|
+
|
|
8
|
+
### fix:测试装置的 Windows 路径 bug(v0.43.0 的 CI 修复,无功能变化)
|
|
9
|
+
|
|
10
|
+
- **0.43.0 的 CI(windows-latest × Node 22/24)红了**:新 `tests/terminal-cancel.mjs` 用
|
|
11
|
+
`new URL('.', import.meta.url).pathname` 拼源码路径——Windows 上 pathname 带前导 `/`,拼出
|
|
12
|
+
`D:\D:\a\...` 双盘符 → ENOENT。macOS 的 `/Users/...` 永远暴露不了这个。**生产代码零变化**
|
|
13
|
+
(发布的 0.43.0 包不受影响:tests 不在发布面,失败仅是仓库 CI),修法是回归仓库惯例
|
|
14
|
+
`readFileSync(new URL('../src/x.js', import.meta.url), 'utf8')`(直接接受 URL,跨平台正确)。
|
|
15
|
+
- **教训入库**:同一仓库里 `align-official.mjs` / `client-regressions.mjs` /
|
|
16
|
+
`compatibility-regressions.mjs` 早就是惯例写法——新测试文件开写前应先看同目录既有文件的
|
|
17
|
+
装置惯例,而不是自造。win32 门控路径在 macOS 上不可见,这是本仓库第二次被同一类问题咬
|
|
18
|
+
(第一次:v0.39.4 smoke stub 的 ns 字面量)。
|
|
19
|
+
- 全量 334 项本机 0 fail;以 CI(ubuntu+windows × Node 22/24)全绿为发版门。
|
|
20
|
+
|
|
21
|
+
## [0.43.0] — 2026-10-23
|
|
22
|
+
|
|
23
|
+
### feat:终端工具任务的取消能力与可观测性(#35,@youyv 报告)
|
|
24
|
+
|
|
25
|
+
- **背景**:#35(高质量报告,代码事实逐行核验全部准确):winget 安装/升级任务「只进不出」——
|
|
26
|
+
下载卡住时卡片停在「处理中」,没有取消按钮、看不到已用时与 winget 的重试输出、单条目最长干等
|
|
27
|
+
15 分钟(`INSTALL_TIMEOUT_MS`,为 UAC 等待保留,不动),唯一出路是重启 DSH;`clear` 只丢
|
|
28
|
+
`activeJob` 锁不杀进程,第二个任务能与仍在跑的 winget 并发(半装状态风险)。
|
|
29
|
+
- **取消(主诉求)**:新路由 `POST ?action=cancel`(`index.js`,与 `run`/`clear` 同一 fence);
|
|
30
|
+
`cancelToolJob()` 标记 job 并经 `io.cancelActive()` **杀整个进程树**——win32 用
|
|
31
|
+
`taskkill /PID <pid> /T /F`(winget 会派发 forwarder/msiexec/DeliveryOptimization 子进程,
|
|
32
|
+
只杀父进程留孤儿),POSIX 用 SIGKILL。进行中的条目 verdict 归 `cancelled`,剩余条目归
|
|
33
|
+
`skipped`(用户要求的是停止,不是失败);任务留在「已取消」终态直到卡片关闭。取消落在
|
|
34
|
+
inventory 读取期间时全部条目 skipped(什么都没尝试,什么都不报失败);落在 winget 已正常
|
|
35
|
+
退出之后时不改写已完成条目的 verdict(只信 run 自己的 `cancelled` 标志,不信 job 级兜底)。
|
|
36
|
+
- **clear 语义修正(堵并发洞)**:任务未完成时 `clearToolJob()` 现在**等价于取消**(杀树 + 标记),
|
|
37
|
+
不再丢锁放行第二个任务;已完成任务照旧忘记。
|
|
38
|
+
- **runner 流式改造**:`defaultRunnerIo` 的 `execFile` 换 `spawn`(stdio pipe,16MiB 输出上限
|
|
39
|
+
语义保留):`hooks.onOutput` 逐行送出最新一行(300 字符截断),job 快照新增
|
|
40
|
+
`startedAt` / `currentSince` / `lastOutput` / `cancelled`(`index.js` snapshot 显式挑字段,
|
|
41
|
+
io 引用不上线路)。超时、取消、看门狗三种终止都走进程树击杀,并以
|
|
42
|
+
`timedOut` / `cancelled` / `networkRetry` 标志区分——`execFile` 时代 ETIMEDOUT 被压成
|
|
43
|
+
`code=-1` 归「一般失败」的问题一并修正。
|
|
44
|
+
- **卡死下载看门狗**:`NETWORK_RETRY_PATTERN`(`/failed to download, waiting a bit and
|
|
45
|
+
retry|0x80072ee2|0x80072efd/i`,#35 实测的 DO→WinINet 重试循环信号)首次命中起计时,一个
|
|
46
|
+
窗口(默认 120s,`defaultRunnerIo({networkRetryWindowMs})` 可注入)后进程仍在即杀树,verdict
|
|
47
|
+
归 `network` 并附最后一行——不再静默烧完 15 分钟。健康下载永远不会碰到它(窗口只从首个
|
|
48
|
+
retry 行起算,且只触发一次)。
|
|
49
|
+
- **UI**:进行中行新增**已用时**(`currentSince` 起算,`2m13s` 数字形,随 1.2s 轮询刷新)、
|
|
50
|
+
**取消按钮**与最新 winget 行(单行灰色 `gb-toolTail`);badge 按 verdict 归类——已取消 /
|
|
51
|
+
已跳过(中性 `–` + `gb-st-skip`)/ 超时 / 网络失败 与 成功/失败 区分;skipped 条目不打失败
|
|
52
|
+
明细行;任务被取消后在卡片上显示总况行。21 门词典各新增 6 键
|
|
53
|
+
(`tools.cancel` / `tools.cancelledNote` / `tools.kind.cancelled|skipped|timeout|network`)。
|
|
54
|
+
- **测试**:新 `tests/terminal-cancel.mjs` 11 项(取消状态机三条路径 / clear 等价 / timeout 与
|
|
55
|
+
networkRetry 归类 / watchdog / onOutput / spawn 失败兼容 / 路由与卡片源码断言);全量 334 项
|
|
56
|
+
333 pass + 1 平台 skip + 0 fail。win32 的进程树击杀(taskkill 路径)本机(macOS)只能验证
|
|
57
|
+
SIGKILL 分支,**Windows 真机复验待做**(卡死下载场景:装一个 GitHub 源包,断网点「取消」,
|
|
58
|
+
看树是否全灭、卡片是否显示已取消)。
|
|
59
|
+
- UAC 提示:杀树后系统 consent.exe 弹窗一般随之撤下;若残留,手动关掉即可,不影响已取消的判定。
|
|
60
|
+
|
|
6
61
|
## [0.42.3] — 2026-10-23
|
|
7
62
|
|
|
8
63
|
### fix:全仓严格 review 的十项修复(PR #34,@KouzakiUmi 贡献;16 文件 +1229/−123)
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-gitbash-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.1",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "Windows 全模式 Git Bash:自带 bash 工具(私有 Git Bash 执行器实例,官方 PowerShell 执行器原样保留),官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0 起;v0.39.0 起不再替换宿主执行器),四个 *·Git Bash 变体 preset 可开关;官方 pwsh 工具默认隐藏、开关可见(真 PowerShell);附带 gb_* FastCtx 文件工具,检测到 dsh-ops 自动让位。联动:dsh-ptc-cordis-preset(PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端标签自动切 Git Bash)。",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gitbash-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.1",
|
|
4
4
|
"description": "Windows 上给 agent 一个真正的 Git Bash 命令工具:私有 Git Bash 执行器实例(官方 PowerShell 执行器原样保留),统一 MSYS 盘根路径方言,官方 pwsh 工具默认隐藏可开关,物化四个内置预设的 Git Bash 变体,侧栏终端接管可开关。· Give agents a real Git Bash tool on Windows: a private Git Bash executor instance (the official PowerShell executor stays untouched), a unified MSYS drive-root path dialect, an opt-in official pwsh tool, and four Git Bash preset variants.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"icon.svg"
|
|
58
58
|
],
|
|
59
59
|
"scripts": {
|
|
60
|
-
"test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs tests/review-core-regressions.mjs tests/review-native-regressions.mjs",
|
|
60
|
+
"test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs tests/terminal-cancel.mjs tests/review-core-regressions.mjs tests/review-native-regressions.mjs",
|
|
61
61
|
"test:preview": "node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs tests/path-preview.mjs tests/native-output-preview.mjs tests/native-preview.mjs tests/native-integration-preview.mjs tests/review-native-preview.mjs",
|
|
62
62
|
"version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
|
|
63
63
|
},
|
package/src/client.js
CHANGED
|
@@ -132,6 +132,12 @@ window.__ModuleLoader__.load({
|
|
|
132
132
|
"tools.state.current": "已是最新",
|
|
133
133
|
"tools.adminHint": "需要管理员权限:会弹出 UAC 窗口,请在电脑上确认",
|
|
134
134
|
"tools.working": "处理中",
|
|
135
|
+
"tools.cancel": "取消",
|
|
136
|
+
"tools.cancelledNote": "已取消:完成的条目保留结果,其余已跳过。",
|
|
137
|
+
"tools.kind.cancelled": "已取消",
|
|
138
|
+
"tools.kind.skipped": "已跳过",
|
|
139
|
+
"tools.kind.timeout": "超时",
|
|
140
|
+
"tools.kind.network": "网络失败",
|
|
135
141
|
"tools.resultOk": "成功",
|
|
136
142
|
"tools.resultFail": "失败",
|
|
137
143
|
"tools.installing": "安装中…",
|
|
@@ -212,6 +218,12 @@ window.__ModuleLoader__.load({
|
|
|
212
218
|
"tools.state.current": "Up to date",
|
|
213
219
|
"tools.adminHint": "Needs administrator: a UAC prompt will appear — accept it on this machine",
|
|
214
220
|
"tools.working": "Working",
|
|
221
|
+
"tools.cancel": "取消",
|
|
222
|
+
"tools.cancelledNote": "已取消:完成的条目保留结果,其余已跳过。",
|
|
223
|
+
"tools.kind.cancelled": "已取消",
|
|
224
|
+
"tools.kind.skipped": "已跳过",
|
|
225
|
+
"tools.kind.timeout": "超时",
|
|
226
|
+
"tools.kind.network": "网络失败",
|
|
215
227
|
"tools.resultOk": "succeeded",
|
|
216
228
|
"tools.resultFail": "failed",
|
|
217
229
|
"tools.installing": "Installing…",
|
|
@@ -302,6 +314,12 @@ window.__ModuleLoader__.load({
|
|
|
302
314
|
"tools.state.current": "محدَّث",
|
|
303
315
|
"tools.adminHint": "يتطلب صلاحيات المسؤول: ستظهر نافذة UAC — أكّدها على هذا الجهاز",
|
|
304
316
|
"tools.working": "جارٍ العمل",
|
|
317
|
+
"tools.cancel": "إلغاء",
|
|
318
|
+
"tools.cancelledNote": "تم الإلغاء: تحتفظ الإدخالات المكتملة بنتائجها، وتخطّي الباقي.",
|
|
319
|
+
"tools.kind.cancelled": "أُلغي",
|
|
320
|
+
"tools.kind.skipped": "تم التخطي",
|
|
321
|
+
"tools.kind.timeout": "انتهت المهلة",
|
|
322
|
+
"tools.kind.network": "فشل الشبكة",
|
|
305
323
|
"tools.resultOk": "نجح",
|
|
306
324
|
"tools.resultFail": "فشل",
|
|
307
325
|
"tools.installing": "جارٍ التثبيت…",
|
|
@@ -382,6 +400,12 @@ window.__ModuleLoader__.load({
|
|
|
382
400
|
"tools.state.current": "Aktuell",
|
|
383
401
|
"tools.adminHint": "Erfordert Administratorrechte: Ein UAC-Fenster erscheint — bitte an diesem Rechner bestätigen",
|
|
384
402
|
"tools.working": "Läuft",
|
|
403
|
+
"tools.cancel": "Abbrechen",
|
|
404
|
+
"tools.cancelledNote": "Abgebrochen: abgeschlossene Einträge behalten ihr Ergebnis, der Rest wurde übersprungen.",
|
|
405
|
+
"tools.kind.cancelled": "Abgebrochen",
|
|
406
|
+
"tools.kind.skipped": "Übersprungen",
|
|
407
|
+
"tools.kind.timeout": "Timeout",
|
|
408
|
+
"tools.kind.network": "Netzwerkfehler",
|
|
385
409
|
"tools.resultOk": "erfolgreich",
|
|
386
410
|
"tools.resultFail": "fehlgeschlagen",
|
|
387
411
|
"tools.installing": "Wird installiert…",
|
|
@@ -462,6 +486,12 @@ window.__ModuleLoader__.load({
|
|
|
462
486
|
"tools.state.current": "À jour",
|
|
463
487
|
"tools.adminHint": "Nécessite les droits administrateur : une fenêtre UAC va s'afficher — validez-la sur cette machine",
|
|
464
488
|
"tools.working": "En cours",
|
|
489
|
+
"tools.cancel": "Annuler",
|
|
490
|
+
"tools.cancelledNote": "Annulé : les entrées terminées conservent leur résultat, les autres ont été ignorées.",
|
|
491
|
+
"tools.kind.cancelled": "Annulé",
|
|
492
|
+
"tools.kind.skipped": "Ignoré",
|
|
493
|
+
"tools.kind.timeout": "Expiré",
|
|
494
|
+
"tools.kind.network": "Échec réseau",
|
|
465
495
|
"tools.resultOk": "réussi",
|
|
466
496
|
"tools.resultFail": "échec",
|
|
467
497
|
"tools.installing": "Installation…",
|
|
@@ -542,6 +572,12 @@ window.__ModuleLoader__.load({
|
|
|
542
572
|
"tools.state.current": "अद्यतन",
|
|
543
573
|
"tools.adminHint": "व्यवस्थापक अनुमति चाहिए: एक UAC विंडो दिखेगी — इस मशीन पर पुष्टि करें",
|
|
544
574
|
"tools.working": "चल रहा है",
|
|
575
|
+
"tools.cancel": "रद्द करें",
|
|
576
|
+
"tools.cancelledNote": "रद्द: पूर्ण प्रविष्टियाँ अपना परिणाम रखती हैं; शेष छोड़ दिए गए।",
|
|
577
|
+
"tools.kind.cancelled": "रद्द",
|
|
578
|
+
"tools.kind.skipped": "छोड़ा गया",
|
|
579
|
+
"tools.kind.timeout": "समय समाप्त",
|
|
580
|
+
"tools.kind.network": "नेटवर्क विफल",
|
|
545
581
|
"tools.resultOk": "सफल",
|
|
546
582
|
"tools.resultFail": "विफल",
|
|
547
583
|
"tools.installing": "इंस्टॉल हो रहा है…",
|
|
@@ -622,6 +658,12 @@ window.__ModuleLoader__.load({
|
|
|
622
658
|
"tools.state.current": "Terbaru",
|
|
623
659
|
"tools.adminHint": "Perlu administrator: jendela UAC akan muncul — setujui di komputer ini",
|
|
624
660
|
"tools.working": "Memproses",
|
|
661
|
+
"tools.cancel": "Batalkan",
|
|
662
|
+
"tools.cancelledNote": "Dibatalkan: entri yang selesai tetap berhasil; sisanya dilewati.",
|
|
663
|
+
"tools.kind.cancelled": "Dibatalkan",
|
|
664
|
+
"tools.kind.skipped": "Dilewati",
|
|
665
|
+
"tools.kind.timeout": "Habis waktu",
|
|
666
|
+
"tools.kind.network": "Kegagalan jaringan",
|
|
625
667
|
"tools.resultOk": "berhasil",
|
|
626
668
|
"tools.resultFail": "gagal",
|
|
627
669
|
"tools.installing": "Memasang…",
|
|
@@ -702,6 +744,12 @@ window.__ModuleLoader__.load({
|
|
|
702
744
|
"tools.state.current": "Aggiornato",
|
|
703
745
|
"tools.adminHint": "Richiede privilegi di amministratore: apparirà una finestra UAC — confermala su questo computer",
|
|
704
746
|
"tools.working": "In corso",
|
|
747
|
+
"tools.cancel": "Annulla",
|
|
748
|
+
"tools.cancelledNote": "Annullato: le voci completate conservano l'esito; le altre sono state saltate.",
|
|
749
|
+
"tools.kind.cancelled": "Annullato",
|
|
750
|
+
"tools.kind.skipped": "Saltato",
|
|
751
|
+
"tools.kind.timeout": "Timeout",
|
|
752
|
+
"tools.kind.network": "Errore di rete",
|
|
705
753
|
"tools.resultOk": "riuscito",
|
|
706
754
|
"tools.resultFail": "non riuscito",
|
|
707
755
|
"tools.installing": "Installazione…",
|
|
@@ -782,6 +830,12 @@ window.__ModuleLoader__.load({
|
|
|
782
830
|
"tools.state.current": "最新",
|
|
783
831
|
"tools.adminHint": "管理者権限が必要です。UAC の画面が表示されるので、この PC で承認してください",
|
|
784
832
|
"tools.working": "処理中",
|
|
833
|
+
"tools.cancel": "キャンセル",
|
|
834
|
+
"tools.cancelledNote": "中止しました:完了した項目の結果は保持され、残りはスキップされました。",
|
|
835
|
+
"tools.kind.cancelled": "中止",
|
|
836
|
+
"tools.kind.skipped": "スキップ",
|
|
837
|
+
"tools.kind.timeout": "タイムアウト",
|
|
838
|
+
"tools.kind.network": "ネットワーク失敗",
|
|
785
839
|
"tools.resultOk": "成功",
|
|
786
840
|
"tools.resultFail": "失敗",
|
|
787
841
|
"tools.installing": "インストール中…",
|
|
@@ -862,6 +916,12 @@ window.__ModuleLoader__.load({
|
|
|
862
916
|
"tools.state.current": "최신",
|
|
863
917
|
"tools.adminHint": "관리자 권한이 필요합니다. UAC 창이 뜨면 이 PC에서 승인하세요",
|
|
864
918
|
"tools.working": "처리 중",
|
|
919
|
+
"tools.cancel": "취소",
|
|
920
|
+
"tools.cancelledNote": "취소됨: 완료된 항목의 결과는 유지되고 나머지는 건너뛰었습니다.",
|
|
921
|
+
"tools.kind.cancelled": "취소됨",
|
|
922
|
+
"tools.kind.skipped": "건너뜀",
|
|
923
|
+
"tools.kind.timeout": "시간 초과",
|
|
924
|
+
"tools.kind.network": "네트워크 실패",
|
|
865
925
|
"tools.resultOk": "성공",
|
|
866
926
|
"tools.resultFail": "실패",
|
|
867
927
|
"tools.installing": "설치 중…",
|
|
@@ -942,6 +1002,12 @@ window.__ModuleLoader__.load({
|
|
|
942
1002
|
"tools.state.current": "Bijgewerkt",
|
|
943
1003
|
"tools.adminHint": "Vereist beheerdersrechten: er verschijnt een UAC-venster — bevestig dat op deze computer",
|
|
944
1004
|
"tools.working": "Bezig",
|
|
1005
|
+
"tools.cancel": "Annuleren",
|
|
1006
|
+
"tools.cancelledNote": "Geannuleerd: voltooide items behouden hun resultaat; de rest is overgeslagen.",
|
|
1007
|
+
"tools.kind.cancelled": "Geannuleerd",
|
|
1008
|
+
"tools.kind.skipped": "Overgeslagen",
|
|
1009
|
+
"tools.kind.timeout": "Time-out",
|
|
1010
|
+
"tools.kind.network": "Netwerkfout",
|
|
945
1011
|
"tools.resultOk": "geslaagd",
|
|
946
1012
|
"tools.resultFail": "mislukt",
|
|
947
1013
|
"tools.installing": "Installeren…",
|
|
@@ -1022,6 +1088,12 @@ window.__ModuleLoader__.load({
|
|
|
1022
1088
|
"tools.state.current": "Aktualne",
|
|
1023
1089
|
"tools.adminHint": "Wymaga uprawnień administratora: pojawi się okno UAC — potwierdź je na tym komputerze",
|
|
1024
1090
|
"tools.working": "Przetwarzanie",
|
|
1091
|
+
"tools.cancel": "Anuluj",
|
|
1092
|
+
"tools.cancelledNote": "Anulowano: ukończone pozycje zachowują wynik, pozostałe pominięto.",
|
|
1093
|
+
"tools.kind.cancelled": "Anulowano",
|
|
1094
|
+
"tools.kind.skipped": "Pominięto",
|
|
1095
|
+
"tools.kind.timeout": "Przekroczono czas",
|
|
1096
|
+
"tools.kind.network": "Błąd sieci",
|
|
1025
1097
|
"tools.resultOk": "sukces",
|
|
1026
1098
|
"tools.resultFail": "niepowodzenie",
|
|
1027
1099
|
"tools.installing": "Instalowanie…",
|
|
@@ -1102,6 +1174,12 @@ window.__ModuleLoader__.load({
|
|
|
1102
1174
|
"tools.state.current": "Atualizado",
|
|
1103
1175
|
"tools.adminHint": "Requer administrador: aparecerá uma janela UAC — confirme-a neste computador",
|
|
1104
1176
|
"tools.working": "A processar",
|
|
1177
|
+
"tools.cancel": "Cancelar",
|
|
1178
|
+
"tools.cancelledNote": "Cancelado: as entradas concluídas mantêm o resultado; as restantes foram ignoradas.",
|
|
1179
|
+
"tools.kind.cancelled": "Cancelado",
|
|
1180
|
+
"tools.kind.skipped": "Ignorado",
|
|
1181
|
+
"tools.kind.timeout": "Tempo esgotado",
|
|
1182
|
+
"tools.kind.network": "Falha de rede",
|
|
1105
1183
|
"tools.resultOk": "com êxito",
|
|
1106
1184
|
"tools.resultFail": "falhou",
|
|
1107
1185
|
"tools.installing": "A instalar…",
|
|
@@ -1182,6 +1260,12 @@ window.__ModuleLoader__.load({
|
|
|
1182
1260
|
"tools.state.current": "Актуально",
|
|
1183
1261
|
"tools.adminHint": "Нужны права администратора: появится окно UAC — подтвердите его на этом компьютере",
|
|
1184
1262
|
"tools.working": "Выполняется",
|
|
1263
|
+
"tools.cancel": "Отмена",
|
|
1264
|
+
"tools.cancelledNote": "Отменено: завершённые пункты сохраняют результат, остальные пропущены.",
|
|
1265
|
+
"tools.kind.cancelled": "Отменено",
|
|
1266
|
+
"tools.kind.skipped": "Пропущено",
|
|
1267
|
+
"tools.kind.timeout": "Превышено время",
|
|
1268
|
+
"tools.kind.network": "Сбой сети",
|
|
1185
1269
|
"tools.resultOk": "успешно",
|
|
1186
1270
|
"tools.resultFail": "ошибка",
|
|
1187
1271
|
"tools.installing": "Установка…",
|
|
@@ -1262,6 +1346,12 @@ window.__ModuleLoader__.load({
|
|
|
1262
1346
|
"tools.state.current": "Senaste",
|
|
1263
1347
|
"tools.adminHint": "Kräver administratör: ett UAC-fönster visas — bekräfta det på den här datorn",
|
|
1264
1348
|
"tools.working": "Arbetar",
|
|
1349
|
+
"tools.cancel": "Avbryt",
|
|
1350
|
+
"tools.cancelledNote": "Avbruten: färdiga poster behåller sitt resultat; resten hoppades över.",
|
|
1351
|
+
"tools.kind.cancelled": "Avbruten",
|
|
1352
|
+
"tools.kind.skipped": "Hoppade över",
|
|
1353
|
+
"tools.kind.timeout": "Tidsgräns",
|
|
1354
|
+
"tools.kind.network": "Nätverksfel",
|
|
1265
1355
|
"tools.resultOk": "lyckades",
|
|
1266
1356
|
"tools.resultFail": "misslyckades",
|
|
1267
1357
|
"tools.installing": "Installerar…",
|
|
@@ -1342,6 +1432,12 @@ window.__ModuleLoader__.load({
|
|
|
1342
1432
|
"tools.state.current": "ล่าสุดแล้ว",
|
|
1343
1433
|
"tools.adminHint": "ต้องใช้สิทธิ์ผู้ดูแลระบบ จะมีหน้าต่าง UAC ขึ้นมา กรุณายืนยันบนเครื่องนี้",
|
|
1344
1434
|
"tools.working": "กำลังดำเนินการ",
|
|
1435
|
+
"tools.cancel": "ยกเลิก",
|
|
1436
|
+
"tools.cancelledNote": "ยกเลิกแล้ว: รายการที่เสร็จแล้วคงผลลัพธ์ไว้ ส่วนที่เหลือถูกข้าม",
|
|
1437
|
+
"tools.kind.cancelled": "ยกเลิกแล้ว",
|
|
1438
|
+
"tools.kind.skipped": "ข้ามไว้",
|
|
1439
|
+
"tools.kind.timeout": "หมดเวลา",
|
|
1440
|
+
"tools.kind.network": "เครือข่ายล้มเหลว",
|
|
1345
1441
|
"tools.resultOk": "สำเร็จ",
|
|
1346
1442
|
"tools.resultFail": "ล้มเหลว",
|
|
1347
1443
|
"tools.installing": "กำลังติดตั้ง…",
|
|
@@ -1422,6 +1518,12 @@ window.__ModuleLoader__.load({
|
|
|
1422
1518
|
"tools.state.current": "Güncel",
|
|
1423
1519
|
"tools.adminHint": "Yönetici izni gerekir: bir UAC penceresi açılır — bu bilgisayarda onaylayın",
|
|
1424
1520
|
"tools.working": "İşleniyor",
|
|
1521
|
+
"tools.cancel": "İptal",
|
|
1522
|
+
"tools.cancelledNote": "İptal edildi: tamamlanan girdiler sonuçlarını korur; kalanlar atlandı.",
|
|
1523
|
+
"tools.kind.cancelled": "İptal edildi",
|
|
1524
|
+
"tools.kind.skipped": "Atlandı",
|
|
1525
|
+
"tools.kind.timeout": "Zaman aşımı",
|
|
1526
|
+
"tools.kind.network": "Ağ hatası",
|
|
1425
1527
|
"tools.resultOk": "başarılı",
|
|
1426
1528
|
"tools.resultFail": "başarısız",
|
|
1427
1529
|
"tools.installing": "Kuruluyor…",
|
|
@@ -1502,6 +1604,12 @@ window.__ModuleLoader__.load({
|
|
|
1502
1604
|
"tools.state.current": "Mới nhất",
|
|
1503
1605
|
"tools.adminHint": "Cần quyền quản trị: cửa sổ UAC sẽ hiện ra — hãy xác nhận trên máy này",
|
|
1504
1606
|
"tools.working": "Đang xử lý",
|
|
1607
|
+
"tools.cancel": "Hủy bỏ",
|
|
1608
|
+
"tools.cancelledNote": "Đã hủy: các mục đã xong giữ nguyên kết quả; phần còn lại đã bỏ qua.",
|
|
1609
|
+
"tools.kind.cancelled": "Đã hủy",
|
|
1610
|
+
"tools.kind.skipped": "Đã bỏ qua",
|
|
1611
|
+
"tools.kind.timeout": "Hết thời gian",
|
|
1612
|
+
"tools.kind.network": "Lỗi mạng",
|
|
1505
1613
|
"tools.resultOk": "thành công",
|
|
1506
1614
|
"tools.resultFail": "thất bại",
|
|
1507
1615
|
"tools.installing": "Đang cài…",
|
|
@@ -1582,6 +1690,12 @@ window.__ModuleLoader__.load({
|
|
|
1582
1690
|
"tools.state.current": "已經最新",
|
|
1583
1691
|
"tools.adminHint": "需要管理員權限:會彈出 UAC 視窗,請喺電腦度確認",
|
|
1584
1692
|
"tools.working": "處理緊",
|
|
1693
|
+
"tools.cancel": "取消",
|
|
1694
|
+
"tools.cancelledNote": "已取消:完成的項目保留結果,其餘已跳過。",
|
|
1695
|
+
"tools.kind.cancelled": "已取消",
|
|
1696
|
+
"tools.kind.skipped": "已跳過",
|
|
1697
|
+
"tools.kind.timeout": "逾時",
|
|
1698
|
+
"tools.kind.network": "網絡失敗",
|
|
1585
1699
|
"tools.resultOk": "成功",
|
|
1586
1700
|
"tools.resultFail": "失敗",
|
|
1587
1701
|
"tools.installing": "安裝緊…",
|
|
@@ -1662,6 +1776,12 @@ window.__ModuleLoader__.load({
|
|
|
1662
1776
|
"tools.state.current": "已經最新",
|
|
1663
1777
|
"tools.adminHint": "需要管理員權限:會彈出 UAC 視窗,請喺電腦度確認",
|
|
1664
1778
|
"tools.working": "處理緊",
|
|
1779
|
+
"tools.cancel": "取消",
|
|
1780
|
+
"tools.cancelledNote": "已取消:完成的項目保留結果,其餘已跳過。",
|
|
1781
|
+
"tools.kind.cancelled": "已取消",
|
|
1782
|
+
"tools.kind.skipped": "已跳過",
|
|
1783
|
+
"tools.kind.timeout": "逾時",
|
|
1784
|
+
"tools.kind.network": "網絡失敗",
|
|
1665
1785
|
"tools.resultOk": "成功",
|
|
1666
1786
|
"tools.resultFail": "失敗",
|
|
1667
1787
|
"tools.installing": "安裝緊…",
|
|
@@ -1742,6 +1862,12 @@ window.__ModuleLoader__.load({
|
|
|
1742
1862
|
"tools.state.current": "已是最新",
|
|
1743
1863
|
"tools.adminHint": "需要管理員權限:會彈出 UAC 視窗,請在這台電腦上確認",
|
|
1744
1864
|
"tools.working": "處理中",
|
|
1865
|
+
"tools.cancel": "取消",
|
|
1866
|
+
"tools.cancelledNote": "已取消:完成的項目保留結果,其餘已跳過。",
|
|
1867
|
+
"tools.kind.cancelled": "已取消",
|
|
1868
|
+
"tools.kind.skipped": "已跳過",
|
|
1869
|
+
"tools.kind.timeout": "逾時",
|
|
1870
|
+
"tools.kind.network": "網路失敗",
|
|
1745
1871
|
"tools.resultOk": "成功",
|
|
1746
1872
|
"tools.resultFail": "失敗",
|
|
1747
1873
|
"tools.installing": "安裝中…",
|
|
@@ -1763,6 +1889,15 @@ window.__ModuleLoader__.load({
|
|
|
1763
1889
|
return Object.prototype.hasOwnProperty.call(bag, key);
|
|
1764
1890
|
}
|
|
1765
1891
|
|
|
1892
|
+
/* Issue #35: how long the entry in flight has been running, in the
|
|
1893
|
+
locale-neutral "2m13s" / "47s" form — a duration needs no words. */
|
|
1894
|
+
function formatElapsed(ms) {
|
|
1895
|
+
var total = Math.max(0, Math.round(ms / 1000));
|
|
1896
|
+
var m = Math.floor(total / 60);
|
|
1897
|
+
var s = total % 60;
|
|
1898
|
+
return m > 0 ? m + "m" + (s < 10 ? "0" + s : "" + s) + "s" : s + "s";
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1766
1901
|
/* Resolve one locale tag against what this plugin ships: the exact tag,
|
|
1767
1902
|
then the primary subtag, then English. Chinese is special-cased because
|
|
1768
1903
|
the traditional variants are shipped while a bare zh-Hant-* is not
|
|
@@ -1890,6 +2025,10 @@ window.__ModuleLoader__.load({
|
|
|
1890
2025
|
".gb-toolState.gb-st-running{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);display:inline-flex;align-items:center;gap:5px}",
|
|
1891
2026
|
".gb-st-ok{border-color:var(--dsw-alias-state-success-primary,#3fb950);color:var(--dsw-alias-state-success-primary,#3fb950)}",
|
|
1892
2027
|
".gb-st-fail{border-color:var(--dsw-alias-status-danger,#e5484d);color:var(--dsw-alias-status-danger,#e5484d)}",
|
|
2028
|
+
".gb-st-skip{color:var(--dsw-alias-label-tertiary)}",
|
|
2029
|
+
".gb-toolElapsed{flex:none;font-size:calc(11px + var(--dsh-content-font-delta-secondary, 0px));color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
|
|
2030
|
+
".gb-toolCancel{flex:none;padding:1px 10px}",
|
|
2031
|
+
".gb-toolTail{margin:0 0 4px;font-size:calc(11px + var(--dsh-content-font-delta-secondary, 0px));line-height:1.4;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
1893
2032
|
".gb-spin{flex:none;display:inline-block;width:10px;height:10px;border:2px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-brand-primary);border-radius:50%;animation:gb-spin .8s linear infinite}",
|
|
1894
2033
|
".gb-toolBar{flex:1 1 auto;min-width:32px;height:4px;border-radius:2px;background:var(--dsw-alias-border-l2);overflow:hidden}",
|
|
1895
2034
|
".gb-toolBarFill{display:block;height:100%;width:38%;border-radius:2px;background:var(--dsw-alias-brand-primary);animation:gb-slide 1.15s ease-in-out infinite}",
|
|
@@ -2570,6 +2709,19 @@ window.__ModuleLoader__.load({
|
|
|
2570
2709
|
.then(function () { setToolBusy(false); });
|
|
2571
2710
|
}
|
|
2572
2711
|
|
|
2712
|
+
function cancelTools() {
|
|
2713
|
+
// Issue #35: the host kills the whole winget process tree; the job
|
|
2714
|
+
// stays current (finished, cancelled) so the card can show what
|
|
2715
|
+
// happened, and `clear` forgets it when the view closes.
|
|
2716
|
+
return fetch(TOOLS_URL + "?action=cancel", { method: "POST", headers: { "content-type": "application/json" } })
|
|
2717
|
+
.then(function (res) {
|
|
2718
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
2719
|
+
return res.json();
|
|
2720
|
+
})
|
|
2721
|
+
.then(function (data) { setToolJob(data !== null && typeof data === "object" && data.job ? data.job : null); })
|
|
2722
|
+
.catch(function (err) { setToolErr(String((err && err.message) || err)); });
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2573
2725
|
var toolsSection = fold("sec.tools",
|
|
2574
2726
|
toolsData === null && toolErr === "" ? E("p", { className: "gb-hint" }, t("tools.loading")) : null,
|
|
2575
2727
|
toolErr !== "" ? E("p", { className: "gb-error" }, toolErr) : null,
|
|
@@ -2590,8 +2742,19 @@ window.__ModuleLoader__.load({
|
|
|
2590
2742
|
E("span", { className: "gb-spin" }),
|
|
2591
2743
|
t(isUpgrade ? "tools.updating" : "tools.installing"));
|
|
2592
2744
|
} else if (entry !== undefined) {
|
|
2593
|
-
|
|
2594
|
-
|
|
2745
|
+
/* Issue #35: the ending matters — cancelled/skipped are not
|
|
2746
|
+
failures, a timeout and a wedged download are not the
|
|
2747
|
+
same as winget saying no. Skipped entries are neutral. */
|
|
2748
|
+
var okBadge = entry.ok === true;
|
|
2749
|
+
var kindKey = okBadge ? "tools.resultOk"
|
|
2750
|
+
: entry.kind === "cancelled" ? "tools.kind.cancelled"
|
|
2751
|
+
: entry.kind === "skipped" ? "tools.kind.skipped"
|
|
2752
|
+
: entry.kind === "timeout" ? "tools.kind.timeout"
|
|
2753
|
+
: entry.kind === "network" ? "tools.kind.network"
|
|
2754
|
+
: "tools.resultFail";
|
|
2755
|
+
var mark = okBadge ? "✓ " : (entry.kind === "skipped" ? "– " : "✗ ");
|
|
2756
|
+
var stateClass = okBadge ? "gb-st-ok" : (entry.kind === "skipped" ? "gb-st-skip" : "gb-st-fail");
|
|
2757
|
+
badge = E("span", { className: "gb-toolState " + stateClass }, mark + t(kindKey));
|
|
2595
2758
|
} else {
|
|
2596
2759
|
badge = E("span", { className: "gb-toolState gb-st-" + row.state }, t("tools.state." + row.state));
|
|
2597
2760
|
}
|
|
@@ -2611,7 +2774,9 @@ window.__ModuleLoader__.load({
|
|
|
2611
2774
|
active ? E("span", { className: "gb-toolBar" }, E("span", { className: "gb-toolBarFill" })) : null,
|
|
2612
2775
|
E("span", { className: "gb-toolSrc" }, (row.version !== "" ? row.version + " · " : "") + (row.provider !== "" ? row.provider : row.source) + (row.admin ? " · " + t("tools.adminHint") : "")),
|
|
2613
2776
|
));
|
|
2614
|
-
|
|
2777
|
+
/* Skipped entries have nothing to explain ("cancelled before
|
|
2778
|
+
this entry started") — only real verdicts print a line. */
|
|
2779
|
+
if (entry !== undefined && entry.ok !== true && entry.kind !== "skipped") {
|
|
2615
2780
|
acc.push(E("p", { key: row.id + ":fail", className: "gb-toolFail" },
|
|
2616
2781
|
t(entry.action === "upgrade" ? "tools.updating" : "tools.installing") + " — " + t("tools.resultFail") + ": " + (entry.detail || "")));
|
|
2617
2782
|
}
|
|
@@ -2636,11 +2801,26 @@ window.__ModuleLoader__.load({
|
|
|
2636
2801
|
E("span", { className: "gb-spin" }),
|
|
2637
2802
|
E("span", { className: "gb-rowLabel" }, (activeCmd !== "" ? activeCmd + " · " : "") + t("tools.working") + ":"),
|
|
2638
2803
|
E("span", { className: "gb-rowValue" }, toolJob.done + " / " + toolJob.total),
|
|
2804
|
+
/* Issue #35: how long the entry in flight has been running —
|
|
2805
|
+
the difference between "slow UAC wait" and "wedged download"
|
|
2806
|
+
is exactly this number. Locale-neutral 2m13s form; refreshed
|
|
2807
|
+
by the 1.2s poll, so no timer of its own. */
|
|
2808
|
+
E("span", { className: "gb-toolElapsed" }, formatElapsed(Date.now() - (toolJob.currentSince || toolJob.startedAt || Date.now()))),
|
|
2809
|
+
E("button", {
|
|
2810
|
+
type: "button",
|
|
2811
|
+
className: "gb-segBtn gb-toolCancel",
|
|
2812
|
+
onClick: cancelTools,
|
|
2813
|
+
}, t("tools.cancel")),
|
|
2639
2814
|
),
|
|
2815
|
+
/* The freshest winget line ("Failed to download, waiting a bit
|
|
2816
|
+
and retry"): the one signal that distinguishes a wedged
|
|
2817
|
+
download from a quiet UAC wait. One grey line, not a log. */
|
|
2818
|
+
typeof toolJob.lastOutput === "string" && toolJob.lastOutput !== "" ? E("p", { className: "gb-toolTail" }, toolJob.lastOutput) : null,
|
|
2640
2819
|
E("div", { className: "gb-jobBar" },
|
|
2641
2820
|
E("div", { className: "gb-jobBarFill", style: { width: jobPct + "%" } }),
|
|
2642
2821
|
),
|
|
2643
2822
|
) : null,
|
|
2823
|
+
toolJob !== null && toolJob.finished === true && toolJob.cancelled === true ? E("p", { className: "gb-hint" }, t("tools.cancelledNote")) : null,
|
|
2644
2824
|
E("p", { className: "gb-hint" }, t("tools.hint")),
|
|
2645
2825
|
);
|
|
2646
2826
|
|
package/src/index.js
CHANGED
|
@@ -308,7 +308,7 @@ import {
|
|
|
308
308
|
restoreOpsBash,
|
|
309
309
|
} from './ops-adopt.js'
|
|
310
310
|
import {
|
|
311
|
-
clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
|
|
311
|
+
cancelToolJob, clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
|
|
312
312
|
probeToolchain, readJsonBody, startToolJob,
|
|
313
313
|
} from './tool-runner.js'
|
|
314
314
|
|
|
@@ -2660,6 +2660,10 @@ export async function apply(ctx, config = {}) {
|
|
|
2660
2660
|
return {
|
|
2661
2661
|
id: job.id, action: job.action, total: job.total, done: job.done,
|
|
2662
2662
|
current: job.current, finished: job.finished, results: job.results.slice(),
|
|
2663
|
+
// v0.43.0 (issue #35): elapsed time, the freshest winget line,
|
|
2664
|
+
// and the cancelled state, so the card can show them.
|
|
2665
|
+
startedAt: job.startedAt, currentSince: job.currentSince,
|
|
2666
|
+
lastOutput: job.lastOutput, cancelled: job.cancelled === true,
|
|
2663
2667
|
}
|
|
2664
2668
|
}
|
|
2665
2669
|
const handler = async (req, res) => {
|
|
@@ -2675,6 +2679,14 @@ export async function apply(ctx, config = {}) {
|
|
|
2675
2679
|
try {
|
|
2676
2680
|
if (method === 'POST') {
|
|
2677
2681
|
const action = query.get('action') || ''
|
|
2682
|
+
if (action === 'cancel') {
|
|
2683
|
+
// v0.43.0 (issue #35): kill the in-flight tree, mark the rest
|
|
2684
|
+
// skipped. The cancelled job stays current until finished so
|
|
2685
|
+
// the card can show what happened; `clear` then forgets it.
|
|
2686
|
+
cancelToolJob('')
|
|
2687
|
+
send(res, 200, { ok: true, job: snapshot() })
|
|
2688
|
+
return
|
|
2689
|
+
}
|
|
2678
2690
|
if (action === 'clear') {
|
|
2679
2691
|
clearToolJob('')
|
|
2680
2692
|
send(res, 200, { ok: true, job: null })
|
package/src/tool-runner.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* module is testable without winget, Windows, or a network.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { spawn } from 'node:child_process'
|
|
29
29
|
import { readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
30
30
|
import { randomUUID } from 'node:crypto'
|
|
31
31
|
import { tmpdir } from 'node:os'
|
|
@@ -42,6 +42,46 @@ import {
|
|
|
42
42
|
const PROBE_TIMEOUT_MS = 60_000
|
|
43
43
|
const INSTALL_TIMEOUT_MS = 15 * 60_000
|
|
44
44
|
|
|
45
|
+
/** winget's own download-retry chatter (issue #35): when this shows up and the
|
|
46
|
+
* process is STILL running one window later, the download is wedged — DO
|
|
47
|
+
* falls back to WinINet, retries, and would otherwise burn the whole 15-minute
|
|
48
|
+
* ceiling in silence. */
|
|
49
|
+
export const NETWORK_RETRY_PATTERN = /failed to download, waiting a bit and retry|0x80072ee2|0x80072efd/i
|
|
50
|
+
const NETWORK_RETRY_WINDOW_MS = 120_000
|
|
51
|
+
/** Hard cap on the collected output of one run (mirrors the old maxBuffer). */
|
|
52
|
+
const OUTPUT_CAP_BYTES = 16 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/** Kill a whole process tree. */
|
|
55
|
+
function killTree(child) {
|
|
56
|
+
try {
|
|
57
|
+
if (!child || child.pid === undefined) return
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
60
|
+
} else {
|
|
61
|
+
child.kill('SIGKILL')
|
|
62
|
+
}
|
|
63
|
+
} catch { /* best effort — the verdict does not depend on the kill landing */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sort a run result that ended through cancel / hard timeout / wedged
|
|
67
|
+
* download from an ordinary winget failure (issue #35).
|
|
68
|
+
* Only the run's OWN `cancelled` flag counts here: a cancel that lands AFTER
|
|
69
|
+
* winget already exited must not rewrite a finished install into a cancelled
|
|
70
|
+
* one — the loop skips the remaining entries instead. */
|
|
71
|
+
function verdictFrom(result) {
|
|
72
|
+
if (result && result.cancelled === true) return { ok: false, kind: 'cancelled', detail: 'cancelled by the user' }
|
|
73
|
+
if (result && result.timedOut === true) return { ok: false, kind: 'timeout', detail: tailLine(result.output) || 'hard timeout' }
|
|
74
|
+
if (result && result.networkRetry === true) return { ok: false, kind: 'network', detail: tailLine(result.output) || 'download retried without progress' }
|
|
75
|
+
return classifyWingetResult(result.code, result.output)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The freshest full line of a run's output, capped — the one signal a stuck
|
|
79
|
+
* download actually prints ("Failed to download, waiting a bit and retry"). */
|
|
80
|
+
function tailLine(text) {
|
|
81
|
+
const lines = String(text || '').trimEnd().split(/\r?\n/)
|
|
82
|
+
return (lines[lines.length - 1] || '').slice(0, 300)
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/** Only one job at a time: winget serializes on its own state anyway, and two
|
|
46
86
|
* concurrent installs is how a half-applied catalog happens. */
|
|
47
87
|
let activeJob = null
|
|
@@ -49,22 +89,101 @@ let activeJob = null
|
|
|
49
89
|
/**
|
|
50
90
|
* The real IO. `run` never rejects: a non-zero exit is data, and a spawn
|
|
51
91
|
* failure becomes a synthetic exit code so callers have one shape to handle.
|
|
92
|
+
*
|
|
93
|
+
* v0.43.0 (issue #35): the old `execFile` buffered the child's whole output,
|
|
94
|
+
* so a wedged winget download looked like "processing" for 15 minutes with
|
|
95
|
+
* nothing to watch and no way out. `spawn` streams instead: `hooks.onOutput`
|
|
96
|
+
* receives each line as it arrives, a hard timeout, an external
|
|
97
|
+
* `cancelActive()` and the download-retry watchdog below all end the run by
|
|
98
|
+
* killing the WHOLE process tree (winget dispatches forwarders / msiexec /
|
|
99
|
+
* DeliveryOptimization children — killing only the parent leaves them), and
|
|
100
|
+
* the result carries `cancelled` / `timedOut` / `networkRetry` so callers can
|
|
101
|
+
* sort those endings from ordinary failures.
|
|
102
|
+
* @param {{ networkRetryWindowMs?: number }} [options]
|
|
52
103
|
* @returns {object}
|
|
53
104
|
*/
|
|
54
|
-
export function defaultRunnerIo() {
|
|
105
|
+
export function defaultRunnerIo(options = {}) {
|
|
106
|
+
const networkRetryWindowMs = Number(options.networkRetryWindowMs) > 0
|
|
107
|
+
? Number(options.networkRetryWindowMs)
|
|
108
|
+
: NETWORK_RETRY_WINDOW_MS
|
|
109
|
+
/** The in-flight run: `{ child, flags }`, or null between runs. */
|
|
110
|
+
let current = null
|
|
55
111
|
return {
|
|
56
|
-
run(file, args, timeoutMs) {
|
|
112
|
+
run(file, args, timeoutMs, hooks) {
|
|
113
|
+
const onOutput = hooks && typeof hooks.onOutput === 'function' ? hooks.onOutput : null
|
|
57
114
|
return new Promise((resolve) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
let settled = false
|
|
116
|
+
const flags = {}
|
|
117
|
+
const child = spawn(file, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
118
|
+
current = { child, flags }
|
|
119
|
+
let output = ''
|
|
120
|
+
let bytes = 0
|
|
121
|
+
const swallow = (chunk) => {
|
|
122
|
+
bytes += chunk.length
|
|
123
|
+
if (bytes <= OUTPUT_CAP_BYTES) output += chunk.toString('utf8')
|
|
124
|
+
if (onOutput !== null) {
|
|
125
|
+
// Keep only the freshest line (a 300-char tail): the card shows one
|
|
126
|
+
// grey line under the progress, not a log.
|
|
127
|
+
const line = String(chunk).split(/\r?\n/).filter(Boolean).pop()
|
|
128
|
+
if (line) onOutput(line.slice(0, 300))
|
|
63
129
|
}
|
|
64
|
-
|
|
130
|
+
}
|
|
131
|
+
child.stdout.on('data', swallow)
|
|
132
|
+
child.stderr.on('data', swallow)
|
|
133
|
+
// The wedged-download watchdog: from the FIRST retry line, a process
|
|
134
|
+
// still alive one window later is not making progress (winget cycles
|
|
135
|
+
// DO → WinINet → "waiting a bit and retry" for minutes). One window,
|
|
136
|
+
// once — a slow but working download never sees it.
|
|
137
|
+
let retryTimer = null
|
|
138
|
+
child.stderr.on('data', (chunk) => {
|
|
139
|
+
if (settled || flags.networkRetry || flags.timedOut || flags.cancelled || retryTimer !== null) return
|
|
140
|
+
if (NETWORK_RETRY_PATTERN.test(String(chunk))) {
|
|
141
|
+
retryTimer = setTimeout(() => {
|
|
142
|
+
retryTimer = null
|
|
143
|
+
if (settled || flags.timedOut || flags.cancelled) return
|
|
144
|
+
flags.networkRetry = true
|
|
145
|
+
killTree(child)
|
|
146
|
+
}, networkRetryWindowMs)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
if (settled || flags.networkRetry || flags.cancelled) return
|
|
151
|
+
flags.timedOut = true
|
|
152
|
+
killTree(child)
|
|
153
|
+
}, timeoutMs || PROBE_TIMEOUT_MS)
|
|
154
|
+
child.on('error', (error) => {
|
|
155
|
+
if (settled) return
|
|
156
|
+
settled = true
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null }
|
|
159
|
+
if (current !== null && current.child === child) current = null
|
|
160
|
+
resolve({ code: -1, output: output || String((error && error.message) || error) })
|
|
161
|
+
})
|
|
162
|
+
child.on('close', (code) => {
|
|
163
|
+
if (settled) return
|
|
164
|
+
settled = true
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null }
|
|
167
|
+
if (current !== null && current.child === child) current = null
|
|
168
|
+
resolve({
|
|
169
|
+
code: typeof code === 'number' ? code : -1,
|
|
170
|
+
output,
|
|
171
|
+
...(flags.cancelled === true ? { cancelled: true } : {}),
|
|
172
|
+
...(flags.timedOut === true ? { timedOut: true } : {}),
|
|
173
|
+
...(flags.networkRetry === true ? { networkRetry: true } : {}),
|
|
174
|
+
})
|
|
65
175
|
})
|
|
66
176
|
})
|
|
67
177
|
},
|
|
178
|
+
/** Abort the in-flight run (issue #35). A no-op between runs; the run
|
|
179
|
+
* resolves with `cancelled: true` once the tree is down. */
|
|
180
|
+
cancelActive() {
|
|
181
|
+
const entry = current
|
|
182
|
+
if (!entry || entry.child.exitCode !== null || entry.child.signalCode !== null) return false
|
|
183
|
+
entry.flags.cancelled = true
|
|
184
|
+
killTree(entry.child)
|
|
185
|
+
return true
|
|
186
|
+
},
|
|
68
187
|
pathDirs() {
|
|
69
188
|
return String(process.env.PATH || '').split(';').filter(Boolean)
|
|
70
189
|
},
|
|
@@ -204,8 +323,18 @@ export async function runToolJob(io, request, onChange) {
|
|
|
204
323
|
total: entries.length,
|
|
205
324
|
done: 0,
|
|
206
325
|
current: '',
|
|
326
|
+
// v0.43.0 (issue #35): observability + cancellation. `currentSince` times
|
|
327
|
+
// the entry in flight, `lastOutput` mirrors the freshest winget line,
|
|
328
|
+
// `cancelled` is set by cancelToolJob(), and `io` is kept so the cancel
|
|
329
|
+
// path can reach the process tree. The route's snapshot picks fields
|
|
330
|
+
// explicitly, so the io reference never reaches the wire.
|
|
331
|
+
startedAt: Date.now(),
|
|
332
|
+
currentSince: 0,
|
|
333
|
+
lastOutput: '',
|
|
334
|
+
cancelled: false,
|
|
207
335
|
finished: false,
|
|
208
336
|
results: [],
|
|
337
|
+
io,
|
|
209
338
|
}
|
|
210
339
|
activeJob = job
|
|
211
340
|
|
|
@@ -216,6 +345,20 @@ export async function runToolJob(io, request, onChange) {
|
|
|
216
345
|
try {
|
|
217
346
|
owned = await readWingetInventory(io)
|
|
218
347
|
} catch (error) {
|
|
348
|
+
if (job.cancelled) {
|
|
349
|
+
// The cancel landed while the inventory was being read: nothing was
|
|
350
|
+
// attempted, so nothing may be reported as a failure.
|
|
351
|
+
job.results = entries.map((tool) => ({
|
|
352
|
+
id: tool.id, cmd: tool.cmd, action: mode === 'upgrade' ? 'upgrade' : 'install',
|
|
353
|
+
wingetId: tool.ids[0], admin: tool.admin === true, ok: false, kind: 'skipped',
|
|
354
|
+
detail: 'cancelled before this entry started',
|
|
355
|
+
}))
|
|
356
|
+
job.done = entries.length
|
|
357
|
+
job.current = ''
|
|
358
|
+
job.finished = true
|
|
359
|
+
if (onChange) onChange(job)
|
|
360
|
+
return job
|
|
361
|
+
}
|
|
219
362
|
// Do not choose a preferred build or start an installer from an unknown inventory.
|
|
220
363
|
job.results = entries.map(tool => ({
|
|
221
364
|
id: tool.id, cmd: tool.cmd, action: mode === 'upgrade' ? 'upgrade' : 'install',
|
|
@@ -230,34 +373,42 @@ export async function runToolJob(io, request, onChange) {
|
|
|
230
373
|
}
|
|
231
374
|
|
|
232
375
|
for (const tool of entries) {
|
|
233
|
-
job.current = tool.cmd
|
|
234
|
-
if (onChange) onChange(job)
|
|
235
376
|
const installed = owned.get(tool.id)
|
|
236
377
|
const useUpgrade = mode === 'upgrade' || (mode === 'auto' && !!installed)
|
|
378
|
+
const base = {
|
|
379
|
+
id: tool.id,
|
|
380
|
+
cmd: tool.cmd,
|
|
381
|
+
action: useUpgrade ? 'upgrade' : 'install',
|
|
382
|
+
wingetId: installed ? installed.pkgId : tool.ids[0],
|
|
383
|
+
admin: tool.admin === true,
|
|
384
|
+
}
|
|
385
|
+
// A cancel lands between entries too: the remaining ones are skipped, not
|
|
386
|
+
// failed (issue #35) — the user asked to stop, not to be told it broke.
|
|
387
|
+
if (job.cancelled) {
|
|
388
|
+
job.results.push({ ...base, ok: false, kind: 'skipped', detail: 'cancelled before this entry started' })
|
|
389
|
+
job.done += 1
|
|
390
|
+
if (onChange) onChange(job)
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
job.current = tool.cmd
|
|
394
|
+
job.currentSince = Date.now()
|
|
395
|
+
if (onChange) onChange(job)
|
|
237
396
|
const argv = useUpgrade ? upgradeArgv(tool, installed && installed.pkgId) : installArgv(tool)
|
|
397
|
+
const hooks = { onOutput: (line) => { job.lastOutput = line } }
|
|
238
398
|
let verdict
|
|
239
399
|
try {
|
|
240
|
-
const result = await io.run('winget', argv, INSTALL_TIMEOUT_MS)
|
|
241
|
-
verdict =
|
|
400
|
+
const result = await io.run('winget', argv, INSTALL_TIMEOUT_MS, hooks)
|
|
401
|
+
verdict = verdictFrom(result)
|
|
242
402
|
// An explicit "install" on something winget already owns is really an
|
|
243
403
|
// upgrade request; retry as one so the user's click does the obvious thing.
|
|
244
|
-
if (!verdict.ok && !useUpgrade && verdict.kind === 'not-found' && installed) {
|
|
245
|
-
const retry = await io.run('winget', upgradeArgv(tool, installed.pkgId), INSTALL_TIMEOUT_MS)
|
|
246
|
-
verdict =
|
|
404
|
+
if (!verdict.ok && !useUpgrade && verdict.kind === 'not-found' && installed && !job.cancelled) {
|
|
405
|
+
const retry = await io.run('winget', upgradeArgv(tool, installed.pkgId), INSTALL_TIMEOUT_MS, hooks)
|
|
406
|
+
verdict = verdictFrom(retry)
|
|
247
407
|
}
|
|
248
408
|
} catch (error) {
|
|
249
409
|
verdict = { ok: false, kind: 'failed', detail: String((error && error.message) || error) }
|
|
250
410
|
}
|
|
251
|
-
job.results.push({
|
|
252
|
-
id: tool.id,
|
|
253
|
-
cmd: tool.cmd,
|
|
254
|
-
action: useUpgrade ? 'upgrade' : 'install',
|
|
255
|
-
wingetId: installed ? installed.pkgId : tool.ids[0],
|
|
256
|
-
admin: tool.admin === true,
|
|
257
|
-
ok: verdict.ok,
|
|
258
|
-
kind: verdict.kind,
|
|
259
|
-
detail: verdict.detail,
|
|
260
|
-
})
|
|
411
|
+
job.results.push({ ...base, ok: verdict.ok, kind: verdict.kind, detail: verdict.detail })
|
|
261
412
|
job.done += 1
|
|
262
413
|
if (onChange) onChange(job)
|
|
263
414
|
}
|
|
@@ -282,9 +433,34 @@ export function currentToolJob() {
|
|
|
282
433
|
return activeJob
|
|
283
434
|
}
|
|
284
435
|
|
|
285
|
-
/**
|
|
436
|
+
/**
|
|
437
|
+
* Cancel the active job: mark it, then kill the whole winget process tree so
|
|
438
|
+
* the in-flight entry ends immediately and the loop marks the rest skipped
|
|
439
|
+
* (issue #35). A no-op on a finished or missing job, or on an id mismatch.
|
|
440
|
+
* @param {string} [id] - when given, only that job is cancelled.
|
|
441
|
+
* @returns {object} the (possibly unchanged) current job.
|
|
442
|
+
*/
|
|
443
|
+
export function cancelToolJob(id) {
|
|
444
|
+
const job = activeJob
|
|
445
|
+
if (!job || job.finished) return currentToolJob()
|
|
446
|
+
if (id && job.id !== id) return job
|
|
447
|
+
job.cancelled = true
|
|
448
|
+
try { if (job.io && typeof job.io.cancelActive === 'function') job.io.cancelActive() } catch { /* best effort */ }
|
|
449
|
+
return job
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Forget a finished job (called when the card closes the progress view).
|
|
453
|
+
* Closing it MID-RUN now cancels instead: the old clear just dropped the
|
|
454
|
+
* activeJob lock and let a second job race the still-running winget — a
|
|
455
|
+
* half-applied catalog was one click away (issue #35). */
|
|
286
456
|
export function clearToolJob(id) {
|
|
287
|
-
|
|
457
|
+
const job = activeJob
|
|
458
|
+
if (!job) return
|
|
459
|
+
if (!job.finished) {
|
|
460
|
+
cancelToolJob(id)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
if (!id || job.id === id) activeJob = null
|
|
288
464
|
}
|
|
289
465
|
|
|
290
466
|
// ── request fencing ─────────────────────────────────────────────────────────
|