dsh-pwsh-plugin 0.2.14 → 0.2.16

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.
@@ -1,274 +0,0 @@
1
- /**
2
- * 把「查到新版本」变成「装上新版本」——走 DSH 自己的 pluginManager,不自己下载覆盖文件。
3
- *
4
- * 为什么不学 our-free-model 自带下载器
5
- * 那个插件是独立包,安装目录里就是自己那几十个文件,所以它能做「清单签名 + 逐文件 sha256 +
6
- * 备份 + 原地替换 + 热重载回滚」这一整套事务。本插件是 profile 里的一个 bundle 依赖:
7
- * 文件躺在 node_modules/dsh-pwsh-plugin,版本钉在 profile 的 package.json 与 pnpm-lock.yaml 里。
8
- * 绕开包管理器去改这些文件,会立刻让 lockfile 与磁盘不一致,下一次 dsh plugin add/remove 就会
9
- * 踩到脏状态。所以安装这一步交给 dsh-plugin-manager(Plugins 页面用的同一个服务),
10
- * 它自己跑 pnpm、自己回滚 package.json / lockfile。
11
- *
12
- * 为什么装完必须回读磁盘版本
13
- * pnpm 有供应链冷却期(minimumReleaseAge,v11 起默认 1440 分钟)。请求 dsh-pwsh-plugin@0.2.13
14
- * 而该版本刚发布几小时,pnpm 会装到它能接受的那个版本;installBundle 的返回值里没有版本号,
15
- * 只有 application 这个枚举。所以「装了什么」只能从装完的 package.json 读回来 ——
16
- * 把这里的判决当成既成事实去宣称,就是在说一句没人验证过的话。
17
- *
18
- * 能不能不重启
19
- * bundle 行的 name 是包名,拿不到 URL 上的缓存破坏参数,ESM 按 URL 缓存模块 —— 光重新 apply
20
- * 只是再 import 同一个 URL。但 installBundle 走 HMR 的 runExclusive,重组合会比对文件指纹
21
- * (inode:size:mtime),把变了的模块驱逐、重新 import,所以**在 HMR 在场的宿主上是真换掉了**,
22
- * 实测 137ms 生效。前提是调用点在 HMR 事务之外,否则嵌套会被拒(见 auto-update.mjs)。
23
- * 没有 HMR 的宿主上 application 才是 'restart-required',这里照实转述。
24
- */
25
-
26
- import { readFileSync } from 'node:fs'
27
- import { join } from 'node:path'
28
-
29
- import { compareVersions, packageVersion } from './update-check.mjs'
30
-
31
- /** 本包的包名:profile 的 dependencies 用它记录安装规格。 */
32
- const OWN_NAME = (() => {
33
- try {
34
- const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
35
- return typeof pkg.name === 'string' && pkg.name.length > 0 ? pkg.name : 'dsh-pwsh-plugin'
36
- } catch {
37
- return 'dsh-pwsh-plugin'
38
- }
39
- })()
40
-
41
- /** `v1.2.3` 与 `1.2.3` 是同一个版本;不是这个形态时返回 undefined。 */
42
- export function bareVersion(value) {
43
- const text = String(value ?? '').trim().replace(/^v/i, '')
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
- }
73
- }
74
-
75
- /**
76
- * profile 里为本包记录的那条依赖值。
77
- *
78
- * @param profileDir - profile 目录,取自 ctx.get('profileContext').dir。
79
- * @param packageName - 要查的包名,默认本包。
80
- * @returns 记不下时(没有 package.json、没这条依赖、值不是字符串)返回 undefined。
81
- */
82
- export function readInstalledSpec(profileDir, packageName = OWN_NAME) {
83
- if (typeof profileDir !== 'string' || profileDir.length === 0) return undefined
84
- try {
85
- const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
86
- const value = manifest?.dependencies?.[packageName]
87
- return typeof value === 'string' && value.length > 0 ? value.trim() : undefined
88
- } catch {
89
- return undefined
90
- }
91
- }
92
-
93
- /**
94
- * 装之前的预检:这次安装有没有意义。
95
- *
96
- * 为什么要有它
97
- * dsh-plugin-manager 在 HMR 在场的宿主上,对「规格与 profile 里已记录的完全相同」的安装会在
98
- * pnpm 之前直接抛 already-installed。有三种情形会撞上,而它们都不该真去装一次:
99
- * 1. 别人(终端 dsh plugin add、Plugins 页面)已经装好了,只是进程还没重启;
100
- * 2. 发版时打了 tag 却忘了改 package.json 的 version —— 装的永远是同一个包内版本,
101
- * 于是每一轮都「有新版、装不进去」,静默循环到天荒地老;
102
- * 3. pnpm 的冷却期让这次安装只会落到已经装好的那个版本上(见下面的 cooled 参数)。
103
- * 都能用手里已有的事实判掉,判掉之后要能说清是哪一种。
104
- *
105
- * @param options - `{ spec, latest, disk, running, installed, cooled }`。
106
- * `disk` 缺省时现读本包 package.json;`running` 缺省时不比对运行版本。
107
- * `cooled` 是「上一次安装实际落到的版本」——冷却期让这次不可能装到 latest 时,
108
- * 调用方把它传进来,这里就不再发起注定落空的安装。
109
- * @returns 该装时返回 null;否则返回 `{ ok, outcome, text }`——几种情形成因不同,都不该重试。
110
- */
111
- export function checkInstallNeed(options = {}) {
112
- const latest = bareVersion(options.latest)
113
- if (latest === undefined) return null
114
- // 「没传 disk」和「传了但读不到」必须分开:前者现读本包,后者说明本包的版本号有问题,
115
- // 那时不该拿它去判别人的发布坏了。
116
- const disk = bareVersion(Object.hasOwn(options, 'disk') ? options.disk : packageVersion())
117
- const running = bareVersion(options.running)
118
- const spec = typeof options.spec === 'string' ? options.spec.trim() : undefined
119
- const installed = typeof options.installed === 'string' ? options.installed.trim() : undefined
120
- const cooled = bareVersion(options.cooled)
121
-
122
- if (disk === latest) {
123
- // 磁盘上已经是这个版本了,再装一次要么被拒,要么装个同样的东西。
124
- if (running === latest) {
125
- return {
126
- ok: true,
127
- outcome: 'already-current',
128
- text: 'v' + latest + ' is already installed and already running in this process — nothing to do.',
129
- }
130
- }
131
- return {
132
- ok: true,
133
- outcome: 'restart-required',
134
- text: 'v' + latest + ' is already on disk, but this process still runs '
135
- + (running === undefined ? 'the previous version' : 'v' + running)
136
- + '. Restart DSH to load it; installing again would only be refused as already-installed.',
137
- }
138
- }
139
-
140
- // 读不到包内版本时不做这个判断:那是「本插件自己的 package.json 有问题」,不是发布的问题,
141
- // 按未知处理比按坏发布处理安全。
142
- if (disk !== undefined && spec !== undefined && spec.length > 0 && installed === spec) {
143
- // profile 已经指向这个版本,而磁盘上的包仍报别的版本:发布本身坏了。
144
- return {
145
- ok: false,
146
- outcome: 'broken-release',
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.',
151
- }
152
- }
153
-
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
- }
165
-
166
- return null
167
- }
168
-
169
- /**
170
- * 从插件上下文里凑齐预检要的事实。
171
- *
172
- * @param ctx - 插件上下文;profileContext 缺席时读不到已装规格(预检只剩版本比对)。
173
- * @param checker - UpdateChecker 实例,用来取进程里在跑的版本。
174
- * @param spec - 要装的规格,末尾钉着版本号。
175
- * @param options - `{ cooled }`:上一次安装实际落到的版本。
176
- * @returns checkInstallNeed 的结果:null 表示该装。
177
- */
178
- export function preflightInstall(ctx, checker, spec, options = {}) {
179
- const profileDir = ctx?.get?.('profileContext')?.dir
180
- return checkInstallNeed({
181
- spec,
182
- latest: specVersion(spec),
183
- running: checker?.version,
184
- installed: readInstalledSpec(profileDir),
185
- ...(options.cooled === undefined ? {} : { cooled: options.cooled }),
186
- })
187
- }
188
-
189
- /** 安装后要把 application 这个枚举翻译成一句能照做的话。 */
190
- export function describeInstallOutcome(result, options = {}) {
191
- const application = typeof result?.application === 'string' ? result.application : 'unknown'
192
- const error = result?.error
193
- if (application === 'failed' || application === 'cancelled') {
194
- const code = typeof error?.code === 'string' ? error.code : 'operation-error'
195
- // 预检(checkInstallNeed)已经拦掉正常的 already-installed,走到这里说明 profile 目录读不到、
196
- // 或者中途被别处抢先装了。这不是故障,说清楚「已经在了」比报一串 operation-error 有用。
197
- if (code === 'already-installed') {
198
- return {
199
- ok: true,
200
- text: 'This release is already installed in the profile, so there was nothing to install. '
201
- + 'If the running process still shows an older version, restart DSH to load what is already on disk.',
202
- }
203
- }
204
- const diagnostic = typeof error?.diagnostic === 'string' && error.diagnostic.length > 0 ? ': ' + error.diagnostic : ''
205
- return {
206
- ok: false,
207
- text: 'pwsh_run could not install the release (' + code + diagnostic + '). '
208
- + 'Nothing was changed in the profile if pnpm failed before the swap; '
209
- + 'run the same command from a terminal to see the full output: ',
210
- }
211
- }
212
- // HMR 在场且安装是在它的事务之外发起时,application 仍是 'applied',但这次是**真的**换掉了:
213
- // HMR 重组合会驱逐本插件的模块、重新 import 并重新 apply(实测改文件后 137ms 生效)。
214
- if (options?.live === true && application === 'applied') {
215
- return {
216
- ok: true,
217
- text: 'The release is installed and the new code is already running (application: applied, '
218
- + 'HMR replaced the plugin in place — no restart needed).',
219
- }
220
- }
221
- // 没有 HMR,或安装是在 HMR 事务里发起的:磁盘是新版,进程里仍是旧模块。bundle 行给不出
222
- // 新的 module URL,ESM 按 URL 缓存,重新 apply 也只是再 import 同一个 URL(实测过)。
223
- return {
224
- ok: true,
225
- text: 'The release is installed on disk (application: ' + application + '). Restart DSH to load it: a bundle row '
226
- + 'names the package, so the module URL does not change and the running process keeps the old code until a restart.',
227
- }
228
- }
229
-
230
- /**
231
- * 装一个钉住版本号的规格,然后回读磁盘上真正落地的版本。
232
- *
233
- * @param manager - ctx.get('pluginManager');缺席时传 null。
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 }`,永不抛出:安装失败是正常结果,不是程序缺陷。
238
- */
239
- export async function installRelease(manager, spec, options = {}) {
240
- if (manager === null || manager === undefined) {
241
- return {
242
- ok: false,
243
- outcome: 'no-manager',
244
- text: 'This profile does not expose ctx.pluginManager, so pwsh_run cannot install the release itself.',
245
- }
246
- }
247
- if (typeof manager.installBundle !== 'function') {
248
- return { ok: false, outcome: 'no-manager', text: 'ctx.pluginManager does not offer installBundle in this DSH build.' }
249
- }
250
- try {
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
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
263
- return {
264
- ok: described.ok,
265
- outcome: typeof result?.application === 'string' ? result.application : 'unknown',
266
- text,
267
- ...(installedVersion === undefined ? {} : { installedVersion }),
268
- ...(result?.error === undefined ? {} : { error: result.error }),
269
- }
270
- } catch (error) {
271
- // change() 只在拿不到 profile 锁或服务正在销毁时 reject;两种都值得原样带出去。
272
- return { ok: false, outcome: 'threw', text: 'pwsh_run could not install the release: ' + String(error?.message ?? error) }
273
- }
274
- }
@@ -1,270 +0,0 @@
1
- /**
2
- * pwsh-run 的更新检查 —— 只查不装。装这一步由 update-apply.mjs 走 DSH 自己的 pluginManager。
3
- *
4
- * 为什么单独一个模块
5
- * 这个插件是纯宿主平面的工具插件,没有配置页面,也没有 client 入口。
6
- * 把「读发布源 / 比版本 / 记状态」这几件事独立出来,就能在纯 node 里
7
- * 完整单测,不必起一个 DSH。
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
- *
18
- * 它做什么
19
- * 启动几秒后读一次 registry 的 dist-tags.latest,之后大约每 12 小时再读一次,
20
- * 与 package.json 里的版本比较。**从不自己安装**:发现新版本时只把版本号与
21
- * 安装规格(<package>@X.Y.Z)记下来。一个版本一个规格,所以装新版本就是盖掉旧的。
22
- *
23
- * 发布源可覆盖
24
- * DSH_PWSH_UPDATE_REGISTRY 覆盖 registry 基址(测试与自建镜像靠它)。
25
- * DSH_PWSH_UPDATE_DELAY_MS 覆盖首次检查的延迟(测试用)。
26
- */
27
-
28
- import { readFileSync } from 'node:fs'
29
-
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'
35
-
36
- /** 启动后先让启动流程跑完,再查更新。 */
37
- const UPDATE_DELAY_MS = 5000
38
- const UPDATE_INTERVAL_MS = 12 * 60 * 60 * 1000
39
-
40
- /** 三段数字,可选预发布尾巴;npm 的版本号允许的形态。 */
41
- const RELEASE_VERSION = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/
42
-
43
- /**
44
- * 读一个版本号。
45
- *
46
- * @param value - 版本号,可带 v 前缀与预发布尾巴(1.2.3 / v1.2.3 / 1.2.3-rc.1)。
47
- * @returns `{ parts: [major, minor, patch], prerelease }`;不是版本形态时返回 undefined。
48
- */
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 }
54
- }
55
-
56
- /**
57
- * 比两个版本,写 X.Y.Z 或 vX.Y.Z 都行。
58
- *
59
- * 预发布(1.2.3-rc.1)按 semver 的规矩排在对应的正式版**之下**:
60
- * 否则一个 rc 会被当成「比 1.2.3 还新」,自动更新就会把用户推到预发布上。
61
- *
62
- * @returns 像排序比较器那样返回负数、零或正数;解析不出来的版本排在所有能解析的之下。
63
- */
64
- export function compareVersions(a, b) {
65
- const pa = parseVersion(a)
66
- const pb = parseVersion(b)
67
- if (pa === undefined || pb === undefined) return (pa === undefined ? 0 : 1) - (pb === undefined ? 0 : 1)
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
74
- }
75
-
76
- /**
77
- * 发布从哪来。默认是 npm 公共 registry;DSH_PWSH_UPDATE_REGISTRY 可以指向自建镜像
78
- * 或测试用的本地服务器 —— 发布正式上线前先在本地预演就靠它。
79
- *
80
- * @param env - 要读的环境。
81
- * @returns `{ kind: 'registry', registry, name, spec }`;`spec` 是不带版本的安装规格。
82
- */
83
- export function updateSource(env = process.env) {
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 }
87
- }
88
-
89
- /** 一个发布的、钉住版本号的安装规格:每个版本一个规格,DSH 会拿它盖掉旧的。 */
90
- export function releaseSpec(source, version) {
91
- return source.name + '@' + String(version).replace(/^v/, '')
92
- }
93
-
94
- /**
95
- * 读 registry 上的包摘要(abbreviated metadata)。
96
- *
97
- * 用 abbreviated 而不是完整文档:完整文档带每个版本的全部 package.json,动辄几百 KB,
98
- * 而这里只需要 dist-tags 与版本清单。
99
- *
100
- * @param source - 由 updateSource() 给出。
101
- * @param options - `{ fetch, signal }`:可替换的 fetch(测试用)与中止信号。
102
- * @returns `{ latest, versions, deprecated }`:dist-tags.latest、版本清单、被废弃的版本集合。
103
- */
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' },
108
- signal,
109
- })
110
- if (!response.ok) {
111
- // 404 是「这个包还不存在」,与「网络不通」必须分开说:前者重试一万次也不会好。
112
- const reason = response.status === 404 ? 'not published on ' + source.registry : 'registry answered HTTP ' + response.status
113
- throw new Error(reason)
114
- }
115
- const body = await response.json()
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
- }
126
-
127
- /**
128
- * 拿 registry 的摘要与正在跑的版本比一次。
129
- *
130
- * dist-tags.latest 缺失时从版本清单里挑:只认正式版(预发布不自动推),取语义最高。
131
- *
132
- * @param document - fetchRegistry() 的返回。
133
- * @param current - 本包版本(X.Y.Z)。
134
- * @returns 有更新的发布时 `{ state: 'available', latest }`,否则 `{ state: 'current', latest? }`。
135
- */
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 }
149
- if (latest !== undefined && compareVersions(latest, current) > 0) return { state: 'available', latest }
150
- return { state: 'current', ...(latest !== undefined ? { latest } : {}) }
151
- }
152
-
153
- /**
154
- * 本包自己的版本。每次**调用**都现读 package.json —— 注意 UpdateChecker 只在构造时调一次,
155
- * 所以实例上的版本是构造那一刻的磁盘值。热替换会重新 apply、重建实例,两者因此仍然对齐。
156
- */
157
- export function packageVersion(fromUrl = import.meta.url) {
158
- try {
159
- const pkg = JSON.parse(readFileSync(new URL('../package.json', fromUrl), 'utf8'))
160
- return typeof pkg.version === 'string' ? pkg.version : undefined
161
- } catch {
162
- return undefined
163
- }
164
- }
165
-
166
- /**
167
- * 一个更新检查器:定时读 registry、记下判决、供工具层取用。
168
- *
169
- * 状态 `state`:off | pending | checking | current | available | failed。
170
- * 任何失败(断网、镜像挂掉、包还没发布)只记下来给调用方看,绝不抛出,也不提前重试:
171
- * 下一轮定时检查会再试。
172
- */
173
- export class UpdateChecker {
174
- /**
175
- * @param options - `{ env, fetch, delayMs, intervalMs, log, source, version }`。
176
- * `version` 缺省时在**构造这一刻**读一次本包 package.json,之后固定在实例上;
177
- * `log` 是接受一行文本的日志函数。
178
- */
179
- constructor(options = {}) {
180
- this.env = options.env ?? process.env
181
- this.fetch = options.fetch
182
- this.delayMs = options.delayMs
183
- this.intervalMs = options.intervalMs ?? UPDATE_INTERVAL_MS
184
- this.log = options.log ?? (() => undefined)
185
- this.version = options.version ?? packageVersion()
186
- this.source = options.source ?? updateSource(this.env)
187
- this.state = { state: 'pending' }
188
- this.timer = undefined
189
- this.interval = undefined
190
- this.checking = undefined
191
- this.announced = undefined
192
- this.abort = new AbortController()
193
- }
194
-
195
- /** 已经报过的那次「有新版」:只在第一次把提示交给调用方,避免每次调用都刷一行。 */
196
- get announce() {
197
- if (this.state.state !== 'available') return undefined
198
- if (this.announced === this.state.latest) return undefined
199
- this.announced = this.state.latest
200
- return { latest: this.state.latest, spec: releaseSpec(this.source, this.state.latest) }
201
- }
202
-
203
- /** 开始定时检查。 */
204
- start() {
205
- if (this.timer !== undefined || this.interval !== undefined) return
206
- const delay = Number(this.env.DSH_PWSH_UPDATE_DELAY_MS ?? this.delayMs)
207
- this.timer = setTimeout(() => {
208
- this.timer = undefined
209
- void this.check()
210
- this.interval = setInterval(() => void this.check(), this.intervalMs)
211
- this.interval.unref?.()
212
- }, Number.isFinite(delay) && delay >= 0 ? delay : UPDATE_DELAY_MS)
213
- this.timer.unref?.()
214
- }
215
-
216
- /** 停掉定时检查并中止在途的那一次。 */
217
- stop() {
218
- clearTimeout(this.timer)
219
- clearInterval(this.interval)
220
- this.timer = undefined
221
- this.interval = undefined
222
- this.abort.abort()
223
- }
224
-
225
- /**
226
- * 立刻查一次(定时器与手动都走这里)。重入时返回同一次 Promise。
227
- *
228
- * @returns 查完之后的状态对象。
229
- */
230
- check() {
231
- if (this.checking !== undefined) return this.checking
232
- if (this.version === undefined) {
233
- this.state = { state: 'failed', error: 'own version unreadable', checkedAt: new Date().toISOString() }
234
- return Promise.resolve(this.state)
235
- }
236
- const previous = this.state
237
- this.state = { ...previous, state: 'checking' }
238
- const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(20000)])
239
- this.checking = fetchRegistry(this.source, { ...(this.fetch === undefined ? {} : { fetch: this.fetch }), signal })
240
- .then((document) => {
241
- const verdict = updateVerdict(document, this.version)
242
- this.state = { ...verdict, checkedAt: new Date().toISOString() }
243
- this.log('update check: ' + this.state.state + (this.state.latest === undefined ? '' : ' (newest ' + this.state.latest + ', running ' + this.version + ')'))
244
- }, (error) => {
245
- if (this.abort.signal.aborted) return
246
- const message = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error).split('\n')[0].slice(0, 200)
247
- // 早先查到过的发布不因一次瞬时失败而消失。
248
- this.state = {
249
- ...(previous.latest === undefined ? {} : { latest: previous.latest }),
250
- state: 'failed',
251
- error: message,
252
- checkedAt: new Date().toISOString(),
253
- ...(previous.state === 'available' ? { stale: 'available' } : {}),
254
- }
255
- this.log('update check failed: ' + message)
256
- })
257
- .finally(() => {
258
- this.checking = undefined
259
- })
260
- return this.checking.then(() => this.state)
261
- }
262
-
263
- /** 给调用方/测试看的一份纯净快照。 */
264
- document() {
265
- const doc = { ...this.state, source: this.source.spec }
266
- if (this.version !== undefined) doc.running = this.version
267
- if (doc.latest !== undefined) doc.spec = releaseSpec(this.source, doc.latest)
268
- return doc
269
- }
270
- }