@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.4

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.
Files changed (79) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +12 -10
  3. package/README.zh.md +12 -10
  4. package/lib/client.js +278 -599
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +396 -913
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +8 -9
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +51 -8
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +0 -84
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +142 -8
  19. package/lib/types/client/settings-entry-form.d.ts +50 -0
  20. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  21. package/lib/types/client/settings-entry-form.js +98 -0
  22. package/lib/types/index.d.ts +99 -7
  23. package/lib/types/index.d.ts.map +1 -1
  24. package/lib/types/index.js +150 -149
  25. package/lib/types/lan-bind-plan.d.ts +14 -0
  26. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  27. package/lib/types/lan-bind-plan.js +16 -0
  28. package/lib/types/lan-bind.d.ts +1 -1
  29. package/lib/types/lan-bind.js +1 -1
  30. package/lib/types/mount-once.d.ts +38 -7
  31. package/lib/types/mount-once.d.ts.map +1 -1
  32. package/lib/types/mount-once.js +96 -12
  33. package/lib/types/pairing.d.ts.map +1 -1
  34. package/lib/types/pairing.js +43 -0
  35. package/lib/types/remote-api.d.ts +13 -1
  36. package/lib/types/remote-api.d.ts.map +1 -1
  37. package/lib/types/remote-api.js +14 -1
  38. package/lib/types/tunnel.d.ts +60 -3
  39. package/lib/types/tunnel.d.ts.map +1 -1
  40. package/lib/types/tunnel.js +136 -3
  41. package/package.json +20 -20
  42. package/src/client/RemoteEntry.tsx +7 -9
  43. package/src/client/RemoteSettingsCard.tsx +0 -2
  44. package/src/client/index.ts +53 -9
  45. package/src/client/locales.ts +0 -84
  46. package/src/client/mobile-adapt.ts +144 -5
  47. package/src/client/remote.module.css +50 -200
  48. package/src/client/settings-entry-form.ts +142 -0
  49. package/src/index.ts +202 -164
  50. package/src/lan-bind-plan.ts +22 -0
  51. package/src/lan-bind.ts +1 -1
  52. package/src/mount-once.ts +107 -13
  53. package/src/pairing.ts +44 -1
  54. package/src/remote-api.ts +14 -1
  55. package/src/tunnel.ts +142 -3
  56. package/lib/types/client/UpdateEntry.d.ts +0 -14
  57. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  58. package/lib/types/client/UpdateEntry.js +0 -112
  59. package/lib/types/client/UpdatePanel.d.ts +0 -42
  60. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  61. package/lib/types/client/UpdatePanel.js +0 -101
  62. package/lib/types/client/update-api.d.ts +0 -29
  63. package/lib/types/client/update-api.d.ts.map +0 -1
  64. package/lib/types/client/update-api.js +0 -47
  65. package/lib/types/console-output.d.ts +0 -41
  66. package/lib/types/console-output.d.ts.map +0 -1
  67. package/lib/types/console-output.js +0 -89
  68. package/lib/types/update-routes.d.ts +0 -30
  69. package/lib/types/update-routes.d.ts.map +0 -1
  70. package/lib/types/update-routes.js +0 -47
  71. package/lib/types/update.d.ts +0 -282
  72. package/lib/types/update.d.ts.map +0 -1
  73. package/lib/types/update.js +0 -773
  74. package/src/client/UpdateEntry.tsx +0 -144
  75. package/src/client/UpdatePanel.tsx +0 -256
  76. package/src/client/update-api.ts +0 -49
  77. package/src/console-output.ts +0 -97
  78. package/src/update-routes.ts +0 -65
  79. package/src/update.ts +0 -915
package/src/update.ts DELETED
@@ -1,915 +0,0 @@
1
- /**
2
- * Remote update support for the dsh-web family — host half. Detects the
3
- * installed aggregate package (@linxin666/dsh-web-all), or the directly
4
- * installed family packages when the aggregate is absent, probes npm for newer
5
- * releases, and runs `pnpm update --latest` inside the owning dsh profile.
6
- *
7
- * Pure logic with injected seams (manifest reading, registry fetches, process
8
- * spawning) so the whole surface is unit-testable without touching disk,
9
- * network, or a real profile.
10
- */
11
-
12
- import { readFileSync } from 'node:fs'
13
- import { dirname, join } from 'node:path'
14
- import { spawn } from 'node:child_process'
15
- import { createOutputCapture } from './console-output.ts'
16
- import { withIdentityEncoding } from './http.ts'
17
-
18
- /** npm registry base used for version probes. */
19
- export const NPM_REGISTRY = 'https://registry.npmjs.org'
20
-
21
- /** The family scope every dsh-web package is published under. */
22
- export const FAMILY_SCOPE = '@linxin666/'
23
-
24
- /** The aggregate package that is the canonical update entry point. */
25
- export const AGGREGATE_PACKAGE = '@linxin666/dsh-web-all'
26
-
27
- /** Fallback anchor: this plugin's own package when the aggregate is absent. */
28
- export const SELF_PACKAGE = '@linxin666/dsh-remote-web-ui'
29
-
30
- /** GitHub repository used to surface human-readable release notes. */
31
- export const UPDATE_RELEASE_REPO = 'zhu1090093659/dsh-web'
32
-
33
- /** Release-notes cache freshness window (host process). */
34
- export const RELEASE_NOTES_CACHE_TTL_MS = 10 * 60_000
35
-
36
- /** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
37
- const PROFILE_NAME_PREFIX = 'dsh-profile-'
38
-
39
- /** How many ancestor directories a profile search walks before giving up. */
40
- const PROFILE_WALK_DEPTH = 12
41
-
42
- /** A parsed semantic version (prerelease identifiers kept as strings). */
43
- export interface SemverParts {
44
- major: number
45
- minor: number
46
- patch: number
47
- /** Dot-split prerelease identifiers; empty when absent. */
48
- prerelease: string[]
49
- }
50
-
51
- /**
52
- * Parse a semantic version string (leading `v` tolerated, build metadata
53
- * ignored). Returns undefined for unparseable input.
54
- * @param value - the version string, e.g. `0.1.10` or `0.1.11-rc.1`.
55
- * @returns the parsed parts, or undefined.
56
- */
57
- export function parseSemver(value: string): SemverParts | undefined {
58
- const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim())
59
- if (match === null) return undefined
60
- return {
61
- major: Number(match[1]),
62
- minor: Number(match[2]),
63
- patch: Number(match[3]),
64
- prerelease: match[4] === undefined ? [] : match[4].split('.'),
65
- }
66
- }
67
-
68
- /**
69
- * Compare two semantic versions per the semver precedence rules (a release
70
- * outranks any of its prereleases; numeric prerelease identifiers compare
71
- * numerically and sort below alphanumeric ones). An unparseable version sorts
72
- * below every parseable one; two unparseable versions compare equal.
73
- * @param a - first version.
74
- * @param b - second version.
75
- * @returns negative when a < b, 0 when equal, positive when a > b.
76
- */
77
- export function compareVersions(a: string, b: string): number {
78
- const pa = parseSemver(a)
79
- const pb = parseSemver(b)
80
- if (pa === undefined && pb === undefined) return 0
81
- if (pa === undefined) return -1
82
- if (pb === undefined) return 1
83
- for (const key of ['major', 'minor', 'patch'] as const) {
84
- if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1
85
- }
86
- if (pa.prerelease.length === 0 && pb.prerelease.length === 0) return 0
87
- if (pa.prerelease.length === 0) return 1
88
- if (pb.prerelease.length === 0) return -1
89
- for (let index = 0; index < Math.max(pa.prerelease.length, pb.prerelease.length); index++) {
90
- const ra = pa.prerelease[index]
91
- const rb = pb.prerelease[index]
92
- if (ra === undefined) return -1
93
- if (rb === undefined) return 1
94
- if (ra === rb) continue
95
- const numericA = /^\d+$/.test(ra)
96
- const numericB = /^\d+$/.test(rb)
97
- if (numericA && numericB) return Number(ra) < Number(rb) ? -1 : 1
98
- // Numeric identifiers always sort below alphanumeric ones.
99
- if (numericA) return -1
100
- if (numericB) return 1
101
- return ra < rb ? -1 : 1
102
- }
103
- return 0
104
- }
105
-
106
- /** Read a package.json at a path, tolerating any parse/IO failure. */
107
- function readManifest(path: string): Record<string, unknown> | undefined {
108
- try {
109
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
110
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
111
- } catch {
112
- return undefined
113
- }
114
- }
115
-
116
- /** The found dsh profile owning an installed package. */
117
- export interface FoundProfile {
118
- /** Profile name (e.g. `web`). */
119
- name: string
120
- /** Absolute profile directory. */
121
- dir: string
122
- }
123
-
124
- /**
125
- * Locate the owning dsh profile by walking up from an installed package's
126
- * manifest until a manifest named `dsh-profile-*` appears (the profile
127
- * directory is the first ancestor whose package.json carries that name).
128
- * @param anchorManifestPath - absolute path of the anchor package.json.
129
- * @returns the profile name/dir, or undefined when not profile-installed.
130
- */
131
- export function findProfile(anchorManifestPath: string): FoundProfile | undefined {
132
- let dir = dirname(anchorManifestPath)
133
- for (let depth = 0; depth < PROFILE_WALK_DEPTH; depth++) {
134
- const manifest = readManifest(join(dir, 'package.json'))
135
- const name = manifest?.name
136
- if (typeof name === 'string' && name.startsWith(PROFILE_NAME_PREFIX)) {
137
- return { name: name.slice(PROFILE_NAME_PREFIX.length), dir }
138
- }
139
- const parent = dirname(dir)
140
- if (parent === dir) return undefined
141
- dir = parent
142
- }
143
- return undefined
144
- }
145
-
146
- /** A package version spec in a manifest dependencies map. */
147
- type DependencySpec = string | { version: string } | undefined
148
-
149
- /** Whether a dependency spec is a local link/file/dev-mode install. */
150
- export function isLinkedSpec(spec: DependencySpec): boolean {
151
- if (typeof spec !== 'string') return false
152
- return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec)
153
- }
154
-
155
- /** Whether a direct local dependency overrides one of the aggregate's children. */
156
- function hasLinkedFamilyOverride(
157
- anchorManifest: Record<string, unknown>,
158
- profileManifest: Record<string, unknown> | undefined,
159
- ): boolean {
160
- const dependencies = profileManifest?.dependencies
161
- if (typeof dependencies !== 'object' || dependencies === null) return false
162
- return familyChildren(anchorManifest).some(name =>
163
- isLinkedSpec((dependencies as Record<string, DependencySpec>)[name]),
164
- )
165
- }
166
-
167
- /** One package's current-vs-latest comparison. */
168
- export interface UpdatePackageStatus {
169
- /** Package name. */
170
- name: string
171
- /** Locally installed version. */
172
- current: string
173
- /** Latest npm release — undefined when the registry probe failed. */
174
- latest?: string
175
- /** Whether npm carries a strictly newer release. */
176
- outdated: boolean
177
- }
178
-
179
- /** Structured release-note sections shown in the update panel. */
180
- export interface UpdateReleaseNotes {
181
- /** Version the notes describe. */
182
- version: string
183
- /** New feature bullets. */
184
- features: string[]
185
- /** Bug-fix bullets. */
186
- fixes: string[]
187
- /** Other-change bullets. */
188
- other: string[]
189
- }
190
-
191
- /** The full update-status snapshot served to the browser half. */
192
- export interface UpdateStatus {
193
- /** npm = registry-managed (updatable); link = local dev install; missing = no anchor package. */
194
- mode: 'npm' | 'link' | 'missing'
195
- /** Owning profile name (npm mode). */
196
- profileName?: string
197
- /** The anchor package the update targets. */
198
- anchor?: string
199
- /** Per-package version comparison (anchor first). */
200
- packages: UpdatePackageStatus[]
201
- /** True when any package has a newer npm release. */
202
- outdated: boolean
203
- /** Human-readable release notes for the target release, when available. */
204
- notes?: UpdateReleaseNotes
205
- /** Whole-check failure (e.g. registry unreachable). */
206
- error?: string
207
- }
208
-
209
- /** Dependency-injection seam for checkUpdates (testable without network). */
210
- export interface UpdateCheckDeps {
211
- /** Absolute path of the anchor package manifest, when resolvable. */
212
- anchorManifestPath?: string
213
- /** Resolve a package.json specifier to its absolute path (host require). */
214
- resolve(specifier: string): string | undefined
215
- /** Probe one package's latest npm version; undefined on failure. */
216
- fetchLatest(name: string): Promise<string | undefined>
217
- /** Fetch structured release notes for one target version, when available. */
218
- fetchReleaseNotes?(version: string): Promise<UpdateReleaseNotes | undefined>
219
- }
220
-
221
- /**
222
- * Resolve the anchor package's manifest path. The aggregate package is the
223
- * canonical entry point; this plugin's own package is the fallback. Both a
224
- * throwing resolve and an undefined return mean "not installed" and move on
225
- * to the next candidate.
226
- * @param resolve - a Node resolve implementation scoped to the host process.
227
- * @returns the absolute manifest path, or undefined when neither is installed.
228
- */
229
- export function resolveAnchorManifest(
230
- resolve: (specifier: string) => string | undefined,
231
- ): string | undefined {
232
- for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) {
233
- try {
234
- const path = resolve(name + '/package.json')
235
- if (path !== undefined) return path
236
- } catch {
237
- // Not installed — try the next candidate.
238
- }
239
- }
240
- return undefined
241
- }
242
-
243
- /** The resolved update target: the profile pnpm runs in plus the package list. */
244
- export interface UpdateTarget {
245
- /** Owning profile name. */
246
- profileName: string
247
- /** Absolute profile directory pnpm runs in. */
248
- profileDir: string
249
- /** The package names pnpm updates (anchor first). */
250
- packages: string[]
251
- }
252
-
253
- /**
254
- * Resolve what an update would touch: the owning profile directory and the
255
- * family package list. Fails with an error code when the anchor is missing
256
- * ('not-found') or is a local dev install ('link').
257
- * @param deps - the anchor manifest path (resolveAnchorManifest output).
258
- * @returns the target, or the failure code.
259
- */
260
- export function resolveUpdateTarget(
261
- deps: { anchorManifestPath?: string },
262
- ): UpdateTarget | { error: 'not-found' | 'link' } {
263
- const manifestPath = deps.anchorManifestPath
264
- if (manifestPath === undefined) return { error: 'not-found' }
265
- const manifest = readManifest(manifestPath)
266
- if (manifest === undefined) return { error: 'not-found' }
267
- const anchor = typeof manifest.name === 'string' ? manifest.name : undefined
268
- if (anchor === undefined) return { error: 'not-found' }
269
- const profile = findProfile(manifestPath)
270
- if (profile === undefined) return { error: 'link' }
271
- const profileManifest = readManifest(join(profile.dir, 'package.json'))
272
- const spec = (profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor]
273
- if (isLinkedSpec(spec) || hasLinkedFamilyOverride(manifest, profileManifest)) return { error: 'link' }
274
- // Standalone installs carry no aggregate: the anchor's own dependency
275
- // list misses every sibling @linxin666/* plugin installed directly into
276
- // the profile, so union the profile's direct family deps (#377).
277
- return {
278
- profileName: profile.name,
279
- profileDir: profile.dir,
280
- packages: familyUpdatePackages(anchor, manifest, profileManifest),
281
- }
282
- }
283
-
284
- /** Family children of the anchor: its dependencies under the family scope. */
285
- export function familyChildren(anchorManifest: Record<string, unknown>): string[] {
286
- const dependencies = anchorManifest.dependencies
287
- if (typeof dependencies !== 'object' || dependencies === null) return []
288
- const names: string[] = []
289
- for (const [name, spec] of Object.entries(dependencies)) {
290
- if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string') names.push(name)
291
- }
292
- return names
293
- }
294
-
295
- /** Registry-managed family packages covered by one update operation. */
296
- function familyUpdatePackages(
297
- anchor: string,
298
- anchorManifest: Record<string, unknown>,
299
- profileManifest: Record<string, unknown> | undefined,
300
- ): string[] {
301
- const names = new Set([anchor, ...familyChildren(anchorManifest)])
302
- const dependencies = profileManifest?.dependencies
303
- if (typeof dependencies !== 'object' || dependencies === null) return [...names]
304
- for (const [name, spec] of Object.entries(dependencies)) {
305
- if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string' && !isLinkedSpec(spec)) names.add(name)
306
- }
307
- return [...names]
308
- }
309
-
310
- /** Concurrent registry probes in flight for one update-status call. */
311
- export const UPDATE_PROBE_CONCURRENCY = 4
312
-
313
- /**
314
- * Probe every package's latest version with a bounded number of requests in
315
- * flight, preserving the caller's order in the result.
316
- * @param names - package names to probe.
317
- * @param fetchLatest - the probe for one package (returns undefined on failure).
318
- * @returns one result per name, in the input order.
319
- */
320
- export async function probeLatestVersions(
321
- names: readonly string[],
322
- fetchLatest: (name: string) => Promise<string | undefined>,
323
- ): Promise<(string | undefined)[]> {
324
- const results: (string | undefined)[] = new Array(names.length)
325
- let next = 0
326
- const worker = async (): Promise<void> => {
327
- for (;;) {
328
- const index = next++
329
- if (index >= names.length) return
330
- results[index] = await fetchLatest(names[index]!)
331
- }
332
- }
333
- const workers = Math.min(UPDATE_PROBE_CONCURRENCY, names.length)
334
- await Promise.all(Array.from({ length: workers }, () => worker()))
335
- return results
336
- }
337
-
338
- /**
339
- * Probe the npm registry for one package's latest release.
340
- * @param name - the package name (scope slash URL-encoded).
341
- * @param fetchImpl - the fetch implementation (global fetch in the host).
342
- * @param timeoutMs - probe timeout.
343
- * @returns the latest version string, or undefined on any failure.
344
- */
345
- export async function fetchLatestVersion(
346
- name: string,
347
- fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
348
- timeoutMs = 10_000,
349
- ): Promise<string | undefined> {
350
- try {
351
- const controller = new AbortController()
352
- const timer = setTimeout(() => { controller.abort() }, timeoutMs)
353
- try {
354
- const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', withIdentityEncoding({ signal: controller.signal }))
355
- if (!response.ok) return undefined
356
- const body = await response.json()
357
- if (typeof body !== 'object' || body === null) return undefined
358
- const version = (body as Record<string, unknown>).version
359
- return typeof version === 'string' ? version : undefined
360
- } finally {
361
- clearTimeout(timer)
362
- }
363
- } catch {
364
- return undefined
365
- }
366
- }
367
-
368
- /** Normalize one release-note bullet. */
369
- function cleanNoteItem(text: string): string {
370
- // Keep the visible label and drop Markdown link targets; issue references
371
- // become plain #123 because the panel has no Markdown renderer.
372
- return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^[-*]\s+/, '').trim()
373
- }
374
-
375
- /** Determine the section kind for a Markdown heading line. */
376
- function noteSectionOf(line: string): keyof Pick<UpdateReleaseNotes, 'features' | 'fixes' | 'other'> | undefined {
377
- const heading = /^#{1,6}\s+(.+)$/.exec(line.trim())
378
- if (heading === null) return undefined
379
- const title = heading[1].replace(/[::].*$/, '').trim().toLowerCase()
380
- if (title === '新功能' || title === 'new features') return 'features'
381
- if (title === '修复' || title === 'bug fixes' || title === 'bugfixes') return 'fixes'
382
- if (title === '其他改动' || title === 'other changes') return 'other'
383
- return undefined
384
- }
385
-
386
- /**
387
- * Parse the GitHub Release body into the three conventional sections.
388
- *
389
- * The committed release notes are bilingual: Chinese is the default view and
390
- * English lives inside a `<details>` block. Prefer the Chinese sections when
391
- * present and fall back to the whole body for English-only releases. Bullets
392
- * are escaped as plain text for the panel (no Markdown renderer).
393
- */
394
- function parseReleaseNotesSource(version: string, source: string): UpdateReleaseNotes | undefined {
395
- const notes: UpdateReleaseNotes = { version, features: [], fixes: [], other: [] }
396
- let current: keyof Pick<UpdateReleaseNotes, 'features' | 'fixes' | 'other'> | undefined
397
- for (const rawLine of source.split(/\r?\n/)) {
398
- const line = rawLine.trim()
399
- const section = noteSectionOf(line)
400
- if (section !== undefined) {
401
- current = section
402
- continue
403
- }
404
- if (current === undefined || !/^-\s+/.test(line)) continue
405
- const item = cleanNoteItem(line)
406
- if (item !== '') notes[current].push(item)
407
- }
408
- if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0) return undefined
409
- return notes
410
- }
411
-
412
- export function parseReleaseNotesBody(version: string, body: string): UpdateReleaseNotes | undefined {
413
- // The committed release notes put Chinese in the default view and English in
414
- // a <details> block. Parse only the default view first, then fall back to
415
- // the full body for English-only releases.
416
- return parseReleaseNotesSource(version, body.split('<details>')[0] ?? body)
417
- ?? parseReleaseNotesSource(version, body)
418
- }
419
-
420
- /**
421
- * Fetch and parse the GitHub Release for one `dsh-web` version.
422
- * @param version - the npm release version (without the `v` prefix).
423
- * @param fetchImpl - injected fetch; global `fetch` in the host.
424
- * @param timeoutMs - hard timeout for the GitHub API probe.
425
- * @returns structured notes, or undefined when unavailable.
426
- */
427
- export async function fetchGitHubReleaseNotes(
428
- version: string,
429
- fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }> = fetch,
430
- timeoutMs = 10_000,
431
- ): Promise<UpdateReleaseNotes | undefined> {
432
- const controller = new AbortController()
433
- const timer = setTimeout(() => { controller.abort() }, timeoutMs)
434
- try {
435
- const response = await fetchImpl(
436
- `https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`,
437
- withIdentityEncoding({
438
- headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-web-update' },
439
- signal: controller.signal,
440
- }),
441
- )
442
- if (!response.ok) return undefined
443
- const body = await response.json()
444
- if (typeof body !== 'object' || body === null) return undefined
445
- const text = (body as Record<string, unknown>).body
446
- return typeof text === 'string' ? parseReleaseNotesBody(version, text) : undefined
447
- } catch {
448
- return undefined
449
- } finally {
450
- clearTimeout(timer)
451
- }
452
- }
453
-
454
- /**
455
- * Sentinel for an installed version that could not be read (missing manifest
456
- * or resolve failure). It is a real-looking version so `checkUpdates` can
457
- * render the affected row, but the verified-update comparison must never
458
- * treat it as evidence — a read failure is not a version that "moved".
459
- */
460
- const VERSION_UNKNOWN = '0.0.0'
461
-
462
- /** The resolved current version of one family package (probe failure tolerated). */
463
- function readInstalledVersion(
464
- resolve: (specifier: string) => string | undefined,
465
- name: string,
466
- profileDir?: string,
467
- ): string {
468
- try {
469
- const path = resolve(name + '/package.json')
470
- const version = path === undefined ? undefined : readManifest(path)?.version
471
- if (typeof version === 'string') return version
472
- } catch { /* fall through to the profile's direct dependency path */ }
473
- // Names originate in the profile dependency map, but still validate the
474
- // npm package shape before using one as path segments.
475
- if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN
476
- const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version
477
- return typeof version === 'string' ? version : VERSION_UNKNOWN
478
- }
479
-
480
- /**
481
- * Build the update status: locate the anchor, detect the install mode, and
482
- * compare every family package against the npm registry.
483
- * @param deps - manifest resolution + registry probe seams.
484
- * @returns the status snapshot.
485
- */
486
- export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus> {
487
- const manifestPath = deps.anchorManifestPath
488
- if (manifestPath === undefined) {
489
- return { mode: 'missing', packages: [], outdated: false }
490
- }
491
- const manifest = readManifest(manifestPath)
492
- if (manifest === undefined) {
493
- return { mode: 'missing', packages: [], outdated: false }
494
- }
495
- const anchor = typeof manifest.name === 'string' ? manifest.name : undefined
496
- if (anchor === undefined) {
497
- return { mode: 'missing', packages: [], outdated: false }
498
- }
499
- // A package inside a dsh profile directory is a registry install; one that
500
- // lives outside every profile (e.g. a repo checkout wired through
501
- // link-profile.mjs) is a local dev install pnpm cannot update.
502
- const profile = findProfile(manifestPath)
503
- const profileManifest = profile === undefined ? undefined : readManifest(join(profile.dir, "package.json"))
504
- const linked = profile === undefined
505
- || isLinkedSpec((profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor])
506
- || hasLinkedFamilyOverride(manifest, profileManifest)
507
- if (profile === undefined) {
508
- return { mode: 'link', packages: [], outdated: false }
509
- }
510
- // Union the profile's direct family deps so standalone installs (no
511
- // aggregate) still check every installed @linxin666/* plugin (#377);
512
- // familyUpdatePackages skips link:/file: development dependencies.
513
- const names = familyUpdatePackages(anchor, manifest, profileManifest)
514
- // The registry probes are independent, so they run concurrently — but with a
515
- // small ceiling rather than all at once. A full install fans out ~20 probes,
516
- // and on hosts behind an application-aware security middlebox that burst is
517
- // what gets the node process's outbound connections throttled (the update
518
- // check itself only ever loses latency: one probe is 10s-bounded and a
519
- // failure is shown as a probe failure). Four keeps the status call quick
520
- // while staying inside the concurrency such a middlebox tolerates (#1677).
521
- const latestList = await probeLatestVersions(names, deps.fetchLatest)
522
- const packages: UpdatePackageStatus[] = []
523
- let probeFailures = 0
524
- names.forEach((name, index) => {
525
- const latest = latestList[index]
526
- if (latest === undefined) probeFailures++
527
- const current = readInstalledVersion(deps.resolve, name, profile.dir)
528
- packages.push({
529
- name,
530
- current,
531
- latest,
532
- outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
533
- })
534
- })
535
- // Registry unreachable: every probe failed — report the outage distinctly
536
- // instead of a misleading "all up to date" (the panel needs the reason).
537
- const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
538
- const targetVersion = packages.find(packageStatus => packageStatus.latest !== undefined)?.latest
539
- const notes = targetVersion !== undefined && deps.fetchReleaseNotes !== undefined
540
- ? await deps.fetchReleaseNotes(targetVersion).catch(() => undefined)
541
- : undefined
542
- return {
543
- mode: linked ? 'link' : 'npm',
544
- profileName: profile.name,
545
- anchor,
546
- packages,
547
- outdated: packages.some(packageStatus => packageStatus.outdated),
548
- ...(notes !== undefined ? { notes } : {}),
549
- ...(error !== undefined ? { error } : {}),
550
- }
551
- }
552
-
553
- /** Structured failure codes the browser half translates. */
554
- export type UpdateErrorCode =
555
- /** pnpm is not on PATH. */
556
- | 'pnpm-missing'
557
- /** The install exceeded the hard timeout. */
558
- | 'timeout'
559
- /** The anchor package is not installed. */
560
- | 'not-found'
561
- /** The anchor is a local link/dev install pnpm cannot update. */
562
- | 'link'
563
- /** pnpm exited non-zero. */
564
- | 'pnpm-failed'
565
- /** pnpm exited 0 but the installed versions did not move. */
566
- | 'stale'
567
- /** pnpm exited 0 but the post-run version check could not verify the install. */
568
- | 'verify-failed'
569
-
570
- /** Result of one update run. */
571
- export interface UpdateRunResult {
572
- ok: boolean
573
- /** pnpm exit code (null when the process never started or was killed). */
574
- exitCode: number | null
575
- /** Captured pnpm output tail (diagnostics for the panel). */
576
- output: string
577
- /** Human-readable failure description (fallback copy). */
578
- error?: string
579
- /** Structured failure code (translated by the browser half). */
580
- errorCode?: UpdateErrorCode
581
- }
582
-
583
- /** Dependency-injection seam for runUpdate. */
584
- export interface UpdateRunDeps {
585
- /** The profile directory pnpm runs in. */
586
- profileDir: string
587
- /** The package names pnpm updates. */
588
- packages: readonly string[]
589
- /** Spawn seam (defaults to child_process.spawn). */
590
- spawnImpl?: typeof spawn
591
- /** Hard timeout; the child is killed on expiry. */
592
- timeoutMs?: number
593
- /** Platform override (defaults to process.platform; test seam). */
594
- platform?: NodeJS.Platform
595
- /** Process-group kill seam (defaults to process.kill; test seam). */
596
- killImpl?: KillImpl
597
- }
598
-
599
- /** Cap on captured pnpm output (keeps error payloads bounded). */
600
- const OUTPUT_CAP = 16 * 1024
601
-
602
- /**
603
- * Characters a package name may contain before it is handed to the update
604
- * command line. Deliberately looser than the strict family shape (the tests
605
- * and future callers use short names) but it admits no whitespace, quotes,
606
- * shell metacharacters, or path separators beyond the scope slash.
607
- */
608
- const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/
609
-
610
- /** Seam for the process-group kill (tests inject a recorder). */
611
- export type KillImpl = (pid: number, signal: NodeJS.Signals) => void
612
-
613
- /**
614
- * Windows cmd command-not-found stderr. With shell:true a missing shim
615
- * exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
616
- * detects this message instead of the spawn error event. Supports both English
617
- * and localized (e.g. Chinese) cmd error messages.
618
- */
619
- const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i
620
-
621
- /**
622
- * Fold one decoded candidate output into the running total under the display
623
- * cap (the tail carries the failure reason).
624
- * @param output - the accumulated text.
625
- * @param text - the candidate's decoded text.
626
- */
627
- function capOutput(output: string, text: string): string {
628
- const combined = output + text
629
- return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined
630
- }
631
-
632
- /**
633
- * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
634
- * silently skips same-day releases — `pnpm update --latest` then exits 0
635
- * without moving anything and the post-run verification would misreport a
636
- * stale no-op. The update here is explicitly user-initiated and the panel
637
- * shows exactly which versions are being installed, so the gate only adds a
638
- * confusing silent no-op; override it per-invocation instead of asking every
639
- * user to edit the profile's pnpm-workspace.yaml.
640
- */
641
- const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
642
-
643
- /**
644
- * Run the update inside the profile directory. Tries pnpm first, falls back
645
- * to corepack and then npx when the previous command is missing (ENOENT);
646
- * all candidates share one hard timeout and keep accumulating output.
647
- * @param deps - profile dir, package list, and spawn/timeout seams.
648
- * @returns the outcome with captured output.
649
- */
650
- export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
651
- return new Promise((resolve) => {
652
- const spawnImpl = deps.spawnImpl ?? spawn
653
- const platform = deps.platform ?? process.platform
654
- // The names come from the profile/anchor manifests and the win32 candidate
655
- // runs through a shell, so anything outside the npm name shape is dropped
656
- // instead of being concatenated into a command line (a dependency key
657
- // containing shell metacharacters would otherwise run as a second command).
658
- const packages = deps.packages.filter(name => SHELL_SAFE_PACKAGE_RE.test(name))
659
- // Windows ships pnpm/corepack/npx as .cmd shims; Node's spawn cannot
660
- // start .cmd files directly (ENOENT even when installed), so route
661
- // them through cmd.exe there. POSIX spawns stay shell-free.
662
- // POSIX: the child leads its own process group (detached) so the timeout
663
- // can signal pnpm's whole tree — pnpm spawns lifecycle-script children that
664
- // a direct SIGTERM to the wrapper leaves running against the profile.
665
- const spawnOptions: Record<string, unknown> = {
666
- cwd: deps.profileDir,
667
- stdio: ['ignore', 'pipe', 'pipe'],
668
- ...(platform === 'win32' ? { shell: true } : { detached: true }),
669
- }
670
- const killImpl: KillImpl = deps.killImpl ?? process.kill
671
- // Ordered fallback chain: each is tried only when the previous one is
672
- // missing on PATH (ENOENT on the spawn error event, never on close).
673
- // `--latest` is required: dsh plugin writes exact-version specs (e.g.
674
- // "0.1.12" without a range), and plain `pnpm update` treats an exact spec
675
- // as pinned — it prints "Already up to date" and exits 0 without moving
676
- // the installed version, so the panel would report a false success.
677
- // `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
678
- // pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
679
- // releases in place, which the post-run verification would then have to
680
- // surface as `stale` even though the update is legitimate.
681
- const candidates: ReadonlyArray<{ command: string; args: string[] }> = [
682
- { command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
683
- { command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
684
- { command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
685
- ]
686
- // `output` accumulates across candidates for UI display; `currentOutput`
687
- // is reset per candidate and carries only that candidate's own diagnostics
688
- // (its tail is subject to OUTPUT_CAP). The win32 missing-command test must
689
- // run against `currentOutput` so a previous candidate's 'not recognized'
690
- // stderr cannot misclassify a real failure in the next one.
691
- // Each candidate accumulates raw bytes and is decoded exactly once
692
- // (console-output.ts): decoding per `data` event splits a multi-byte
693
- // character that straddles two reads into replacement characters, which is
694
- // what the panel then shows as garbled pnpm diagnostics.
695
- let output = ''
696
- let capture = createOutputCapture(OUTPUT_CAP)
697
- let candidateFolded = false
698
- const append = (chunk: Buffer): void => { capture.push(chunk) }
699
- /** This candidate's decoded text (idempotent). */
700
- const candidateText = (): string => capture.read()
701
- /** Fold this candidate's text into the running output exactly once. */
702
- const foldCandidate = (): void => {
703
- if (candidateFolded) return
704
- candidateFolded = true
705
- output = capOutput(output, capture.read())
706
- }
707
- let currentChild: ReturnType<typeof spawnImpl> | undefined
708
- // Terminal guard: once a result is produced the promise is settled and no
709
- // further candidate is started (a killed child's late close must not
710
- // respawn anything after a timeout resolution).
711
- let finished = false
712
- const finish = (result: UpdateRunResult): void => {
713
- if (finished) return
714
- finished = true
715
- clearTimeout(timer)
716
- resolve(result)
717
- }
718
- const timer = setTimeout(() => {
719
- if (finished) return
720
- if (platform === 'win32') {
721
- // cmd.exe under shell:true only kills the shell wrapper; kill the whole
722
- // process tree best-effort so pnpm/npx do not keep running.
723
- const pid = (currentChild as { pid?: number } | undefined)?.pid
724
- if (pid !== undefined && pid > 0) {
725
- try {
726
- // A spawn failure arrives as an asynchronous 'error' event, which
727
- // the try/catch cannot see; an emitter with no error listener throws
728
- // and would take the host process down on this path.
729
- const killer = spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' })
730
- killer.on?.('error', () => {})
731
- } catch {
732
- // Best-effort kill; fall through to the timeout result.
733
- }
734
- }
735
- } else {
736
- // Detached child: its process-group id is its pid, so the negative pid
737
- // reaches pnpm's grandchildren too. A group kill throws when the child
738
- // already exited; fall back to the direct kill.
739
- const pid = (currentChild as { pid?: number } | undefined)?.pid
740
- if (pid !== undefined && pid > 0) {
741
- try {
742
- killImpl(-pid, 'SIGTERM')
743
- } catch {
744
- try { currentChild?.kill('SIGTERM') } catch { /* already gone */ }
745
- }
746
- } else {
747
- currentChild?.kill('SIGTERM')
748
- }
749
- }
750
- foldCandidate()
751
- finish({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' })
752
- }, deps.timeoutMs ?? 10 * 60_000)
753
- const runCandidate = (index: number): void => {
754
- if (finished) return
755
- if (index >= candidates.length) {
756
- finish({
757
- ok: false,
758
- exitCode: null,
759
- output,
760
- error: 'pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app',
761
- errorCode: 'pnpm-missing',
762
- })
763
- return
764
- }
765
- const candidate = candidates[index]
766
- capture = createOutputCapture(OUTPUT_CAP)
767
- candidateFolded = false
768
- const child = spawnImpl(candidate.command, candidate.args, spawnOptions)
769
- currentChild = child
770
- let settled = false
771
- const once = (fn: () => void): void => {
772
- if (settled) return
773
- settled = true
774
- fn()
775
- }
776
- child.stdout?.on('data', append)
777
- child.stderr?.on('data', append)
778
- child.on('error', (error: Error) => {
779
- once(() => {
780
- foldCandidate()
781
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
782
- // Command missing: try the next candidate in the chain.
783
- runCandidate(index + 1)
784
- } else {
785
- finish({ ok: false, exitCode: null, output, error: error.message, errorCode: undefined })
786
- }
787
- })
788
- })
789
- child.on('close', (code: number | null) => {
790
- if (settled || finished) return
791
- // Windows + shell: a missing command reports exit 1 or 9009 with
792
- // 'not recognized' or localized message instead of ENOENT — keep the
793
- // chain going. Checked against this candidate's own output so a prior
794
- // fallback's stderr cannot misclassify a real failure here.
795
- if (platform === 'win32' && (code === 9009 || (code !== 0 && WIN_CMD_MISSING_RE.test(candidateText())))) {
796
- foldCandidate()
797
- runCandidate(index + 1)
798
- return
799
- }
800
- settled = true
801
- foldCandidate()
802
- finish({
803
- ok: code === 0,
804
- exitCode: code,
805
- output,
806
- error: code === 0 ? undefined : `${candidate.command} exited with code ` + String(code),
807
- ...(code === 0 ? {} : { errorCode: 'pnpm-failed' as const }),
808
- })
809
- })
810
- }
811
- runCandidate(0)
812
- })
813
- }
814
-
815
- /** Seam set for the verified update run (run + post-run status check). */
816
- export interface UpdateRunVerifiedDeps {
817
- /** The pnpm run (profile dir + package list). */
818
- run: UpdateRunDeps
819
- /** The post-run status check (same seams as checkUpdates). */
820
- check: UpdateCheckDeps
821
- }
822
-
823
- /**
824
- * Run the update, then verify the installed versions actually moved. pnpm
825
- * exits 0 even when it silently kept the installed versions — runUpdate
826
- * overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
827
- * `--config.minimumReleaseAge=0`, but an older pnpm without the override or
828
- * another silent no-op can still keep versions in place — so a green exit
829
- * alone must not report a misleading "update complete". Re-read the
830
- * installed versions afterwards and surface a `stale` failure (with the
831
- * captured pnpm output) when nothing moved, so the panel can tell the user
832
- * how to unblock the gate instead of claiming success.
833
- *
834
- * The stale decision anchors on the pre-run installed versions, not on the
835
- * registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
836
- * latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
837
- * path is re-resolved rather than reused from boot time: pnpm removes the old
838
- * version's .pnpm directory on update, so a boot-time captured path would
839
- * fail to read and collapse a successful update into a bogus 'missing'.
840
- *
841
- * A green exit whose verification has nothing to compare (registry probe
842
- * outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
843
- * must never read as success. A green exit where no package moved but the
844
- * post-run check could not prove the install is fully current (a partial
845
- * probe failure hides whether a gate kept something back) is also
846
- * `verify-failed`: it must not collapse into a success either.
847
- * @param deps - the run and check seams.
848
- * @returns the run result; `stale` when exit 0 left every version in place,
849
- * `verify-failed` when the post-run check could not verify anything.
850
- */
851
- export async function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<UpdateRunResult> {
852
- // Pre-run snapshot: the installed versions the update starts from. Only
853
- // successfully-read versions are recorded — a read failure (VERSION_UNKNOWN)
854
- // is not a baseline and must not look like a version that "moved", or a
855
- // green exit that left everything in place could be mistaken for success.
856
- const before = new Map<string, string>()
857
- for (const name of deps.run.packages) {
858
- const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir)
859
- if (version !== VERSION_UNKNOWN) before.set(name, version)
860
- }
861
- const result = await runUpdate(deps.run)
862
- if (!result.ok) return result
863
- // Re-resolve the anchor after the run: pnpm removes the old version's
864
- // .pnpm directory, so a boot-time captured path no longer reads. Fall back
865
- // to the provided path only when nothing resolves now.
866
- const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath
867
- const status = await checkUpdates({ ...deps.check, anchorManifestPath })
868
- // A green exit with no comparable result is not a success: registry probe
869
- // outage, missing anchor, or a non-npm install mode all mean the post-run
870
- // check could not verify anything.
871
- if (status.error !== undefined || status.mode !== 'npm') {
872
- return {
873
- ...result,
874
- ok: false,
875
- errorCode: 'verify-failed',
876
- error: 'pnpm exited 0 but the post-run version check could not verify the install',
877
- }
878
- }
879
- // A package "moved" only when a known pre-run version differs from a known
880
- // post-run version. A read failure on either side (VERSION_UNKNOWN) is not
881
- // evidence of movement and must not turn a no-op update into a success; a
882
- // package without a pre-run baseline is ignored — only the packages pnpm
883
- // was told to update count as evidence.
884
- const moved = status.packages.some(packageStatus => {
885
- const beforeVersion = before.get(packageStatus.name)
886
- if (beforeVersion === undefined || packageStatus.current === VERSION_UNKNOWN) return false
887
- return packageStatus.current !== beforeVersion
888
- })
889
- if (moved) return result
890
- // Nothing moved. A green exit is only a true no-op when the post-run check
891
- // proves the install is already current: every probe succeeded and nothing
892
- // is outdated. If a probe failed we cannot tell "already up to date" from
893
- // "a gate silently kept it back", so report verify-failed rather than a
894
- // false success.
895
- if (status.outdated) {
896
- // stale: the check ran, a newer release exists, and no package moved
897
- // (e.g. the minimumReleaseAge gate kept everything in place).
898
- return {
899
- ...result,
900
- ok: false,
901
- errorCode: 'stale',
902
- error: 'pnpm exited 0 but the installed versions did not change',
903
- }
904
- }
905
- const unverifiable = status.packages.some(packageStatus => packageStatus.latest === undefined)
906
- if (unverifiable) {
907
- return {
908
- ...result,
909
- ok: false,
910
- errorCode: 'verify-failed',
911
- error: 'pnpm exited 0 but the post-run version check could not verify the install',
912
- }
913
- }
914
- return result
915
- }