dsh-pwsh-plugin 0.2.12 → 0.2.14
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/FINDINGS.md +133 -7
- package/README.md +52 -36
- package/README.zh-CN.md +41 -33
- package/package.json +1 -1
- package/plugin/auto-update.mjs +42 -10
- package/plugin/index.mjs +9 -6
- package/plugin/update-apply.mjs +85 -29
- package/plugin/update-check.mjs +96 -83
- package/wsl/llrun +0 -0
package/FINDINGS.md
CHANGED
|
@@ -559,7 +559,12 @@ full-access 不挂围栏、流分离 + `exit code: 7`、2000 行大输出 ——
|
|
|
559
559
|
|
|
560
560
|
---
|
|
561
561
|
|
|
562
|
-
##
|
|
562
|
+
## 十九、自动更新:只查、只提示、从不自己装(历史)
|
|
563
|
+
|
|
564
|
+
> 本节记录的是 0.2.2 时期的形态:发布源是 GitHub 标签、装由人触发。第三十三节之后发布源换成
|
|
565
|
+
> npm registry、自动路径会自己装,但下面的**判决逻辑与失败语义**(失败不抛、保留 stale、查与装分开的
|
|
566
|
+
> 理由、`plugin_manager` 工具与服务的区别)仍然成立。文中出现的 `DSH_PWSH_UPDATE_SOURCE` / `github:` 规格
|
|
567
|
+
> 与 WSLENV 陷阱都属于那一版,现已被 `DSH_PWSH_UPDATE_REGISTRY` 取代。
|
|
563
568
|
|
|
564
569
|
参考同作者的 `dsh-remote-access-proxy`(v1.4 起有更新检查,1454 行里第 102-107 / 225-233 / 244 / 982 / 1010 行是全部相关代码)。
|
|
565
570
|
本插件照搬了它的**判决逻辑与失败语义**,改掉了两处形态。
|
|
@@ -568,9 +573,9 @@ full-access 不挂围栏、流分离 + `exit code: 7`、2000 行大输出 ——
|
|
|
568
573
|
|
|
569
574
|
| 设计 | 参考实现 | 本插件 |
|
|
570
575
|
|---|---|---|
|
|
571
|
-
|
|
|
572
|
-
| 安装规格 | `${source.spec}#${tag}` —— 一个 tag 一个规格,装新版就是盖掉旧的 |
|
|
573
|
-
| 源可覆盖 | `DSH_RAP_UPDATE_SOURCE`:`owner/repo` → GitHub;`git+<url>` → `git ls-remote` | `
|
|
576
|
+
| 版本契约 | `/^v(0\|\d*)\.(0\|\d*)\.(0\|\d*)$/`,预发布永不推送 | 同(`parseVersion` / `updateVerdict`)|
|
|
577
|
+
| 安装规格 | `${source.spec}#${tag}` —— 一个 tag 一个规格,装新版就是盖掉旧的 | `dsh-pwsh-plugin@X.Y.Z`(registry 形式,见第三十三节)|
|
|
578
|
+
| 源可覆盖 | `DSH_RAP_UPDATE_SOURCE`:`owner/repo` → GitHub;`git+<url>` → `git ls-remote` | `DSH_PWSH_UPDATE_REGISTRY`,指 registry 基址(第三十三节已从 GitHub 标签改为 npm dist-tags)|
|
|
574
579
|
| 首次延迟 / 周期 | 5s 之后每 12h,timer 都 `.unref?.()` | 同 |
|
|
575
580
|
| 失败不抛 | `state:'failed'` + 保留先前 `latest` + `stale:'available'` | 同,注释原话「一次网络抖动不该让已知的新版本消失」 |
|
|
576
581
|
| **查与装分开** | 页面把钉了 tag 的 spec 交给 DSH 的 `plugin_manager` | 输出末尾挂一行提示;装由 `pwsh_run_update` 显式触发,同样交给 `pluginManager` |
|
|
@@ -593,10 +598,10 @@ full-access 不挂围栏、流分离 + `exit code: 7`、2000 行大输出 ——
|
|
|
593
598
|
|
|
594
599
|
```powershell
|
|
595
600
|
$env:DSH_HOME = '<DSH_HOME>'
|
|
596
|
-
& '<desktop cli>\dsh.cmd' plugin --profile desktop add '
|
|
601
|
+
& '<desktop cli>\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
597
602
|
```
|
|
598
603
|
|
|
599
|
-
2. **`pwsh_run_update` 工具**(0.2.2
|
|
604
|
+
2. **`pwsh_run_update` 工具**(0.2.2 起):把钉住版本的 spec 交给 `ctx.get('pluginManager')`。
|
|
600
605
|
|
|
601
606
|
这里要区分**工具**与**服务**,早前我把两者混为一谈:
|
|
602
607
|
- 工具 `plugin_manager`:`dsh-base/cordis.patch.yml:15-17` 里 `tool-plugin-manager` 是 `disabled: true`,
|
|
@@ -1695,4 +1700,125 @@ if [ -f "<path>" ] && [ -O "<path>" ] && chmod +x "<path>" 2>/dev/null && [ -x "
|
|
|
1695
1700
|
改成先自己试写一次,写不出去就 `SKIP`。
|
|
1696
1701
|
|
|
1697
1702
|
### 验收
|
|
1698
|
-
十一份脚本在 Windows node 下 **
|
|
1703
|
+
十一份脚本在 Windows node 下 **305 项全绿**;bashtest 在 WSL 侧另跑到 `TOTAL pass=20 fail=0`(四项按平台跳过)。
|
|
1704
|
+
|
|
1705
|
+
---
|
|
1706
|
+
|
|
1707
|
+
## 三十三、发布与更新改走 npm registry(2026-10-28)
|
|
1708
|
+
|
|
1709
|
+
### 为什么换
|
|
1710
|
+
|
|
1711
|
+
原来更新检查读的是 GitHub 的 `GET /repos/<repo>/tags`,安装规格是 `github:owner/repo#vX.Y.Z`。
|
|
1712
|
+
包既然已经发在 npm 上(第三十二节之后),读 tag 就成了第二个真相源:同一个版本在两处分别可达,
|
|
1713
|
+
而两边谁先谁后没有任何约束。而且 `github:` 规格装的是仓库快照,不经过 pnpm 的供应链策略
|
|
1714
|
+
(`minimumReleaseAge`)—— 绕开安全网的路不该是默认路。
|
|
1715
|
+
|
|
1716
|
+
现在只有一个真相源:`dist-tags.latest`。
|
|
1717
|
+
|
|
1718
|
+
### 检查:读 abbreviated 文档
|
|
1719
|
+
|
|
1720
|
+
`GET https://registry.npmjs.org/dsh-pwsh-plugin`,请求头 `accept: application/vnd.npm.install-v1+json`。
|
|
1721
|
+
实测这份文档 200 时形状是:
|
|
1722
|
+
|
|
1723
|
+
```json
|
|
1724
|
+
{ "dist-tags": { "latest": "0.2.12" },
|
|
1725
|
+
"versions": { "0.0.0-stage": {...}, "0.2.12": { "version": "0.2.12", "dist": { "shasum": "1ca2d9f0…" } } } }
|
|
1726
|
+
```
|
|
1727
|
+
|
|
1728
|
+
三条判决规则:
|
|
1729
|
+
|
|
1730
|
+
| 情形 | 判决 |
|
|
1731
|
+
|---|---|
|
|
1732
|
+
| `dist-tags.latest` 是纯 `X.Y.Z` 且高于当前 | `available` |
|
|
1733
|
+
| `dist-tags.latest` 被标了 `deprecated` | `current`,并把被废弃的版本报出来(不推一个官方已劝退的版本)|
|
|
1734
|
+
| 没有 `dist-tags` | 从版本清单里挑最高**正式版**(预发布跳过)|
|
|
1735
|
+
|
|
1736
|
+
`404` 单独成一条文案:`not published on <registry>`。它不是「没有新版本」——一个是配置或发版出了问题,
|
|
1737
|
+
一个是正常工作状态,混在一起会把事故说成安静。
|
|
1738
|
+
|
|
1739
|
+
**abbreviated 文档里没有 `time` 字段**(实测 `time keys` 为空),所以检查阶段无法得知发布时间;
|
|
1740
|
+
冷却期只能靠 pnpm 自己的策略,见下。
|
|
1741
|
+
|
|
1742
|
+
### 安装:回读磁盘,不信返回值
|
|
1743
|
+
|
|
1744
|
+
`installBundle` 的返回里**没有 version 字段**(`application` 只有
|
|
1745
|
+
`'applied' | 'restart-required' | 'failed' | 'cancelled'`)。装到哪个版本只能从
|
|
1746
|
+
`<profileDir>/node_modules/dsh-pwsh-plugin/package.json` 回读。
|
|
1747
|
+
|
|
1748
|
+
这不是洁癖。pnpm 的 `minimumReleaseAge` 默认 1440 分钟(1 天,v11 起),今天发布的版本今天解析不到 ——
|
|
1749
|
+
于是「请求 0.2.13、装到 0.2.12」是**正常行为**,而 `installRelease` 若拿 `state.latest` 当结果,
|
|
1750
|
+
就会把一个没发生的事记成发生了。现在的做法:
|
|
1751
|
+
|
|
1752
|
+
- 回读 `installedVersion`;
|
|
1753
|
+
- 若它低于请求的版本,输出末尾追加一句 `pnpm installed X rather than Y: its minimumReleaseAge policy
|
|
1754
|
+
withholds versions that are too recent.`;
|
|
1755
|
+
- 自动路径把 `lastInstalled` 传进下一轮预检(`cooled`),若「冷却期落地的版本」仍低于 `latest`,
|
|
1756
|
+
把它从 `attempted` 里撤出来并在 1 小时后重试 —— 否则这个进程的生命周期会被钉死在那个旧版本上,
|
|
1757
|
+
而它明明已经装好了。
|
|
1758
|
+
|
|
1759
|
+
预检 `checkInstallNeed` 的 `cooling-down` 判据是:
|
|
1760
|
+
`cooled < latest && cooled >= (running ?? disk ?? '0.0.0')`。换句话说,只有当**已经装上的东西**比目标旧、
|
|
1761
|
+
且不比运行中的更旧时,才值得再发一次安装请求。
|
|
1762
|
+
|
|
1763
|
+
### 发布:OIDC trusted publishing
|
|
1764
|
+
|
|
1765
|
+
`.github/workflows/release.yml` 在 tag 上跑 `scripts/release-check.mjs`,然后
|
|
1766
|
+
`npm publish --provenance`,凭 npm 的 trusted publishing(runner 用 OIDC 换一个只对本仓库 + 本
|
|
1767
|
+
workflow 文件有效的令牌)。仓库里不放 `NPM_TOKEN`。
|
|
1768
|
+
|
|
1769
|
+
三点是实测/文档里挑出来的硬约束:
|
|
1770
|
+
|
|
1771
|
+
1. **workflow 文件名是信任契约的一部分。** 在 npm 的 Trusted publisher 设置里填的是 `release.yml`,
|
|
1772
|
+
改名或搬到子目录,每次发布都会 403,而且报错不会告诉你原因。
|
|
1773
|
+
2. **npm 11.5.1 起才支持。** Node 24 自带 11.x,workflow 里仍显式校验这条下限。
|
|
1774
|
+
3. **trusted publishing 只授权带 provenance 的发布**,所以 `--provenance` 不是可选项。
|
|
1775
|
+
|
|
1776
|
+
为什么不是「发版时手动 `npm publish`」:手动发布需要一个 bypass-2FA 的 granular token,而账号只有
|
|
1777
|
+
passkey(WebAuthn)、没有 TOTP 入口 —— 产不出 `--otp` 要的六位码。OIDC 把「谁能发」从「持有一个长期
|
|
1778
|
+
令牌的人」换成「这个仓库的这个 workflow」,同时消掉了令牌本身。
|
|
1779
|
+
|
|
1780
|
+
#### 实测:OIDC 这条路当时走不通,0.2.13 是用 token 发的
|
|
1781
|
+
|
|
1782
|
+
Trusted publisher 要在 npm 的 `/package/<name>/access` 页上绑定,提交表单后 npm 要求二次验证,
|
|
1783
|
+
而该账号的 2FA **只有 WebAuthn security key**(Bitwarden passkey),页面上只有一个 `Use security key`
|
|
1784
|
+
按钮;没有 TOTP 入口,也就没有可以填进 `--otp` 的六位码。**这一步需要人亲手碰钥匙,自动化在这里
|
|
1785
|
+
必须停手。**
|
|
1786
|
+
|
|
1787
|
+
所以 0.2.13 走的是已经验证过的老路:从用户环境变量取 `NPM_TOKEN`,写一份临时 `.npmrc`
|
|
1788
|
+
(`//registry.npmjs.org/:_authToken=…`),`npm publish --access public`,发完立刻删掉 `.npmrc`。
|
|
1789
|
+
结果 `+ dsh-pwsh-plugin@0.2.13`、`http fetch PUT 202`、`exit 0`。**token 没有进对话,也没有留在磁盘。**
|
|
1790
|
+
|
|
1791
|
+
顺带一条:`actions/setup-node` 一旦配了 `registry-url`,就会写一行空的
|
|
1792
|
+
`_authToken=${NODE_AUTH_TOKEN}`;npm 看到「有但为空」的凭据就不再走 OIDC,服务端把它映射成
|
|
1793
|
+
误导性的 `404 Not Found - PUT https://registry.npmjs.org/<pkg>`(provenance 都签成了、sigstore 也记了)。
|
|
1794
|
+
`0ae4bf5` 去掉 `registry-url` 并显式删掉这行,就是为了让 OIDC 接手。
|
|
1795
|
+
|
|
1796
|
+
#### 实测:发布后头五分钟,tarball 在 CDN 上是 404
|
|
1797
|
+
|
|
1798
|
+
`PUT 202` 返回时版本文档和 `dist-tags.latest` 已经是新值,但 tarball 地址
|
|
1799
|
+
`/-/dsh-pwsh-plugin-0.2.13.tgz` 仍回 **404**,响应头是 `cache-control: public, max-age=300`、
|
|
1800
|
+
`age: 179` —— Cloudflare 边缘缓存着一份「还没发布」时的 404。`pnpm add` 因此报:
|
|
1801
|
+
|
|
1802
|
+
```
|
|
1803
|
+
[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/dsh-pwsh-plugin/-/dsh-pwsh-plugin-0.2.13.tgz:
|
|
1804
|
+
Not Found - 404
|
|
1805
|
+
No authorization header was set for the request.
|
|
1806
|
+
```
|
|
1807
|
+
|
|
1808
|
+
看起来像权限问题,其实只是缓存。等满 300 秒后同一个 URL 立刻 200,`cache-control` 变成
|
|
1809
|
+
`immutable, max-age=31557600`。**没有捷径**:带 `?nocache=` 的地址能绕缓存,但 npm / pnpm 只会去取
|
|
1810
|
+
文档里那个裸 URL。实操口径于是写成「发布后等五分钟再装」。
|
|
1811
|
+
|
|
1812
|
+
附带一个无害的副作用:安装失败那次已经把 `dsh-pwsh-plugin@0.2.13` 写进了 profile 的
|
|
1813
|
+
`pnpm-workspace.yaml` → `minimumReleaseAgeExclude`,重试时直接复用。
|
|
1814
|
+
|
|
1815
|
+
### 测试
|
|
1816
|
+
|
|
1817
|
+
`test/support.mjs` 里加了 `startRegistry`:用 `node:http` 起一个本机假 registry,记录每次请求的
|
|
1818
|
+
`accept` 头,可随时 `set(name, doc)`。**每个响应都带 `connection: close`** —— undici 会为 keep-alive
|
|
1819
|
+
空闲连接挂一个解析器超时计时器,而退避测试要压缩全局 `setTimeout`,那个计时器会被提前触发并抛
|
|
1820
|
+
`TypeError: Cannot read properties of undefined (reading 'deref') at Timeout.onParserTimeout`。
|
|
1821
|
+
让连接短命是唯一不用改被测代码的解法。
|
|
1822
|
+
|
|
1823
|
+
`updatetest` 118 项、`autoupdatetest` 46 项、`wiringtest` 24 项全部改走这个假 registry。
|
|
1824
|
+
|
package/README.md
CHANGED
|
@@ -45,6 +45,21 @@ It inserts two:
|
|
|
45
45
|
It does **not** disable the stock `tool-bash`: that row already carries `disabled: !!js process.platform ===
|
|
46
46
|
'win32'`, and a hard `disabled: true` from here would switch the official bash off on Linux too.
|
|
47
47
|
|
|
48
|
+
**npm registry.** Also published to npm: `npm i dsh-pwsh-plugin`, or let DSH install the registry spec:
|
|
49
|
+
|
|
50
|
+
```powershell
|
|
51
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> pnpm's `minimumReleaseAge` defaults to 1440 minutes, so a version published today is not resolved
|
|
55
|
+
today — pressing update installs the previous release. That is the supply-chain policy, not a failure.
|
|
56
|
+
|
|
57
|
+
> For the first five minutes after `npm publish` the tarball URL still answers **404** from the CDN
|
|
58
|
+
edge (`cache-control: public, max-age=300`) even though the version document and `dist-tags.latest` are
|
|
59
|
+
already updated. Installing inside that window fails with `ERR_PNPM_FETCH_404 … .tgz: Not Found`, which
|
|
60
|
+
reads like a permissions problem and is not one. Wait the five minutes out; a `?nocache=` query string
|
|
61
|
+
bypasses the cache but neither npm nor pnpm will fetch that URL.
|
|
62
|
+
|
|
48
63
|
**Sidebar.** Plugins → Add plugin → `https://github.com/citydirector/dsh-pwsh-plugin`, then switch it on.
|
|
49
64
|
|
|
50
65
|
**CLI.** The `desktop` profile can only be managed by the desktop application's own CLI:
|
|
@@ -53,8 +68,8 @@ It does **not** disable the stock `tool-bash`: that row already carries `disable
|
|
|
53
68
|
$dsh = '<desktop install>\resources\runtime\cli\bin\dsh.cmd'
|
|
54
69
|
$env:DSH_HOME = '<DSH_HOME>' # the data root the desktop app itself uses
|
|
55
70
|
|
|
56
|
-
# <
|
|
57
|
-
& $dsh plugin --profile desktop add '
|
|
71
|
+
# <version> is any published version; the newest one is what the plugin itself would install
|
|
72
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
58
73
|
# or, from a local checkout
|
|
59
74
|
& $dsh plugin --profile desktop add 'file:<path to checkout>'
|
|
60
75
|
```
|
|
@@ -65,10 +80,12 @@ back out. Verify with `--dump-config` on a non-reserved profile, where the row s
|
|
|
65
80
|
|
|
66
81
|
Two things to know before you choose a spec:
|
|
67
82
|
|
|
68
|
-
-
|
|
69
|
-
changes nothing until you run `add` again. A `file:` install hard-links the checkout,
|
|
70
|
-
immediately (`fsutil hardlink list` shows both paths), at the cost of pinning an absolute
|
|
71
|
-
`package.json`. Use `file:` while developing,
|
|
83
|
+
- **A registry spec copies, `file:` hard-links.** `dsh-pwsh-plugin@X.Y.Z` installs one copy of that release —
|
|
84
|
+
editing the checkout changes nothing until you run `add` again. A `file:` install hard-links the checkout,
|
|
85
|
+
so edits are live immediately (`fsutil hardlink list` shows both paths), at the cost of pinning an absolute
|
|
86
|
+
path in `package.json`. Use `file:` while developing, a registry spec for keeps.
|
|
87
|
+
`github:citydirector/dsh-pwsh-plugin#<tag>` still works and installs the same copy, but it goes around
|
|
88
|
+
pnpm's supply-chain policy.
|
|
72
89
|
- **The bundle row cannot carry `?v=N`.** Its `name` must be the package name so it resolves from the
|
|
73
90
|
profile's `node_modules`, so there is no URL to change and ESM keeps serving the module it already cached.
|
|
74
91
|
Changing a file under `node_modules` is only picked up if the host happens to run a recomposition later;
|
|
@@ -127,7 +144,7 @@ When order matters, merge them yourself: `2>&1` or `*>&1`.
|
|
|
127
144
|
| `maxSessions` | `4` | How many sessions may live at once (one per agent workspace). |
|
|
128
145
|
| `maxOutputBytes` | `8388608` | Output collection cap per call (8 MB). Truncation is reported honestly, never silently. |
|
|
129
146
|
| `allowUnconfined` | `false` | `true` = start a session even when the sandbox policy service is missing. |
|
|
130
|
-
| `updateCheck` | `true` | Look for
|
|
147
|
+
| `updateCheck` | `true` | Look for newer published versions periodically. Checking and installing are separate: this only checks; `pwsh_run_update` and auto-update do the install. |
|
|
131
148
|
| `autoUpdate` | `true` | Install a newer release by itself, and — where the host runs HMR — take effect without a restart. |
|
|
132
149
|
| `autoUpdateDelayMs` | `90000` | Wait before the first automatic check, so start-up is not competing with pnpm. |
|
|
133
150
|
|
|
@@ -165,14 +182,14 @@ runspace. The tool reports that clearly and points at the built-in `pwsh` tool,
|
|
|
165
182
|
|
|
166
183
|
## Updates
|
|
167
184
|
|
|
168
|
-
Left alone, the plugin upgrades itself: 90 seconds after start, then every 12 hours, it reads the
|
|
169
|
-
|
|
185
|
+
Left alone, the plugin upgrades itself: 90 seconds after start, then every 12 hours, it reads the `latest`
|
|
186
|
+
dist-tag from the npm registry, and a newer release is installed on the spot. On a host that runs DSH's HMR service the new code is
|
|
170
187
|
**already live when the install returns** — no restart, no prompt, nothing for the user to do.
|
|
171
188
|
|
|
172
189
|
| How | What happens |
|
|
173
190
|
|---|---|
|
|
174
191
|
| Automatic (`autoUpdate: true`, default) | Checks, installs, and takes effect in place. A failed install backs off exponentially (5 min doubling to 2 h) and tries again; a version that installed successfully is not retried. **Never while a call is running** — it retries in 3 minutes. On a host **without** HMR it installs nothing — see below. |
|
|
175
|
-
| `pwsh_run_update` `action: "check"` | Reads the
|
|
192
|
+
| `pwsh_run_update` `action: "check"` | Reads the registry once and reports `state` / `running` / `newest release` / `install spec`. Installs nothing. |
|
|
176
193
|
| `pwsh_run_update` `action: "install"` | The same install, driven by hand instead of by the clock. Reports `install: postponed` if a call is running, and `install: restart-required` / `install: broken-release` when a pre-flight check says installing would achieve nothing (see below). |
|
|
177
194
|
|
|
178
195
|
The install goes through `ctx.get('pluginManager')` — the service behind the Plugins page — which runs pnpm
|
|
@@ -221,39 +238,31 @@ Without `pluginManager` — a profile where the `plugin-manager` service is disa
|
|
|
221
238
|
|
|
222
239
|
```powershell
|
|
223
240
|
$env:DSH_HOME = '<DSH_HOME>'
|
|
224
|
-
& '<desktop install>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add '
|
|
241
|
+
& '<desktop install>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
225
242
|
```
|
|
226
243
|
|
|
227
244
|
Note that DSH's stock composition disables the *tool* `plugin_manager` while leaving the *service* running
|
|
228
245
|
for desktop profiles; the two are different things, and only the service is used here.
|
|
229
246
|
|
|
230
|
-
The
|
|
231
|
-
|
|
247
|
+
The source is the npm registry entry for this package: `GET https://registry.npmjs.org/dsh-pwsh-plugin`
|
|
248
|
+
with `accept: application/vnd.npm.install-v1+json`. Its `dist-tags.latest` is the release to offer; it must
|
|
249
|
+
be a plain `X.Y.Z`. A `latest` marked `deprecated` is never offered, and without a `dist-tags` the check
|
|
250
|
+
falls back to the highest stable version in the version list. A `404` is reported as "not published on
|
|
251
|
+
<registry>" rather than "no newer release" — those are different facts, and only one of them is temporary.
|
|
252
|
+
|
|
253
|
+
The install spec is the registry form, `dsh-pwsh-plugin@X.Y.Z`, so what the plugin asks for and what
|
|
254
|
+
`dsh plugin add` accepts are the same string.
|
|
232
255
|
|
|
233
256
|
| Environment variable | Effect |
|
|
234
257
|
|---|---|
|
|
235
|
-
| `
|
|
236
|
-
| `DSH_PWSH_UPDATE_API` | GitHub API base, default `https://api.github.com` |
|
|
258
|
+
| `DSH_PWSH_UPDATE_REGISTRY` | Registry base URL, default `https://registry.npmjs.org`. Point it at a mirror, or at a local server to rehearse. |
|
|
237
259
|
| `DSH_PWSH_UPDATE_DELAY_MS` | First check delay, default 5000 |
|
|
238
260
|
|
|
239
|
-
To rehearse locally, make a tagged repository and point the source at it:
|
|
240
|
-
|
|
241
261
|
```powershell
|
|
242
|
-
$
|
|
243
|
-
git init -q -b main $root
|
|
244
|
-
git -C $root -c user.email=t@l -c user.name=t commit -q --allow-empty -m release
|
|
245
|
-
git -C $root tag v0.2.1
|
|
246
|
-
$env:DSH_PWSH_UPDATE_SOURCE = 'git+file:///' + ($root -replace '\\','/')
|
|
247
|
-
node -e "import('./plugin/update-check.mjs').then(async (m) => console.log(JSON.stringify(await new m.UpdateChecker({ version: '0.1.0' }).check())))"
|
|
248
|
-
# -> {"state":"available","latest":"v0.2.1","checkedAt":"..."}
|
|
249
|
-
Remove-Item -Recurse -Force $root
|
|
262
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
250
263
|
```
|
|
251
264
|
|
|
252
|
-
|
|
253
|
-
`DSH_PWSH_UPDATE_SOURCE` from a WSL `bash` call never reaches a Windows process — WSL only forwards the
|
|
254
|
-
variables named in `WSLENV`, so every other `DSH_*` disappears and the plugin queries the default GitHub
|
|
255
|
-
repository instead. Crossing over needs `WSLENV=DSH_PWSH_UPDATE_SOURCE`; do **not** add `/p`, which would
|
|
256
|
-
rewrite `/tmp/...` into a `\\wsl.localhost\...` path and break it.
|
|
265
|
+
Installing by hand is the same operation the plugin performs, with the same policy applied by pnpm.
|
|
257
266
|
|
|
258
267
|
A failed query never throws into the tool call: it records `state: 'failed'`, logs one line through
|
|
259
268
|
`ctx.logger`, and keeps an earlier `latest` as `stale: 'available'` — one network hiccup should not make a
|
|
@@ -352,7 +361,7 @@ plugin/
|
|
|
352
361
|
index.mjs tool registration, sandbox wiring, timeouts
|
|
353
362
|
session.mjs the session object: framing, queue, base64 encode, boot argv
|
|
354
363
|
session-server.ps1 the PowerShell side: runspace, stream tagging, sentinels
|
|
355
|
-
update-check.mjs
|
|
364
|
+
update-check.mjs registry lookup and version comparison
|
|
356
365
|
update-apply.mjs hands the pinned spec to pluginManager, never downloads
|
|
357
366
|
auto-update.mjs the automatic path: install outside the HMR transaction, back off on failure
|
|
358
367
|
test/
|
|
@@ -363,7 +372,7 @@ test/
|
|
|
363
372
|
sandboxtest.mjs real LocalSandboxProvider, boundary + fail-closed cases
|
|
364
373
|
bashtest.mjs custom-bash fence triage (mock runtime)
|
|
365
374
|
bash-e2e.mjs custom-bash end to end (real runtime + real llrun)
|
|
366
|
-
updatetest.mjs update check against a local
|
|
375
|
+
updatetest.mjs update check against a local registry, install outcomes
|
|
367
376
|
autoupdatetest.mjs the automatic loop, its back-off, its idle gate, and the HMR-context escape it needs
|
|
368
377
|
wiringtest.mjs the update notice lands at the end of tool output, and installs wait for an idle session
|
|
369
378
|
denialtest.mjs the sandbox-denial hint fires on the right messages only
|
|
@@ -391,17 +400,24 @@ node test/denialtest.mjs # 11: sandbox-denial hint (no spawn
|
|
|
391
400
|
node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
|
|
392
401
|
```
|
|
393
402
|
|
|
394
|
-
Eleven scripts, **
|
|
403
|
+
Eleven scripts, **305 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
|
|
395
404
|
DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
|
|
396
405
|
(`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
|
|
397
406
|
instead of failing when none is found.
|
|
398
407
|
|
|
399
408
|
### Releasing
|
|
400
409
|
|
|
410
|
+
Publishing from a tag is `[.github/workflows/release.yml](.github/workflows/release.yml)`: it runs
|
|
411
|
+
`scripts/release-check.mjs`, then `npm publish --provenance` under npm's **trusted publishing**, which trades
|
|
412
|
+
the runner's short-lived GitHub identity for a token scoped to this repository and this workflow file. No
|
|
413
|
+
`NPM_TOKEN` secret exists, and none should be added. Two consequences worth knowing: the workflow file name is
|
|
414
|
+
part of the trust contract (rename it and every publish fails with 403 until the npm setting is updated), and
|
|
415
|
+
provenance is mandatory there, so the tarball carries a signed statement of which commit built it.
|
|
416
|
+
|
|
401
417
|
The version in `package.json` is the one thing in this repository that is hard-coded, and the update check
|
|
402
|
-
compares it against the published
|
|
403
|
-
release forever: the installed files keep reading the old number, so every cycle
|
|
404
|
-
the back-off climbs to its ceiling. `npm version` writes the version, commits, and tags in one step, which is
|
|
418
|
+
compares it against the published version. Publish without bumping it and the check reports a newer
|
|
419
|
+
release forever: the installed files keep reading the old number, so every cycle requests the same version
|
|
420
|
+
and the back-off climbs to its ceiling. `npm version` writes the version, commits, and tags in one step, which is
|
|
405
421
|
why the three cannot drift apart:
|
|
406
422
|
|
|
407
423
|
```powershell
|
package/README.zh-CN.md
CHANGED
|
@@ -42,6 +42,16 @@
|
|
|
42
42
|
它**不会**顺手禁用官方的 `tool-bash`:那一行本来就带 `disabled: !!js process.platform === 'win32'`,
|
|
43
43
|
在这里写死 `disabled: true` 会把 Linux 上的官方 bash 一起关掉。
|
|
44
44
|
|
|
45
|
+
**npm registry。** 也在 npm 上:`npm i dsh-pwsh-plugin`,或者让 DSH 装 registry 规格:
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> 注意 pnpm 的 `minimumReleaseAge` 默认 1440 分钟:刚发布的版本当天不会自动解析到,点「更新」会装到上一个版本。这是供应链策略,不是装错。
|
|
52
|
+
|
|
53
|
+
> `npm publish` 之后的头五分钟里,tarball 地址在 CDN 边缘仍返回 **404**(`cache-control: public, max-age=300`),而版本文档和 `dist-tags.latest` 都已经是新值。在这段时间里安装会报 `ERR_PNPM_FETCH_404 … .tgz: Not Found`,看起来像权限问题,其实不是。等满五分钟即可;加 `?nocache=` 能绕过缓存,但 npm 和 pnpm 都不会去取那个地址。
|
|
54
|
+
|
|
45
55
|
**侧栏。** Plugins → Add plugin → `https://github.com/citydirector/dsh-pwsh-plugin`,然后打开开关。
|
|
46
56
|
|
|
47
57
|
**命令行。** `desktop` profile 只能由桌面端自带的 CLI 管:
|
|
@@ -50,8 +60,8 @@
|
|
|
50
60
|
$dsh = '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd'
|
|
51
61
|
$env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
|
|
52
62
|
|
|
53
|
-
# <
|
|
54
|
-
& $dsh plugin --profile desktop add '
|
|
63
|
+
# <version> 是任意已发布版本;最新的那个就是插件自己会装的那个
|
|
64
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
55
65
|
# 或者从本地检出装
|
|
56
66
|
& $dsh plugin --profile desktop add 'file:<检出目录>'
|
|
57
67
|
```
|
|
@@ -62,9 +72,11 @@ $env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
|
|
|
62
72
|
|
|
63
73
|
选 spec 之前要知道两件事:
|
|
64
74
|
|
|
65
|
-
-
|
|
66
|
-
`file:` 直接硬链接检出,改完立刻生效(`fsutil hardlink list`
|
|
67
|
-
里钉死了绝对路径。开发期用 `file:`,长期用
|
|
75
|
+
- **registry 规格是拷贝,`file:` 是硬链接。** `dsh-pwsh-plugin@X.Y.Z` 装的是那次发布的一份拷贝 ——
|
|
76
|
+
改检出不会生效,得重新 `add`。`file:` 直接硬链接检出,改完立刻生效(`fsutil hardlink list` 能看到
|
|
77
|
+
两条路径),代价是 `package.json` 里钉死了绝对路径。开发期用 `file:`,长期用 registry 规格。
|
|
78
|
+
`github:citydirector/dsh-pwsh-plugin#<tag>` 仍然能装,效果与 registry 规格相同(都是拷贝),只是
|
|
79
|
+
绕开了 pnpm 的供应链策略。
|
|
68
80
|
- **bundle 那一行带不了 `?v=N`。** 它的 `name` 必须是包名,才能从 profile 的 `node_modules` 解析,
|
|
69
81
|
因此没有 URL 可变,ESM 继续用它已经缓存的那个模块。改 `node_modules` 下的文件,只有等宿主碰巧跑一次
|
|
70
82
|
重组合才可能被换掉 —— 开发期不能指望这个,用下面那种手写行并把 `?v=N` 提一号,或者重启。
|
|
@@ -115,7 +127,7 @@ $env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
|
|
|
115
127
|
| `maxSessions` | `4` | 同时存活的会话上限(每个 agent 工作区一个)。 |
|
|
116
128
|
| `maxOutputBytes` | `8388608` | 单次调用的输出收集上限(8MB)。截断会明确报出来,不静默。 |
|
|
117
129
|
| `allowUnconfined` | `false` | `true` = 沙箱策略服务缺席时也起会话。 |
|
|
118
|
-
| `updateCheck` | `true` |
|
|
130
|
+
| `updateCheck` | `true` | 定期查有没有新发布的版本。查与装分开:这里只查,装由 `pwsh_run_update` 与自动更新做。 |
|
|
119
131
|
| `autoUpdate` | `true` | 自己装上更新,宿主有 HMR 时**不需要重启**即生效。 |
|
|
120
132
|
| `autoUpdateDelayMs` | `90000` | 自动检查前的等待,避免与启动流程抢 pnpm。 |
|
|
121
133
|
|
|
@@ -148,14 +160,14 @@ session,**静默退回部署默认值**(mode + 配置里的 `workspaceRoot`
|
|
|
148
160
|
|
|
149
161
|
## 更新
|
|
150
162
|
|
|
151
|
-
默认情况下插件自己升级:启动 90
|
|
152
|
-
|
|
163
|
+
默认情况下插件自己升级:启动 90 秒后查一次 npm registry 的 `latest` 标签,之后每 12 小时一次;查到
|
|
164
|
+
更高的版本就直接装上。宿主跑着 DSH 的 HMR 服务时,**装上的那一刻新代码就已经在跑** —— 不用重启,不用弹窗,用户什么
|
|
153
165
|
都不用做。
|
|
154
166
|
|
|
155
167
|
| 方式 | 行为 |
|
|
156
168
|
|---|---|
|
|
157
169
|
| 自动(`autoUpdate: true`,默认) | 查 → 装 → 就地生效。装失败按指数退避重试(5 分钟起、翻倍、封顶 2 小时);装成功过的版本不再重试。**有调用在跑时绝不装** —— 3 分钟后再看。宿主**没有** HMR 时它什么都不装,见下。 |
|
|
158
|
-
| `pwsh_run_update` 的 `action: "check"` |
|
|
170
|
+
| `pwsh_run_update` 的 `action: "check"` | 只读一次 registry,报出 `state` / `running` / `newest release` / `install spec`。不装任何东西。 |
|
|
159
171
|
| `pwsh_run_update` 的 `action: "install"` | 同一条安装路径,只是由人按下去而不是由时钟按下去。有调用在跑时报 `install: postponed`;预检判定「装了也没用」时报 `install: restart-required` / `install: broken-release`(见下)。 |
|
|
160
172
|
|
|
161
173
|
安装走 `ctx.get('pluginManager')` —— 也就是「插件」页面背后那个服务 —— 由它跑 pnpm,失败时回滚
|
|
@@ -197,40 +209,29 @@ session,**静默退回部署默认值**(mode + 配置里的 `workspaceRoot`
|
|
|
197
209
|
|
|
198
210
|
```powershell
|
|
199
211
|
$env:DSH_HOME = '<DSH_HOME>'
|
|
200
|
-
& '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add '
|
|
212
|
+
& '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
201
213
|
```
|
|
202
214
|
|
|
203
215
|
注意 DSH 的默认组合关掉的是**工具** `plugin_manager`,而桌面 profile 上的**服务** `plugin-manager`
|
|
204
216
|
是活的 —— 两者不是一回事,这里只用服务。
|
|
205
217
|
|
|
218
|
+
发布源就是这个包在 npm registry 上的条目:`GET https://registry.npmjs.org/dsh-pwsh-plugin`,请求头带
|
|
219
|
+
`accept: application/vnd.npm.install-v1+json`。它的 `dist-tags.latest` 就是要推的版本,必须是纯
|
|
220
|
+
`X.Y.Z`;被标了 `deprecated` 的 `latest` 永不推送;没有 `dist-tags` 时回落到版本清单里的最高正式版。
|
|
221
|
+
返回 `404` 时报的是「not published on <registry>」而不是「没有新版本」—— 这两件事不同,只有一件是临时的。
|
|
206
222
|
|
|
207
|
-
|
|
208
|
-
默认发布源是 `github:citydirector/dsh-pwsh-plugin`,读 `GET /repos/<repo>/tags`。标签必须匹配
|
|
209
|
-
`^v\d+\.\d+\.\d+$`(预发布永不推送),取版本号最大的那个。
|
|
223
|
+
安装规格用 registry 形式 `dsh-pwsh-plugin@X.Y.Z`,所以插件请求的字符串与 `dsh plugin add` 接受的是同一个。
|
|
210
224
|
|
|
211
225
|
| 环境变量 | 作用 |
|
|
212
226
|
|---|---|
|
|
213
|
-
| `
|
|
214
|
-
| `DSH_PWSH_UPDATE_API` | GitHub API 基址,默认 `https://api.github.com` |
|
|
227
|
+
| `DSH_PWSH_UPDATE_REGISTRY` | registry 基址,默认 `https://registry.npmjs.org`。可指向镜像,或指向本机服务做预演 |
|
|
215
228
|
| `DSH_PWSH_UPDATE_DELAY_MS` | 首次检查延迟,默认 5000 |
|
|
216
229
|
|
|
217
|
-
本地预演:先建一个带 tag 的仓库,再把源指过去 ——
|
|
218
|
-
|
|
219
230
|
```powershell
|
|
220
|
-
$
|
|
221
|
-
git init -q -b main $root
|
|
222
|
-
git -C $root -c user.email=t@l -c user.name=t commit -q --allow-empty -m release
|
|
223
|
-
git -C $root tag v0.2.1
|
|
224
|
-
$env:DSH_PWSH_UPDATE_SOURCE = 'git+file:///' + ($root -replace '\\','/')
|
|
225
|
-
node -e "import('./plugin/update-check.mjs').then(async (m) => console.log(JSON.stringify(await new m.UpdateChecker({ version: '0.1.0' }).check())))"
|
|
226
|
-
# → {"state":"available","latest":"v0.2.1","checkedAt":"..."}
|
|
227
|
-
Remove-Item -Recurse -Force $root
|
|
231
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
228
232
|
```
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
`export DSH_PWSH_UPDATE_SOURCE=...` 传不进 Windows 进程 —— WSL 只透传 `WSLENV` 里点名过的变量,其余
|
|
232
|
-
`DSH_*` 到子进程就消失,表现是「明明设了源,却去查默认的 GitHub 仓库」。真要跨过去得写
|
|
233
|
-
`WSLENV=DSH_PWSH_UPDATE_SOURCE`;**别加 `/p`**,那会把 `/tmp/...` 转成 `\\wsl.localhost\...` 路径,源就废了。
|
|
234
|
+
手动安装与插件自己做的事是同一条路径,同样由 pnpm 施加同样的策略。
|
|
234
235
|
|
|
235
236
|
查询失败不会抛进工具调用:记 `state: 'failed'`,经 `ctx.logger` 留一行,先前查到过的 `latest` 保留成
|
|
236
237
|
`stale: 'available'` —— 一次网络抖动不该让已知的新版本消失。
|
|
@@ -314,7 +315,7 @@ plugin/
|
|
|
314
315
|
index.mjs 工具注册、沙箱接线、超时
|
|
315
316
|
session.mjs 会话对象:分帧、队列、base64、启动 argv
|
|
316
317
|
session-server.ps1 PowerShell 侧:runspace、流打标、哨兵
|
|
317
|
-
update-check.mjs
|
|
318
|
+
update-check.mjs registry 查询与版本比较
|
|
318
319
|
update-apply.mjs 把钉住的规格交给 pluginManager,不自己下载
|
|
319
320
|
auto-update.mjs 自动路径:在 HMR 事务之外安装,失败退避
|
|
320
321
|
test/
|
|
@@ -353,14 +354,21 @@ node test/denialtest.mjs # 11:沙箱拒绝提示(不起
|
|
|
353
354
|
node test/metatest.mjs # 23:插件页元数据(不起子进程,哪儿都能跑)
|
|
354
355
|
```
|
|
355
356
|
|
|
356
|
-
十一份脚本合计 **
|
|
357
|
+
十一份脚本合计 **305 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
|
|
357
358
|
`node_modules` 的 DSH 安装。需要 DSH 运行时的测试会自动找(`DSH_MODULES`、`DSH_MODULES_ASAR`,
|
|
358
359
|
或从工作目录逐级向上),找不到时打一行 `SKIP` 提示而不是失败。
|
|
359
360
|
|
|
360
361
|
### 发版
|
|
361
362
|
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
从 tag 发布由 [`.github/workflows/release.yml`](.github/workflows/release.yml) 负责:先跑
|
|
364
|
+
`scripts/release-check.mjs`,再用 npm 的 **trusted publishing** 执行 `npm publish --provenance` ——
|
|
365
|
+
runner 用自己那份短命的 GitHub 身份换一个只对「这个仓库 + 这个 workflow 文件」有效的令牌。仓库里没有
|
|
366
|
+
`NPM_TOKEN`,也不该加。两个连带后果值得记住:workflow 的文件名是信任契约的一部分(改名后每次发布都会
|
|
367
|
+
403,直到在 npm 上改设置);provenance 在那里是强制的,所以 tarball 会带上「由哪个提交构建」的签名声明。
|
|
368
|
+
|
|
369
|
+
`package.json` 里的 version 是本仓库唯一写死的东西,而更新检查拿它跟 registry 上的已发布版本比。
|
|
370
|
+
不升版本就发版,检查会永远报有新版:装上去的文件读到的还是旧数字,于是每轮都请求同一个版本,
|
|
371
|
+
退避一路爬到上限。
|
|
364
372
|
`npm version` 一次完成「改版本 + 提交 + 打 annotated tag」,所以这三者不会漂开:
|
|
365
373
|
|
|
366
374
|
```powershell
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pwsh-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persistent pwsh session tool (pwsh_run) for DeepSeek Harness: run PowerShell scripts with zero escaping, no 32K command-line cap, and state that survives across calls.",
|
|
6
6
|
"icon": "./icon.svg",
|
package/plugin/auto-update.mjs
CHANGED
|
@@ -23,9 +23,19 @@
|
|
|
23
23
|
* 若此刻正有一条调用在跑,它会连带被掐断——脚本跑到一半、半截输出、会话状态全丢,
|
|
24
24
|
* 与直接断电没有区别。所以安装前要问宿主「现在安静吗」,忙就整个推迟,
|
|
25
25
|
* 而不是装完再说「可惜刚才那条被打断了」。
|
|
26
|
+
*
|
|
27
|
+
* 为什么要记住「上一次实际装到了哪个版本」
|
|
28
|
+
* pnpm 的 minimumReleaseAge 会让「请求 0.2.13、装到 0.2.12」成为正常结果。若不留这个记忆,
|
|
29
|
+
* 下一轮还会请求 0.2.13、还会落回 0.2.12,被 plugin-manager 判 already-installed,
|
|
30
|
+
* 退避爬到上限,永远如此。记住之后就能认出「这是冷却期,不是故障」,直接等到期。
|
|
31
|
+
*
|
|
32
|
+
* 为什么冷却期落地之后要把目标版本从「装过」里撤出来
|
|
33
|
+
* 落到更旧的版本算成功,但它不是这次的目标:目标自己还要等窗口过去。若不当场撤掉,
|
|
34
|
+
* 这个版本就再也不会被尝试,等于把这个进程的生命周期钉死在旧版本上。
|
|
26
35
|
*/
|
|
27
36
|
|
|
28
37
|
import { installRelease, preflightInstall } from './update-apply.mjs'
|
|
38
|
+
import { compareVersions } from './update-check.mjs'
|
|
29
39
|
|
|
30
40
|
/** 首次检查前的等待:让启动流程先跑完,别跟它抢 pnpm。 */
|
|
31
41
|
const FIRST_DELAY_MS = 90 * 1000
|
|
@@ -35,24 +45,29 @@ const RETRY_BASE_MS = 5 * 60 * 1000
|
|
|
35
45
|
const RETRY_MAX_MS = 2 * 60 * 60 * 1000
|
|
36
46
|
/** 查出有新版但有调用正跑着:过一会儿再看一眼,别等到下一轮 12 小时。 */
|
|
37
47
|
const BUSY_RETRY_MS = 3 * 60 * 1000
|
|
48
|
+
/** 冷却期:pnpm 的窗口默认 24 小时,隔一小时再看一眼不浪费什么。 */
|
|
49
|
+
const COOLDOWN_RETRY_MS = 60 * 60 * 1000
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
52
|
* 在 HMR 事务之外装一个发布。
|
|
41
53
|
*
|
|
42
54
|
* @param ctx - 插件上下文,用来取 pluginManager 与 hmr。
|
|
43
|
-
* @param spec -
|
|
55
|
+
* @param spec - 钉住版本号的安装规格,例如 dsh-pwsh-plugin@0.2.13。
|
|
44
56
|
* @param options - { requestId, log }。
|
|
45
57
|
* @returns 与 installRelease 相同的结果对象,永不抛出。
|
|
46
58
|
*/
|
|
47
59
|
export async function installReleaseLive(ctx, spec, options = {}) {
|
|
48
60
|
const manager = ctx?.get?.('pluginManager')
|
|
49
61
|
const hmr = ctx?.get?.('hmr')
|
|
62
|
+
// 回读装完的版本需要 profile 目录;读不到就只报 application,不编一个版本号出来。
|
|
63
|
+
const profileDir = ctx?.get?.('profileContext')?.dir
|
|
64
|
+
const base = { ...options, ...(typeof profileDir === 'string' ? { profileDir } : {}) }
|
|
50
65
|
if (hmr === undefined || typeof hmr?.executing?.run !== 'function') {
|
|
51
|
-
return await installRelease(manager, spec, { ...
|
|
66
|
+
return await installRelease(manager, spec, { ...base, live: false })
|
|
52
67
|
}
|
|
53
68
|
// 上下文清空后 installBundle 自己会拿 runExclusive;装完 HMR 重组合,
|
|
54
69
|
// 本插件的模块被驱逐、新模块 import 进来重新 apply。
|
|
55
|
-
return await hmr.executing.run(undefined, () => installRelease(manager, spec, { ...
|
|
70
|
+
return await hmr.executing.run(undefined, () => installRelease(manager, spec, { ...base, live: true }))
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/**
|
|
@@ -72,7 +87,7 @@ function hmrIsLive(ctx) {
|
|
|
72
87
|
*
|
|
73
88
|
* @param ctx - 插件上下文。
|
|
74
89
|
* @param checker - UpdateChecker 实例;它的 check() 重入安全,直接复用。
|
|
75
|
-
* @param options - { log, firstDelayMs, intervalMs, retryBaseMs, retryMaxMs }。
|
|
90
|
+
* @param options - { log, firstDelayMs, intervalMs, retryBaseMs, retryMaxMs, cooldownRetryMs }。
|
|
76
91
|
* @returns 停止函数;插件被卸载或热替换时由调用方停掉。
|
|
77
92
|
*/
|
|
78
93
|
export function startAutoUpdate(ctx, checker, options = {}) {
|
|
@@ -81,12 +96,15 @@ export function startAutoUpdate(ctx, checker, options = {}) {
|
|
|
81
96
|
const intervalMs = options.intervalMs ?? INTERVAL_MS
|
|
82
97
|
const retryBaseMs = options.retryBaseMs ?? RETRY_BASE_MS
|
|
83
98
|
const retryMaxMs = options.retryMaxMs ?? RETRY_MAX_MS
|
|
99
|
+
const cooldownRetryMs = options.cooldownRetryMs ?? COOLDOWN_RETRY_MS
|
|
84
100
|
|
|
85
101
|
const installGate = options.installGate
|
|
86
102
|
const busyRetryMs = options.busyRetryMs ?? BUSY_RETRY_MS
|
|
87
103
|
|
|
88
104
|
/** 已经自动装过的版本:同一个版本装成功过就不再装第二次。 */
|
|
89
105
|
const attempted = new Set()
|
|
106
|
+
/** 上一次安装实际落到的版本:冷却期会让它与请求的版本不同,用来认出「不是故障」。 */
|
|
107
|
+
let lastInstalled
|
|
90
108
|
let timer
|
|
91
109
|
let stopped = false
|
|
92
110
|
let running = false
|
|
@@ -116,15 +134,20 @@ export function startAutoUpdate(ctx, checker, options = {}) {
|
|
|
116
134
|
const spec = doc.spec
|
|
117
135
|
if (typeof spec === 'string' && spec.length > 0) {
|
|
118
136
|
// 装之前先问一句值不值得装,否则 already-installed 会以「失败」的姿态出现,
|
|
119
|
-
//
|
|
120
|
-
const unnecessary = preflightInstall(ctx, checker, spec)
|
|
137
|
+
// 而它其实有几种含义完全不同的成因(别人装好了 / 发布忘了升版本 / 冷却期未过)。
|
|
138
|
+
const unnecessary = preflightInstall(ctx, checker, spec, lastInstalled === undefined ? {} : { cooled: lastInstalled })
|
|
121
139
|
if (unnecessary !== null) {
|
|
122
140
|
busyNoted = false
|
|
123
|
-
//
|
|
124
|
-
//
|
|
141
|
+
// 记下这个版本,别再排下一轮:这几种成因都不会在几分钟内自己好转。
|
|
142
|
+
// 冷却期例外——它是会到期的,所以单独按小时再看。
|
|
125
143
|
attempted.add(state.latest)
|
|
126
144
|
log('auto update: ' + state.latest + ' — ' + unnecessary.outcome + ': ' + unnecessary.text)
|
|
127
|
-
if (unnecessary.
|
|
145
|
+
if (unnecessary.outcome === 'cooling-down') {
|
|
146
|
+
attempted.delete(state.latest)
|
|
147
|
+
next = cooldownRetryMs
|
|
148
|
+
} else if (unnecessary.ok === true) {
|
|
149
|
+
failures = 0
|
|
150
|
+
}
|
|
128
151
|
return
|
|
129
152
|
}
|
|
130
153
|
busyNoted = false
|
|
@@ -132,6 +155,15 @@ export function startAutoUpdate(ctx, checker, options = {}) {
|
|
|
132
155
|
log('auto update: installing ' + state.latest + ' from ' + spec)
|
|
133
156
|
const outcome = await installReleaseLive(ctx, spec, { requestId: 'pwsh-run-auto-update', log })
|
|
134
157
|
log('auto update: ' + outcome.outcome + ' — ' + outcome.text)
|
|
158
|
+
if (outcome.installedVersion !== undefined) {
|
|
159
|
+
lastInstalled = outcome.installedVersion
|
|
160
|
+
// 落到更旧的版本是 pnpm 冷却期的正常结果,但目标版本还在窗口里:
|
|
161
|
+
// 撤掉这条记录,过一小时(窗口是 24 小时)再看,而不是等满 12 小时。
|
|
162
|
+
if (compareVersions(outcome.installedVersion, state.latest) < 0) {
|
|
163
|
+
attempted.delete(state.latest)
|
|
164
|
+
next = cooldownRetryMs
|
|
165
|
+
}
|
|
166
|
+
}
|
|
135
167
|
if (outcome.ok !== true) {
|
|
136
168
|
// 装失败要能重来:把记录撤掉,交给退避后的下一轮。
|
|
137
169
|
attempted.delete(state.latest)
|
|
@@ -175,4 +207,4 @@ export function startAutoUpdate(ctx, checker, options = {}) {
|
|
|
175
207
|
stopped = true
|
|
176
208
|
clearTimeout(timer)
|
|
177
209
|
}
|
|
178
|
-
}
|
|
210
|
+
}
|
package/plugin/index.mjs
CHANGED
|
@@ -316,7 +316,7 @@ export function apply(ctx, config) {
|
|
|
316
316
|
sessions.clear()
|
|
317
317
|
}, 'pwsh-run.sessions')
|
|
318
318
|
|
|
319
|
-
//
|
|
319
|
+
// 更新检查:只读 npm registry 的 dist-tags、只记判决,从不自己安装。
|
|
320
320
|
// 发现新版本时不改工具描述(描述在注册时就固定了),而是把提示挂在每次调用的
|
|
321
321
|
// 输出末尾一次——够模型看见,也不至于每次刷。
|
|
322
322
|
const checker = updateCheck
|
|
@@ -366,10 +366,10 @@ export function apply(ctx, config) {
|
|
|
366
366
|
name: 'pwsh_run_update',
|
|
367
367
|
description: [
|
|
368
368
|
'Check for, and optionally install, a newer release of the pwsh_run plugin.',
|
|
369
|
-
'* action "check" reads the
|
|
370
|
-
'* action "install" hands the pinned
|
|
369
|
+
'* action "check" reads the npm registry dist-tags once and reports the verdict; it never installs anything.',
|
|
370
|
+
'* action "install" hands the pinned version spec (<package>@X.Y.Z) to DSH\'s own plugin manager (the same service the Plugins page uses), which runs pnpm and rolls back on failure.',
|
|
371
371
|
'* Installing writes the package to the profile. Where the host runs its HMR service, the new code is already live when this returns; otherwise the running process keeps the old modules until DSH restarts. The result says which of the two happened.',
|
|
372
|
-
'* Only
|
|
372
|
+
'* Only published stable versions are considered; pre-releases are never pushed. The version actually installed is read back from disk, because pnpm\'s minimumReleaseAge policy can install an older release than the one requested.',
|
|
373
373
|
'* An install replaces this plugin in place, which terminates the persistent pwsh sessions. It is therefore refused while a pwsh_run call is running; retry after that call finishes (the automatic updater applies the same rule on its own).',
|
|
374
374
|
].join('\n'),
|
|
375
375
|
parameters: {
|
|
@@ -413,8 +413,8 @@ export function apply(ctx, config) {
|
|
|
413
413
|
}
|
|
414
414
|
else {
|
|
415
415
|
// 这条分支上 doc.spec 是 undefined,插值只会印出一个字面占位符,所以给模板。
|
|
416
|
-
lines.push('Could not reach the
|
|
417
|
-
+ 'Run the check again later, or upgrade by hand: dsh plugin --profile <name> add "
|
|
416
|
+
lines.push('Could not reach the npm registry, so whether a newer version exists is unknown. '
|
|
417
|
+
+ 'Run the check again later, or upgrade by hand: dsh plugin --profile <name> add "<package>@X.Y.Z"')
|
|
418
418
|
}
|
|
419
419
|
return { text: lines.join('\n') }
|
|
420
420
|
}
|
|
@@ -446,6 +446,9 @@ export function apply(ctx, config) {
|
|
|
446
446
|
}
|
|
447
447
|
lines.push('install: ' + outcome.outcome)
|
|
448
448
|
lines.push(outcome.text)
|
|
449
|
+
if (outcome.installedVersion !== undefined && outcome.installedVersion !== doc.latest) {
|
|
450
|
+
lines.push('Installed version on disk: ' + outcome.installedVersion + ' (requested ' + String(doc.latest) + ').')
|
|
451
|
+
}
|
|
449
452
|
if (outcome.ok !== true && doc.spec !== undefined) lines.push('Manual path: dsh plugin --profile <name> add "' + String(doc.spec) + '"')
|
|
450
453
|
return { text: lines.join('\n') }
|
|
451
454
|
},
|
package/plugin/update-apply.mjs
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* 踩到脏状态。所以安装这一步交给 dsh-plugin-manager(Plugins 页面用的同一个服务),
|
|
10
10
|
* 它自己跑 pnpm、自己回滚 package.json / lockfile。
|
|
11
11
|
*
|
|
12
|
+
* 为什么装完必须回读磁盘版本
|
|
13
|
+
* pnpm 有供应链冷却期(minimumReleaseAge,v11 起默认 1440 分钟)。请求 dsh-pwsh-plugin@0.2.13
|
|
14
|
+
* 而该版本刚发布几小时,pnpm 会装到它能接受的那个版本;installBundle 的返回值里没有版本号,
|
|
15
|
+
* 只有 application 这个枚举。所以「装了什么」只能从装完的 package.json 读回来 ——
|
|
16
|
+
* 把这里的判决当成既成事实去宣称,就是在说一句没人验证过的话。
|
|
17
|
+
*
|
|
12
18
|
* 能不能不重启
|
|
13
19
|
* bundle 行的 name 是包名,拿不到 URL 上的缓存破坏参数,ESM 按 URL 缓存模块 —— 光重新 apply
|
|
14
20
|
* 只是再 import 同一个 URL。但 installBundle 走 HMR 的 runExclusive,重组合会比对文件指纹
|
|
@@ -20,7 +26,7 @@
|
|
|
20
26
|
import { readFileSync } from 'node:fs'
|
|
21
27
|
import { join } from 'node:path'
|
|
22
28
|
|
|
23
|
-
import { packageVersion } from './update-check.mjs'
|
|
29
|
+
import { compareVersions, packageVersion } from './update-check.mjs'
|
|
24
30
|
|
|
25
31
|
/** 本包的包名:profile 的 dependencies 用它记录安装规格。 */
|
|
26
32
|
const OWN_NAME = (() => {
|
|
@@ -33,9 +39,37 @@ const OWN_NAME = (() => {
|
|
|
33
39
|
})()
|
|
34
40
|
|
|
35
41
|
/** `v1.2.3` 与 `1.2.3` 是同一个版本;不是这个形态时返回 undefined。 */
|
|
36
|
-
function bareVersion(value) {
|
|
42
|
+
export function bareVersion(value) {
|
|
37
43
|
const text = String(value ?? '').trim().replace(/^v/i, '')
|
|
38
|
-
return /^\d+\.\d+\.\d
|
|
44
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 安装规格末尾钉的版本:`dsh-pwsh-plugin@1.2.3` → `1.2.3`。
|
|
49
|
+
*
|
|
50
|
+
* 只认包名之后的那一段:npm 规格里 @ 既是 scope 分隔符也是版本分隔符,
|
|
51
|
+
* 所以取**最后一个** @ 之后的部分,并对 `@scope/name@1.2.3` 同样成立。
|
|
52
|
+
*/
|
|
53
|
+
export function specVersion(spec) {
|
|
54
|
+
const text = typeof spec === 'string' ? spec.trim() : ''
|
|
55
|
+
const at = text.lastIndexOf('@')
|
|
56
|
+
if (at <= 0) return undefined
|
|
57
|
+
return bareVersion(text.slice(at + 1))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从一份 package.json 里读版本号。
|
|
62
|
+
*
|
|
63
|
+
* @param path - package.json 的路径。
|
|
64
|
+
* @returns 版本号;文件不存在、坏 JSON、没有 version 字段时返回 undefined。
|
|
65
|
+
*/
|
|
66
|
+
export function readVersionAt(path) {
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
|
69
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
39
73
|
}
|
|
40
74
|
|
|
41
75
|
/**
|
|
@@ -61,16 +95,18 @@ export function readInstalledSpec(profileDir, packageName = OWN_NAME) {
|
|
|
61
95
|
*
|
|
62
96
|
* 为什么要有它
|
|
63
97
|
* dsh-plugin-manager 在 HMR 在场的宿主上,对「规格与 profile 里已记录的完全相同」的安装会在
|
|
64
|
-
* pnpm 之前直接抛 already-installed
|
|
98
|
+
* pnpm 之前直接抛 already-installed。有三种情形会撞上,而它们都不该真去装一次:
|
|
65
99
|
* 1. 别人(终端 dsh plugin add、Plugins 页面)已经装好了,只是进程还没重启;
|
|
66
100
|
* 2. 发版时打了 tag 却忘了改 package.json 的 version —— 装的永远是同一个包内版本,
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
101
|
+
* 于是每一轮都「有新版、装不进去」,静默循环到天荒地老;
|
|
102
|
+
* 3. pnpm 的冷却期让这次安装只会落到已经装好的那个版本上(见下面的 cooled 参数)。
|
|
103
|
+
* 都能用手里已有的事实判掉,判掉之后要能说清是哪一种。
|
|
70
104
|
*
|
|
71
|
-
* @param options - `{ spec, latest, disk, running, installed }`。
|
|
105
|
+
* @param options - `{ spec, latest, disk, running, installed, cooled }`。
|
|
72
106
|
* `disk` 缺省时现读本包 package.json;`running` 缺省时不比对运行版本。
|
|
73
|
-
*
|
|
107
|
+
* `cooled` 是「上一次安装实际落到的版本」——冷却期让这次不可能装到 latest 时,
|
|
108
|
+
* 调用方把它传进来,这里就不再发起注定落空的安装。
|
|
109
|
+
* @returns 该装时返回 null;否则返回 `{ ok, outcome, text }`——几种情形成因不同,都不该重试。
|
|
74
110
|
*/
|
|
75
111
|
export function checkInstallNeed(options = {}) {
|
|
76
112
|
const latest = bareVersion(options.latest)
|
|
@@ -81,6 +117,7 @@ export function checkInstallNeed(options = {}) {
|
|
|
81
117
|
const running = bareVersion(options.running)
|
|
82
118
|
const spec = typeof options.spec === 'string' ? options.spec.trim() : undefined
|
|
83
119
|
const installed = typeof options.installed === 'string' ? options.installed.trim() : undefined
|
|
120
|
+
const cooled = bareVersion(options.cooled)
|
|
84
121
|
|
|
85
122
|
if (disk === latest) {
|
|
86
123
|
// 磁盘上已经是这个版本了,再装一次要么被拒,要么装个同样的东西。
|
|
@@ -103,25 +140,30 @@ export function checkInstallNeed(options = {}) {
|
|
|
103
140
|
// 读不到包内版本时不做这个判断:那是「本插件自己的 package.json 有问题」,不是发布的问题,
|
|
104
141
|
// 按未知处理比按坏发布处理安全。
|
|
105
142
|
if (disk !== undefined && spec !== undefined && spec.length > 0 && installed === spec) {
|
|
106
|
-
// profile
|
|
143
|
+
// profile 已经指向这个版本,而磁盘上的包仍报别的版本:发布本身坏了。
|
|
107
144
|
return {
|
|
108
145
|
ok: false,
|
|
109
146
|
outcome: 'broken-release',
|
|
110
|
-
text: 'The release
|
|
111
|
-
+ ' in its package.json, and the profile already points at that
|
|
112
|
-
+ 'refused as already-installed and could never change anything. This release is broken:
|
|
113
|
-
+ '
|
|
147
|
+
text: 'The release ' + latest + ' still declares version ' + disk
|
|
148
|
+
+ ' in its package.json, and the profile already points at that version. Every install attempt would be '
|
|
149
|
+
+ 'refused as already-installed and could never change anything. This release is broken: it was published '
|
|
150
|
+
+ 'without bumping package.json.',
|
|
114
151
|
}
|
|
115
152
|
}
|
|
116
153
|
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
// 冷却期:上一次装同一个包时落在 cooled 上,而这次的目标比它新 —— pnpm 会重演同一个决定。
|
|
155
|
+
// 说清是策略在拦,并给出越过它的唯一官方办法(显式指定版本仍会被拦,所以要靠卸载重装)。
|
|
156
|
+
if (cooled !== undefined && compareVersions(cooled, latest) < 0 && compareVersions(cooled, running ?? disk ?? '0.0.0') >= 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
outcome: 'cooling-down',
|
|
160
|
+
text: 'pnpm\'s supply-chain policy (minimumReleaseAge) refuses releases younger than its window, so ' + latest
|
|
161
|
+
+ ' cannot be installed yet; the previous install landed on ' + cooled + '. Wait for the window to pass '
|
|
162
|
+
+ '(24 hours by default from the publish time), or install it by hand after disabling the policy.',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
119
165
|
|
|
120
|
-
|
|
121
|
-
export function specTag(spec) {
|
|
122
|
-
const text = typeof spec === 'string' ? spec.trim() : ''
|
|
123
|
-
const hash = text.lastIndexOf('#')
|
|
124
|
-
return hash === -1 ? undefined : text.slice(hash + 1)
|
|
166
|
+
return null
|
|
125
167
|
}
|
|
126
168
|
|
|
127
169
|
/**
|
|
@@ -129,16 +171,18 @@ export function specTag(spec) {
|
|
|
129
171
|
*
|
|
130
172
|
* @param ctx - 插件上下文;profileContext 缺席时读不到已装规格(预检只剩版本比对)。
|
|
131
173
|
* @param checker - UpdateChecker 实例,用来取进程里在跑的版本。
|
|
132
|
-
* @param spec -
|
|
174
|
+
* @param spec - 要装的规格,末尾钉着版本号。
|
|
175
|
+
* @param options - `{ cooled }`:上一次安装实际落到的版本。
|
|
133
176
|
* @returns checkInstallNeed 的结果:null 表示该装。
|
|
134
177
|
*/
|
|
135
|
-
export function preflightInstall(ctx, checker, spec) {
|
|
178
|
+
export function preflightInstall(ctx, checker, spec, options = {}) {
|
|
136
179
|
const profileDir = ctx?.get?.('profileContext')?.dir
|
|
137
180
|
return checkInstallNeed({
|
|
138
181
|
spec,
|
|
139
|
-
latest:
|
|
182
|
+
latest: specVersion(spec),
|
|
140
183
|
running: checker?.version,
|
|
141
184
|
installed: readInstalledSpec(profileDir),
|
|
185
|
+
...(options.cooled === undefined ? {} : { cooled: options.cooled }),
|
|
142
186
|
})
|
|
143
187
|
}
|
|
144
188
|
|
|
@@ -184,12 +228,13 @@ export function describeInstallOutcome(result, options = {}) {
|
|
|
184
228
|
}
|
|
185
229
|
|
|
186
230
|
/**
|
|
187
|
-
*
|
|
231
|
+
* 装一个钉住版本号的规格,然后回读磁盘上真正落地的版本。
|
|
188
232
|
*
|
|
189
233
|
* @param manager - ctx.get('pluginManager');缺席时传 null。
|
|
190
|
-
* @param spec - 例如 `
|
|
191
|
-
* @param options - `{ requestId, signal, log }`。
|
|
192
|
-
*
|
|
234
|
+
* @param spec - 例如 `dsh-pwsh-plugin@0.2.13`。
|
|
235
|
+
* @param options - `{ requestId, signal, log, live, profileDir }`。
|
|
236
|
+
* `profileDir` 用来回读装完后的 package.json —— 冷却期会让实际装到的版本比 spec 旧。
|
|
237
|
+
* @returns `{ ok, outcome, text, error, installedVersion }`,永不抛出:安装失败是正常结果,不是程序缺陷。
|
|
193
238
|
*/
|
|
194
239
|
export async function installRelease(manager, spec, options = {}) {
|
|
195
240
|
if (manager === null || manager === undefined) {
|
|
@@ -204,11 +249,22 @@ export async function installRelease(manager, spec, options = {}) {
|
|
|
204
249
|
}
|
|
205
250
|
try {
|
|
206
251
|
const result = await manager.installBundle(spec, options.requestId === undefined ? {} : { requestId: options.requestId })
|
|
252
|
+
// 返回值里没有版本号:装了哪个版本只能从装完的磁盘读回来。冷却期会让它与 spec 不同,
|
|
253
|
+
// 而「说装好了」这句话必须对着磁盘上的事实说。
|
|
254
|
+
const installedVersion = typeof options.profileDir === 'string' && options.profileDir.length > 0
|
|
255
|
+
? readVersionAt(join(options.profileDir, 'node_modules', OWN_NAME, 'package.json'))
|
|
256
|
+
: undefined
|
|
207
257
|
const described = describeInstallOutcome(result, { live: options.live === true })
|
|
258
|
+
const text = installedVersion !== undefined && described.ok === true && compareVersions(installedVersion, specVersion(spec) ?? '0') < 0
|
|
259
|
+
// 装到了别的版本:pnpm 的冷却期在起作用。必须说出来,否则用户会以为升级成功了。
|
|
260
|
+
? described.text + ' pnpm installed ' + installedVersion + ' rather than ' + String(specVersion(spec))
|
|
261
|
+
+ ': its minimumReleaseAge policy withholds versions that are too recent.'
|
|
262
|
+
: described.text
|
|
208
263
|
return {
|
|
209
264
|
ok: described.ok,
|
|
210
265
|
outcome: typeof result?.application === 'string' ? result.application : 'unknown',
|
|
211
|
-
text
|
|
266
|
+
text,
|
|
267
|
+
...(installedVersion === undefined ? {} : { installedVersion }),
|
|
212
268
|
...(result?.error === undefined ? {} : { error: result.error }),
|
|
213
269
|
}
|
|
214
270
|
} catch (error) {
|
package/plugin/update-check.mjs
CHANGED
|
@@ -3,136 +3,149 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 为什么单独一个模块
|
|
5
5
|
* 这个插件是纯宿主平面的工具插件,没有配置页面,也没有 client 入口。
|
|
6
|
-
*
|
|
6
|
+
* 把「读发布源 / 比版本 / 记状态」这几件事独立出来,就能在纯 node 里
|
|
7
7
|
* 完整单测,不必起一个 DSH。
|
|
8
8
|
*
|
|
9
|
+
* 发布源是 npm registry,不是 git 标签
|
|
10
|
+
* 早期版本读 GitHub 的 vX.Y.Z 标签,安装规格是 github:owner/repo#vX.Y.Z。
|
|
11
|
+
* 改到 registry 之后同一份发布既是 npm 包、也是 profile 里能被 pnpm 解析的依赖,
|
|
12
|
+
* 「装到的是哪个版本」由 pnpm 说了算,插件不必自己拼 git 规格。
|
|
13
|
+
* 更要紧的一条:pnpm 有供应链冷却期(minimumReleaseAge,默认 1440 分钟),
|
|
14
|
+
* 当天发布的版本当天可能解析不到 —— 于是「最新版」与「实际装到的版本」不是一回事。
|
|
15
|
+
* 检查阶段只认 registry 的 dist-tags,安装之后必须回读磁盘上的版本,
|
|
16
|
+
* 拿这里的判决去宣称装好了就是在说一句没人验证过的话。
|
|
17
|
+
*
|
|
9
18
|
* 它做什么
|
|
10
|
-
*
|
|
11
|
-
* 与 package.json
|
|
12
|
-
*
|
|
13
|
-
* 由外部去装。一个 tag 一个规格,所以装新版本就是盖掉旧的,不需要额外逻辑。
|
|
14
|
-
* 这里自己不动手:本模块只负责「读标签 → 比版本 → 记状态」,装与不装是另一个决定,
|
|
15
|
-
* 由 pwsh_run_update 工具按用户的显式动作去做(见 update-apply.mjs)。
|
|
19
|
+
* 启动几秒后读一次 registry 的 dist-tags.latest,之后大约每 12 小时再读一次,
|
|
20
|
+
* 与 package.json 里的版本比较。**从不自己安装**:发现新版本时只把版本号与
|
|
21
|
+
* 安装规格(<package>@X.Y.Z)记下来。一个版本一个规格,所以装新版本就是盖掉旧的。
|
|
16
22
|
*
|
|
17
23
|
* 发布源可覆盖
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* DSH_PWSH_UPDATE_API 覆盖 GitHub API 基址,DSH_PWSH_UPDATE_DELAY_MS
|
|
21
|
-
* 覆盖首次检查的延迟(测试用)。
|
|
24
|
+
* DSH_PWSH_UPDATE_REGISTRY 覆盖 registry 基址(测试与自建镜像靠它)。
|
|
25
|
+
* DSH_PWSH_UPDATE_DELAY_MS 覆盖首次检查的延迟(测试用)。
|
|
22
26
|
*/
|
|
23
27
|
|
|
24
|
-
import { execFile } from 'node:child_process'
|
|
25
28
|
import { readFileSync } from 'node:fs'
|
|
26
29
|
|
|
27
|
-
/**
|
|
28
|
-
export const
|
|
30
|
+
/** 发布在哪:这个包名在 npm registry 上的 dist-tags 就是发布。 */
|
|
31
|
+
export const UPDATE_PACKAGE = 'dsh-pwsh-plugin'
|
|
32
|
+
|
|
33
|
+
/** 默认 registry。 */
|
|
34
|
+
export const UPDATE_REGISTRY = 'https://registry.npmjs.org'
|
|
29
35
|
|
|
30
36
|
/** 启动后先让启动流程跑完,再查更新。 */
|
|
31
37
|
const UPDATE_DELAY_MS = 5000
|
|
32
38
|
const UPDATE_INTERVAL_MS = 12 * 60 * 60 * 1000
|
|
33
39
|
|
|
34
|
-
/**
|
|
35
|
-
const
|
|
40
|
+
/** 三段数字,可选预发布尾巴;npm 的版本号允许的形态。 */
|
|
41
|
+
const RELEASE_VERSION = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
|
-
*
|
|
44
|
+
* 读一个版本号。
|
|
39
45
|
*
|
|
40
|
-
* @param
|
|
41
|
-
* @returns `[major, minor, patch]
|
|
46
|
+
* @param value - 版本号,可带 v 前缀与预发布尾巴(1.2.3 / v1.2.3 / 1.2.3-rc.1)。
|
|
47
|
+
* @returns `{ parts: [major, minor, patch], prerelease }`;不是版本形态时返回 undefined。
|
|
42
48
|
*/
|
|
43
|
-
export function
|
|
44
|
-
const match =
|
|
45
|
-
|
|
49
|
+
export function parseVersion(value) {
|
|
50
|
+
const match = RELEASE_VERSION.exec(String(value ?? '').trim())
|
|
51
|
+
if (match === null) return undefined
|
|
52
|
+
const prerelease = match[4] === undefined ? undefined : match[4]
|
|
53
|
+
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease }
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/**
|
|
49
57
|
* 比两个版本,写 X.Y.Z 或 vX.Y.Z 都行。
|
|
50
58
|
*
|
|
59
|
+
* 预发布(1.2.3-rc.1)按 semver 的规矩排在对应的正式版**之下**:
|
|
60
|
+
* 否则一个 rc 会被当成「比 1.2.3 还新」,自动更新就会把用户推到预发布上。
|
|
61
|
+
*
|
|
51
62
|
* @returns 像排序比较器那样返回负数、零或正数;解析不出来的版本排在所有能解析的之下。
|
|
52
63
|
*/
|
|
53
64
|
export function compareVersions(a, b) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const pb = parseReleaseTag(tag(b))
|
|
65
|
+
const pa = parseVersion(a)
|
|
66
|
+
const pb = parseVersion(b)
|
|
57
67
|
if (pa === undefined || pb === undefined) return (pa === undefined ? 0 : 1) - (pb === undefined ? 0 : 1)
|
|
58
|
-
for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] - pb[i]
|
|
59
|
-
return 0
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
* @param tags - 标签名,顺序无所谓。
|
|
66
|
-
* @returns 最新的发布标签;一个都没有时返回 undefined。
|
|
67
|
-
*/
|
|
68
|
-
export function latestRelease(tags) {
|
|
69
|
-
let best
|
|
70
|
-
for (const tag of tags ?? []) {
|
|
71
|
-
if (parseReleaseTag(tag) === undefined) continue
|
|
72
|
-
if (best === undefined || compareVersions(tag, best) > 0) best = tag
|
|
73
|
-
}
|
|
74
|
-
return best
|
|
68
|
+
for (let i = 0; i < 3; i += 1) if (pa.parts[i] !== pb.parts[i]) return pa.parts[i] - pb.parts[i]
|
|
69
|
+
if (pa.prerelease === pb.prerelease) return 0
|
|
70
|
+
// 都没有预发布尾巴 → 相等(上面已处理);一个有一个没有 → 有尾巴的那个更旧。
|
|
71
|
+
if (pa.prerelease === undefined) return 1
|
|
72
|
+
if (pb.prerelease === undefined) return -1
|
|
73
|
+
return pa.prerelease < pb.prerelease ? -1 : pa.prerelease > pb.prerelease ? 1 : 0
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
/**
|
|
78
|
-
* 发布从哪来。默认是
|
|
79
|
-
*
|
|
77
|
+
* 发布从哪来。默认是 npm 公共 registry;DSH_PWSH_UPDATE_REGISTRY 可以指向自建镜像
|
|
78
|
+
* 或测试用的本地服务器 —— 发布正式上线前先在本地预演就靠它。
|
|
80
79
|
*
|
|
81
80
|
* @param env - 要读的环境。
|
|
82
|
-
* @returns `{ kind: '
|
|
83
|
-
* `spec` 是不带 #tag 的安装规格。
|
|
81
|
+
* @returns `{ kind: 'registry', registry, name, spec }`;`spec` 是不带版本的安装规格。
|
|
84
82
|
*/
|
|
85
83
|
export function updateSource(env = process.env) {
|
|
86
|
-
const raw = String(env.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return { kind: 'github', repository: UPDATE_REPOSITORY, spec: 'github:' + UPDATE_REPOSITORY }
|
|
84
|
+
const raw = String(env.DSH_PWSH_UPDATE_REGISTRY ?? '').trim()
|
|
85
|
+
const registry = raw.length === 0 ? UPDATE_REGISTRY : raw.replace(/\/+$/, '')
|
|
86
|
+
return { kind: 'registry', registry, name: UPDATE_PACKAGE, spec: UPDATE_PACKAGE }
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
/**
|
|
93
|
-
export function releaseSpec(source,
|
|
94
|
-
return source.
|
|
89
|
+
/** 一个发布的、钉住版本号的安装规格:每个版本一个规格,DSH 会拿它盖掉旧的。 */
|
|
90
|
+
export function releaseSpec(source, version) {
|
|
91
|
+
return source.name + '@' + String(version).replace(/^v/, '')
|
|
95
92
|
}
|
|
96
93
|
|
|
97
94
|
/**
|
|
98
|
-
*
|
|
95
|
+
* 读 registry 上的包摘要(abbreviated metadata)。
|
|
96
|
+
*
|
|
97
|
+
* 用 abbreviated 而不是完整文档:完整文档带每个版本的全部 package.json,动辄几百 KB,
|
|
98
|
+
* 而这里只需要 dist-tags 与版本清单。
|
|
99
99
|
*
|
|
100
100
|
* @param source - 由 updateSource() 给出。
|
|
101
|
-
* @param options - `{
|
|
102
|
-
* @returns
|
|
101
|
+
* @param options - `{ fetch, signal }`:可替换的 fetch(测试用)与中止信号。
|
|
102
|
+
* @returns `{ latest, versions, deprecated }`:dist-tags.latest、版本清单、被废弃的版本集合。
|
|
103
103
|
*/
|
|
104
|
-
export async function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (error) reject(error)
|
|
109
|
-
else resolve(stdout)
|
|
110
|
-
})
|
|
111
|
-
})
|
|
112
|
-
return output.split('\n').map((line) => /refs\/tags\/([^\s^]+)$/.exec(line.trim())?.[1]).filter(Boolean)
|
|
113
|
-
}
|
|
114
|
-
const response = await fetch(api.replace(/\/+$/, '') + '/repos/' + source.repository + '/tags?per_page=100', {
|
|
115
|
-
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-pwsh-plugin', 'x-github-api-version': '2022-11-28' },
|
|
104
|
+
export async function fetchRegistry(source, { fetch: fetchImpl = globalThis.fetch, signal } = {}) {
|
|
105
|
+
const url = source.registry + '/' + encodeURIComponent(source.name)
|
|
106
|
+
const response = await fetchImpl(url, {
|
|
107
|
+
headers: { accept: 'application/vnd.npm.install-v1+json', 'user-agent': 'dsh-pwsh-plugin' },
|
|
116
108
|
signal,
|
|
117
109
|
})
|
|
118
110
|
if (!response.ok) {
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
// 404 是「这个包还不存在」,与「网络不通」必须分开说:前者重试一万次也不会好。
|
|
112
|
+
const reason = response.status === 404 ? 'not published on ' + source.registry : 'registry answered HTTP ' + response.status
|
|
113
|
+
throw new Error(reason)
|
|
121
114
|
}
|
|
122
115
|
const body = await response.json()
|
|
123
|
-
if (
|
|
124
|
-
|
|
116
|
+
if (body === null || typeof body !== 'object') throw new Error('registry answered something other than a package document')
|
|
117
|
+
const versions = Object.keys(body.versions ?? {})
|
|
118
|
+
const deprecated = new Set(versions.filter((version) => typeof body.versions[version]?.deprecated === 'string'))
|
|
119
|
+
const tagged = body['dist-tags']?.latest
|
|
120
|
+
return {
|
|
121
|
+
...(typeof tagged === 'string' ? { latest: tagged } : {}),
|
|
122
|
+
versions,
|
|
123
|
+
deprecated,
|
|
124
|
+
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
|
-
*
|
|
128
|
+
* 拿 registry 的摘要与正在跑的版本比一次。
|
|
129
|
+
*
|
|
130
|
+
* dist-tags.latest 缺失时从版本清单里挑:只认正式版(预发布不自动推),取语义最高。
|
|
129
131
|
*
|
|
130
|
-
* @param
|
|
132
|
+
* @param document - fetchRegistry() 的返回。
|
|
131
133
|
* @param current - 本包版本(X.Y.Z)。
|
|
132
134
|
* @returns 有更新的发布时 `{ state: 'available', latest }`,否则 `{ state: 'current', latest? }`。
|
|
133
135
|
*/
|
|
134
|
-
export function updateVerdict(
|
|
135
|
-
const
|
|
136
|
+
export function updateVerdict(document, current) {
|
|
137
|
+
const versions = Array.isArray(document?.versions) ? document.versions : []
|
|
138
|
+
const deprecated = document?.deprecated instanceof Set ? document.deprecated : new Set()
|
|
139
|
+
let latest = typeof document?.latest === 'string' ? document.latest : undefined
|
|
140
|
+
if (latest === undefined) {
|
|
141
|
+
for (const version of versions) {
|
|
142
|
+
const parsed = parseVersion(version)
|
|
143
|
+
if (parsed === undefined || parsed.prerelease !== undefined || deprecated.has(version)) continue
|
|
144
|
+
if (latest === undefined || compareVersions(version, latest) > 0) latest = version
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 被标为 deprecated 的「最新版」不可信:那是发布者自己说的「别用这个」。
|
|
148
|
+
if (latest !== undefined && deprecated.has(latest)) return { state: 'current', deprecated: latest }
|
|
136
149
|
if (latest !== undefined && compareVersions(latest, current) > 0) return { state: 'available', latest }
|
|
137
150
|
return { state: 'current', ...(latest !== undefined ? { latest } : {}) }
|
|
138
151
|
}
|
|
@@ -151,21 +164,21 @@ export function packageVersion(fromUrl = import.meta.url) {
|
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
/**
|
|
154
|
-
*
|
|
167
|
+
* 一个更新检查器:定时读 registry、记下判决、供工具层取用。
|
|
155
168
|
*
|
|
156
169
|
* 状态 `state`:off | pending | checking | current | available | failed。
|
|
157
|
-
*
|
|
170
|
+
* 任何失败(断网、镜像挂掉、包还没发布)只记下来给调用方看,绝不抛出,也不提前重试:
|
|
158
171
|
* 下一轮定时检查会再试。
|
|
159
172
|
*/
|
|
160
173
|
export class UpdateChecker {
|
|
161
174
|
/**
|
|
162
|
-
* @param options - `{ env,
|
|
175
|
+
* @param options - `{ env, fetch, delayMs, intervalMs, log, source, version }`。
|
|
163
176
|
* `version` 缺省时在**构造这一刻**读一次本包 package.json,之后固定在实例上;
|
|
164
177
|
* `log` 是接受一行文本的日志函数。
|
|
165
178
|
*/
|
|
166
179
|
constructor(options = {}) {
|
|
167
180
|
this.env = options.env ?? process.env
|
|
168
|
-
this.
|
|
181
|
+
this.fetch = options.fetch
|
|
169
182
|
this.delayMs = options.delayMs
|
|
170
183
|
this.intervalMs = options.intervalMs ?? UPDATE_INTERVAL_MS
|
|
171
184
|
this.log = options.log ?? (() => undefined)
|
|
@@ -223,9 +236,9 @@ export class UpdateChecker {
|
|
|
223
236
|
const previous = this.state
|
|
224
237
|
this.state = { ...previous, state: 'checking' }
|
|
225
238
|
const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(20000)])
|
|
226
|
-
this.checking =
|
|
227
|
-
.then((
|
|
228
|
-
const verdict = updateVerdict(
|
|
239
|
+
this.checking = fetchRegistry(this.source, { ...(this.fetch === undefined ? {} : { fetch: this.fetch }), signal })
|
|
240
|
+
.then((document) => {
|
|
241
|
+
const verdict = updateVerdict(document, this.version)
|
|
229
242
|
this.state = { ...verdict, checkedAt: new Date().toISOString() }
|
|
230
243
|
this.log('update check: ' + this.state.state + (this.state.latest === undefined ? '' : ' (newest ' + this.state.latest + ', running ' + this.version + ')'))
|
|
231
244
|
}, (error) => {
|
package/wsl/llrun
CHANGED
|
File without changes
|