@linxin666/dsh-remote-web-ui 0.4.1 → 0.4.3

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 (91) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -7
  3. package/README.zh.md +9 -7
  4. package/lib/client.js +282 -619
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +418 -901
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +9 -10
  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 +56 -11
  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 +2 -86
  17. package/lib/types/client/pair-api.d.ts +4 -0
  18. package/lib/types/client/pair-api.d.ts.map +1 -1
  19. package/lib/types/client/pair-api.js +11 -7
  20. package/lib/types/client/remote-channel.d.ts +27 -7
  21. package/lib/types/client/remote-channel.d.ts.map +1 -1
  22. package/lib/types/client/remote-channel.js +25 -17
  23. package/lib/types/client/settings-entry-form.d.ts +50 -0
  24. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  25. package/lib/types/client/settings-entry-form.js +98 -0
  26. package/lib/types/index.d.ts +99 -7
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +104 -137
  29. package/lib/types/lan-bind-plan.d.ts +14 -0
  30. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  31. package/lib/types/lan-bind-plan.js +16 -0
  32. package/lib/types/lan-bind.d.ts +5 -1
  33. package/lib/types/lan-bind.d.ts.map +1 -1
  34. package/lib/types/lan-bind.js +65 -2
  35. package/lib/types/mount-once.d.ts +38 -7
  36. package/lib/types/mount-once.d.ts.map +1 -1
  37. package/lib/types/mount-once.js +96 -12
  38. package/lib/types/pairing.d.ts +15 -2
  39. package/lib/types/pairing.d.ts.map +1 -1
  40. package/lib/types/pairing.js +26 -14
  41. package/lib/types/pairing.persist.test.js +43 -3
  42. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  43. package/lib/types/remote-channel-boot.js +13 -3
  44. package/lib/types/remote-channel-rules.d.ts +74 -0
  45. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  46. package/lib/types/remote-channel-rules.js +104 -0
  47. package/lib/types/tunnel.d.ts +60 -3
  48. package/lib/types/tunnel.d.ts.map +1 -1
  49. package/lib/types/tunnel.js +136 -3
  50. package/package.json +21 -20
  51. package/src/client/RemoteEntry.tsx +8 -10
  52. package/src/client/RemoteSettingsCard.tsx +0 -2
  53. package/src/client/index.ts +58 -12
  54. package/src/client/locales.ts +2 -86
  55. package/src/client/pair-api.ts +11 -7
  56. package/src/client/remote-channel.ts +38 -15
  57. package/src/client/remote.module.css +61 -193
  58. package/src/client/settings-entry-form.ts +142 -0
  59. package/src/index.ts +156 -152
  60. package/src/lan-bind-plan.ts +22 -0
  61. package/src/lan-bind.ts +63 -2
  62. package/src/mount-once.ts +107 -13
  63. package/src/pairing.persist.test.ts +47 -3
  64. package/src/pairing.ts +26 -12
  65. package/src/remote-channel-boot.ts +13 -3
  66. package/src/remote-channel-rules.ts +113 -0
  67. package/src/tunnel.ts +142 -3
  68. package/lib/types/client/UpdateEntry.d.ts +0 -14
  69. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  70. package/lib/types/client/UpdateEntry.js +0 -112
  71. package/lib/types/client/UpdatePanel.d.ts +0 -42
  72. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  73. package/lib/types/client/UpdatePanel.js +0 -101
  74. package/lib/types/client/update-api.d.ts +0 -29
  75. package/lib/types/client/update-api.d.ts.map +0 -1
  76. package/lib/types/client/update-api.js +0 -47
  77. package/lib/types/console-output.d.ts +0 -41
  78. package/lib/types/console-output.d.ts.map +0 -1
  79. package/lib/types/console-output.js +0 -89
  80. package/lib/types/update-routes.d.ts +0 -30
  81. package/lib/types/update-routes.d.ts.map +0 -1
  82. package/lib/types/update-routes.js +0 -47
  83. package/lib/types/update.d.ts +0 -272
  84. package/lib/types/update.d.ts.map +0 -1
  85. package/lib/types/update.js +0 -744
  86. package/src/client/UpdateEntry.tsx +0 -144
  87. package/src/client/UpdatePanel.tsx +0 -256
  88. package/src/client/update-api.ts +0 -49
  89. package/src/console-output.ts +0 -97
  90. package/src/update-routes.ts +0 -65
  91. package/src/update.ts +0 -882
package/src/update.ts DELETED
@@ -1,882 +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
- /**
311
- * Probe the npm registry for one package's latest release.
312
- * @param name - the package name (scope slash URL-encoded).
313
- * @param fetchImpl - the fetch implementation (global fetch in the host).
314
- * @param timeoutMs - probe timeout.
315
- * @returns the latest version string, or undefined on any failure.
316
- */
317
- export async function fetchLatestVersion(
318
- name: string,
319
- fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
320
- timeoutMs = 10_000,
321
- ): Promise<string | undefined> {
322
- try {
323
- const controller = new AbortController()
324
- const timer = setTimeout(() => { controller.abort() }, timeoutMs)
325
- try {
326
- const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', withIdentityEncoding({ signal: controller.signal }))
327
- if (!response.ok) return undefined
328
- const body = await response.json()
329
- if (typeof body !== 'object' || body === null) return undefined
330
- const version = (body as Record<string, unknown>).version
331
- return typeof version === 'string' ? version : undefined
332
- } finally {
333
- clearTimeout(timer)
334
- }
335
- } catch {
336
- return undefined
337
- }
338
- }
339
-
340
- /** Normalize one release-note bullet. */
341
- function cleanNoteItem(text: string): string {
342
- // Keep the visible label and drop Markdown link targets; issue references
343
- // become plain #123 because the panel has no Markdown renderer.
344
- return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^[-*]\s+/, '').trim()
345
- }
346
-
347
- /** Determine the section kind for a Markdown heading line. */
348
- function noteSectionOf(line: string): keyof Pick<UpdateReleaseNotes, 'features' | 'fixes' | 'other'> | undefined {
349
- const heading = /^#{1,6}\s+(.+)$/.exec(line.trim())
350
- if (heading === null) return undefined
351
- const title = heading[1].replace(/[::].*$/, '').trim().toLowerCase()
352
- if (title === '新功能' || title === 'new features') return 'features'
353
- if (title === '修复' || title === 'bug fixes' || title === 'bugfixes') return 'fixes'
354
- if (title === '其他改动' || title === 'other changes') return 'other'
355
- return undefined
356
- }
357
-
358
- /**
359
- * Parse the GitHub Release body into the three conventional sections.
360
- *
361
- * The committed release notes are bilingual: Chinese is the default view and
362
- * English lives inside a `<details>` block. Prefer the Chinese sections when
363
- * present and fall back to the whole body for English-only releases. Bullets
364
- * are escaped as plain text for the panel (no Markdown renderer).
365
- */
366
- function parseReleaseNotesSource(version: string, source: string): UpdateReleaseNotes | undefined {
367
- const notes: UpdateReleaseNotes = { version, features: [], fixes: [], other: [] }
368
- let current: keyof Pick<UpdateReleaseNotes, 'features' | 'fixes' | 'other'> | undefined
369
- for (const rawLine of source.split(/\r?\n/)) {
370
- const line = rawLine.trim()
371
- const section = noteSectionOf(line)
372
- if (section !== undefined) {
373
- current = section
374
- continue
375
- }
376
- if (current === undefined || !/^-\s+/.test(line)) continue
377
- const item = cleanNoteItem(line)
378
- if (item !== '') notes[current].push(item)
379
- }
380
- if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0) return undefined
381
- return notes
382
- }
383
-
384
- export function parseReleaseNotesBody(version: string, body: string): UpdateReleaseNotes | undefined {
385
- // The committed release notes put Chinese in the default view and English in
386
- // a <details> block. Parse only the default view first, then fall back to
387
- // the full body for English-only releases.
388
- return parseReleaseNotesSource(version, body.split('<details>')[0] ?? body)
389
- ?? parseReleaseNotesSource(version, body)
390
- }
391
-
392
- /**
393
- * Fetch and parse the GitHub Release for one `dsh-web` version.
394
- * @param version - the npm release version (without the `v` prefix).
395
- * @param fetchImpl - injected fetch; global `fetch` in the host.
396
- * @param timeoutMs - hard timeout for the GitHub API probe.
397
- * @returns structured notes, or undefined when unavailable.
398
- */
399
- export async function fetchGitHubReleaseNotes(
400
- version: string,
401
- fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }> = fetch,
402
- timeoutMs = 10_000,
403
- ): Promise<UpdateReleaseNotes | undefined> {
404
- const controller = new AbortController()
405
- const timer = setTimeout(() => { controller.abort() }, timeoutMs)
406
- try {
407
- const response = await fetchImpl(
408
- `https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`,
409
- withIdentityEncoding({
410
- headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-web-update' },
411
- signal: controller.signal,
412
- }),
413
- )
414
- if (!response.ok) return undefined
415
- const body = await response.json()
416
- if (typeof body !== 'object' || body === null) return undefined
417
- const text = (body as Record<string, unknown>).body
418
- return typeof text === 'string' ? parseReleaseNotesBody(version, text) : undefined
419
- } catch {
420
- return undefined
421
- } finally {
422
- clearTimeout(timer)
423
- }
424
- }
425
-
426
- /**
427
- * Sentinel for an installed version that could not be read (missing manifest
428
- * or resolve failure). It is a real-looking version so `checkUpdates` can
429
- * render the affected row, but the verified-update comparison must never
430
- * treat it as evidence — a read failure is not a version that "moved".
431
- */
432
- const VERSION_UNKNOWN = '0.0.0'
433
-
434
- /** The resolved current version of one family package (probe failure tolerated). */
435
- function readInstalledVersion(
436
- resolve: (specifier: string) => string | undefined,
437
- name: string,
438
- profileDir?: string,
439
- ): string {
440
- try {
441
- const path = resolve(name + '/package.json')
442
- const version = path === undefined ? undefined : readManifest(path)?.version
443
- if (typeof version === 'string') return version
444
- } catch { /* fall through to the profile's direct dependency path */ }
445
- // Names originate in the profile dependency map, but still validate the
446
- // npm package shape before using one as path segments.
447
- if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN
448
- const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version
449
- return typeof version === 'string' ? version : VERSION_UNKNOWN
450
- }
451
-
452
- /**
453
- * Build the update status: locate the anchor, detect the install mode, and
454
- * compare every family package against the npm registry.
455
- * @param deps - manifest resolution + registry probe seams.
456
- * @returns the status snapshot.
457
- */
458
- export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus> {
459
- const manifestPath = deps.anchorManifestPath
460
- if (manifestPath === undefined) {
461
- return { mode: 'missing', packages: [], outdated: false }
462
- }
463
- const manifest = readManifest(manifestPath)
464
- if (manifest === undefined) {
465
- return { mode: 'missing', packages: [], outdated: false }
466
- }
467
- const anchor = typeof manifest.name === 'string' ? manifest.name : undefined
468
- if (anchor === undefined) {
469
- return { mode: 'missing', packages: [], outdated: false }
470
- }
471
- // A package inside a dsh profile directory is a registry install; one that
472
- // lives outside every profile (e.g. a repo checkout wired through
473
- // link-profile.mjs) is a local dev install pnpm cannot update.
474
- const profile = findProfile(manifestPath)
475
- const profileManifest = profile === undefined ? undefined : readManifest(join(profile.dir, "package.json"))
476
- const linked = profile === undefined
477
- || isLinkedSpec((profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor])
478
- || hasLinkedFamilyOverride(manifest, profileManifest)
479
- if (profile === undefined) {
480
- return { mode: 'link', packages: [], outdated: false }
481
- }
482
- // Union the profile's direct family deps so standalone installs (no
483
- // aggregate) still check every installed @linxin666/* plugin (#377);
484
- // familyUpdatePackages skips link:/file: development dependencies.
485
- const names = familyUpdatePackages(anchor, manifest, profileManifest)
486
- // The registry probes are independent: run them together instead of
487
- // serializing up to N x 10s of registry latency behind one status call.
488
- const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)))
489
- const packages: UpdatePackageStatus[] = []
490
- let probeFailures = 0
491
- names.forEach((name, index) => {
492
- const latest = latestList[index]
493
- if (latest === undefined) probeFailures++
494
- const current = readInstalledVersion(deps.resolve, name, profile.dir)
495
- packages.push({
496
- name,
497
- current,
498
- latest,
499
- outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
500
- })
501
- })
502
- // Registry unreachable: every probe failed — report the outage distinctly
503
- // instead of a misleading "all up to date" (the panel needs the reason).
504
- const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
505
- const targetVersion = packages.find(packageStatus => packageStatus.latest !== undefined)?.latest
506
- const notes = targetVersion !== undefined && deps.fetchReleaseNotes !== undefined
507
- ? await deps.fetchReleaseNotes(targetVersion).catch(() => undefined)
508
- : undefined
509
- return {
510
- mode: linked ? 'link' : 'npm',
511
- profileName: profile.name,
512
- anchor,
513
- packages,
514
- outdated: packages.some(packageStatus => packageStatus.outdated),
515
- ...(notes !== undefined ? { notes } : {}),
516
- ...(error !== undefined ? { error } : {}),
517
- }
518
- }
519
-
520
- /** Structured failure codes the browser half translates. */
521
- export type UpdateErrorCode =
522
- /** pnpm is not on PATH. */
523
- | 'pnpm-missing'
524
- /** The install exceeded the hard timeout. */
525
- | 'timeout'
526
- /** The anchor package is not installed. */
527
- | 'not-found'
528
- /** The anchor is a local link/dev install pnpm cannot update. */
529
- | 'link'
530
- /** pnpm exited non-zero. */
531
- | 'pnpm-failed'
532
- /** pnpm exited 0 but the installed versions did not move. */
533
- | 'stale'
534
- /** pnpm exited 0 but the post-run version check could not verify the install. */
535
- | 'verify-failed'
536
-
537
- /** Result of one update run. */
538
- export interface UpdateRunResult {
539
- ok: boolean
540
- /** pnpm exit code (null when the process never started or was killed). */
541
- exitCode: number | null
542
- /** Captured pnpm output tail (diagnostics for the panel). */
543
- output: string
544
- /** Human-readable failure description (fallback copy). */
545
- error?: string
546
- /** Structured failure code (translated by the browser half). */
547
- errorCode?: UpdateErrorCode
548
- }
549
-
550
- /** Dependency-injection seam for runUpdate. */
551
- export interface UpdateRunDeps {
552
- /** The profile directory pnpm runs in. */
553
- profileDir: string
554
- /** The package names pnpm updates. */
555
- packages: readonly string[]
556
- /** Spawn seam (defaults to child_process.spawn). */
557
- spawnImpl?: typeof spawn
558
- /** Hard timeout; the child is killed on expiry. */
559
- timeoutMs?: number
560
- /** Platform override (defaults to process.platform; test seam). */
561
- platform?: NodeJS.Platform
562
- /** Process-group kill seam (defaults to process.kill; test seam). */
563
- killImpl?: KillImpl
564
- }
565
-
566
- /** Cap on captured pnpm output (keeps error payloads bounded). */
567
- const OUTPUT_CAP = 16 * 1024
568
-
569
- /**
570
- * Characters a package name may contain before it is handed to the update
571
- * command line. Deliberately looser than the strict family shape (the tests
572
- * and future callers use short names) but it admits no whitespace, quotes,
573
- * shell metacharacters, or path separators beyond the scope slash.
574
- */
575
- const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/
576
-
577
- /** Seam for the process-group kill (tests inject a recorder). */
578
- export type KillImpl = (pid: number, signal: NodeJS.Signals) => void
579
-
580
- /**
581
- * Windows cmd command-not-found stderr. With shell:true a missing shim
582
- * exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
583
- * detects this message instead of the spawn error event. Supports both English
584
- * and localized (e.g. Chinese) cmd error messages.
585
- */
586
- const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i
587
-
588
- /**
589
- * Fold one decoded candidate output into the running total under the display
590
- * cap (the tail carries the failure reason).
591
- * @param output - the accumulated text.
592
- * @param text - the candidate's decoded text.
593
- */
594
- function capOutput(output: string, text: string): string {
595
- const combined = output + text
596
- return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined
597
- }
598
-
599
- /**
600
- * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
601
- * silently skips same-day releases — `pnpm update --latest` then exits 0
602
- * without moving anything and the post-run verification would misreport a
603
- * stale no-op. The update here is explicitly user-initiated and the panel
604
- * shows exactly which versions are being installed, so the gate only adds a
605
- * confusing silent no-op; override it per-invocation instead of asking every
606
- * user to edit the profile's pnpm-workspace.yaml.
607
- */
608
- const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
609
-
610
- /**
611
- * Run the update inside the profile directory. Tries pnpm first, falls back
612
- * to corepack and then npx when the previous command is missing (ENOENT);
613
- * all candidates share one hard timeout and keep accumulating output.
614
- * @param deps - profile dir, package list, and spawn/timeout seams.
615
- * @returns the outcome with captured output.
616
- */
617
- export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
618
- return new Promise((resolve) => {
619
- const spawnImpl = deps.spawnImpl ?? spawn
620
- const platform = deps.platform ?? process.platform
621
- // The names come from the profile/anchor manifests and the win32 candidate
622
- // runs through a shell, so anything outside the npm name shape is dropped
623
- // instead of being concatenated into a command line (a dependency key
624
- // containing shell metacharacters would otherwise run as a second command).
625
- const packages = deps.packages.filter(name => SHELL_SAFE_PACKAGE_RE.test(name))
626
- // Windows ships pnpm/corepack/npx as .cmd shims; Node's spawn cannot
627
- // start .cmd files directly (ENOENT even when installed), so route
628
- // them through cmd.exe there. POSIX spawns stay shell-free.
629
- // POSIX: the child leads its own process group (detached) so the timeout
630
- // can signal pnpm's whole tree — pnpm spawns lifecycle-script children that
631
- // a direct SIGTERM to the wrapper leaves running against the profile.
632
- const spawnOptions: Record<string, unknown> = {
633
- cwd: deps.profileDir,
634
- stdio: ['ignore', 'pipe', 'pipe'],
635
- ...(platform === 'win32' ? { shell: true } : { detached: true }),
636
- }
637
- const killImpl: KillImpl = deps.killImpl ?? process.kill
638
- // Ordered fallback chain: each is tried only when the previous one is
639
- // missing on PATH (ENOENT on the spawn error event, never on close).
640
- // `--latest` is required: dsh plugin writes exact-version specs (e.g.
641
- // "0.1.12" without a range), and plain `pnpm update` treats an exact spec
642
- // as pinned — it prints "Already up to date" and exits 0 without moving
643
- // the installed version, so the panel would report a false success.
644
- // `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
645
- // pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
646
- // releases in place, which the post-run verification would then have to
647
- // surface as `stale` even though the update is legitimate.
648
- const candidates: ReadonlyArray<{ command: string; args: string[] }> = [
649
- { command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
650
- { command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
651
- { command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
652
- ]
653
- // `output` accumulates across candidates for UI display; `currentOutput`
654
- // is reset per candidate and carries only that candidate's own diagnostics
655
- // (its tail is subject to OUTPUT_CAP). The win32 missing-command test must
656
- // run against `currentOutput` so a previous candidate's 'not recognized'
657
- // stderr cannot misclassify a real failure in the next one.
658
- // Each candidate accumulates raw bytes and is decoded exactly once
659
- // (console-output.ts): decoding per `data` event splits a multi-byte
660
- // character that straddles two reads into replacement characters, which is
661
- // what the panel then shows as garbled pnpm diagnostics.
662
- let output = ''
663
- let capture = createOutputCapture(OUTPUT_CAP)
664
- let candidateFolded = false
665
- const append = (chunk: Buffer): void => { capture.push(chunk) }
666
- /** This candidate's decoded text (idempotent). */
667
- const candidateText = (): string => capture.read()
668
- /** Fold this candidate's text into the running output exactly once. */
669
- const foldCandidate = (): void => {
670
- if (candidateFolded) return
671
- candidateFolded = true
672
- output = capOutput(output, capture.read())
673
- }
674
- let currentChild: ReturnType<typeof spawnImpl> | undefined
675
- // Terminal guard: once a result is produced the promise is settled and no
676
- // further candidate is started (a killed child's late close must not
677
- // respawn anything after a timeout resolution).
678
- let finished = false
679
- const finish = (result: UpdateRunResult): void => {
680
- if (finished) return
681
- finished = true
682
- clearTimeout(timer)
683
- resolve(result)
684
- }
685
- const timer = setTimeout(() => {
686
- if (finished) return
687
- if (platform === 'win32') {
688
- // cmd.exe under shell:true only kills the shell wrapper; kill the whole
689
- // process tree best-effort so pnpm/npx do not keep running.
690
- const pid = (currentChild as { pid?: number } | undefined)?.pid
691
- if (pid !== undefined && pid > 0) {
692
- try {
693
- // A spawn failure arrives as an asynchronous 'error' event, which
694
- // the try/catch cannot see; an emitter with no error listener throws
695
- // and would take the host process down on this path.
696
- const killer = spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' })
697
- killer.on?.('error', () => {})
698
- } catch {
699
- // Best-effort kill; fall through to the timeout result.
700
- }
701
- }
702
- } else {
703
- // Detached child: its process-group id is its pid, so the negative pid
704
- // reaches pnpm's grandchildren too. A group kill throws when the child
705
- // already exited; fall back to the direct kill.
706
- const pid = (currentChild as { pid?: number } | undefined)?.pid
707
- if (pid !== undefined && pid > 0) {
708
- try {
709
- killImpl(-pid, 'SIGTERM')
710
- } catch {
711
- try { currentChild?.kill('SIGTERM') } catch { /* already gone */ }
712
- }
713
- } else {
714
- currentChild?.kill('SIGTERM')
715
- }
716
- }
717
- foldCandidate()
718
- finish({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' })
719
- }, deps.timeoutMs ?? 10 * 60_000)
720
- const runCandidate = (index: number): void => {
721
- if (finished) return
722
- if (index >= candidates.length) {
723
- finish({
724
- ok: false,
725
- exitCode: null,
726
- output,
727
- error: 'pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app',
728
- errorCode: 'pnpm-missing',
729
- })
730
- return
731
- }
732
- const candidate = candidates[index]
733
- capture = createOutputCapture(OUTPUT_CAP)
734
- candidateFolded = false
735
- const child = spawnImpl(candidate.command, candidate.args, spawnOptions)
736
- currentChild = child
737
- let settled = false
738
- const once = (fn: () => void): void => {
739
- if (settled) return
740
- settled = true
741
- fn()
742
- }
743
- child.stdout?.on('data', append)
744
- child.stderr?.on('data', append)
745
- child.on('error', (error: Error) => {
746
- once(() => {
747
- foldCandidate()
748
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
749
- // Command missing: try the next candidate in the chain.
750
- runCandidate(index + 1)
751
- } else {
752
- finish({ ok: false, exitCode: null, output, error: error.message, errorCode: undefined })
753
- }
754
- })
755
- })
756
- child.on('close', (code: number | null) => {
757
- if (settled || finished) return
758
- // Windows + shell: a missing command reports exit 1 or 9009 with
759
- // 'not recognized' or localized message instead of ENOENT — keep the
760
- // chain going. Checked against this candidate's own output so a prior
761
- // fallback's stderr cannot misclassify a real failure here.
762
- if (platform === 'win32' && (code === 9009 || (code !== 0 && WIN_CMD_MISSING_RE.test(candidateText())))) {
763
- foldCandidate()
764
- runCandidate(index + 1)
765
- return
766
- }
767
- settled = true
768
- foldCandidate()
769
- finish({
770
- ok: code === 0,
771
- exitCode: code,
772
- output,
773
- error: code === 0 ? undefined : `${candidate.command} exited with code ` + String(code),
774
- ...(code === 0 ? {} : { errorCode: 'pnpm-failed' as const }),
775
- })
776
- })
777
- }
778
- runCandidate(0)
779
- })
780
- }
781
-
782
- /** Seam set for the verified update run (run + post-run status check). */
783
- export interface UpdateRunVerifiedDeps {
784
- /** The pnpm run (profile dir + package list). */
785
- run: UpdateRunDeps
786
- /** The post-run status check (same seams as checkUpdates). */
787
- check: UpdateCheckDeps
788
- }
789
-
790
- /**
791
- * Run the update, then verify the installed versions actually moved. pnpm
792
- * exits 0 even when it silently kept the installed versions — runUpdate
793
- * overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
794
- * `--config.minimumReleaseAge=0`, but an older pnpm without the override or
795
- * another silent no-op can still keep versions in place — so a green exit
796
- * alone must not report a misleading "update complete". Re-read the
797
- * installed versions afterwards and surface a `stale` failure (with the
798
- * captured pnpm output) when nothing moved, so the panel can tell the user
799
- * how to unblock the gate instead of claiming success.
800
- *
801
- * The stale decision anchors on the pre-run installed versions, not on the
802
- * registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
803
- * latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
804
- * path is re-resolved rather than reused from boot time: pnpm removes the old
805
- * version's .pnpm directory on update, so a boot-time captured path would
806
- * fail to read and collapse a successful update into a bogus 'missing'.
807
- *
808
- * A green exit whose verification has nothing to compare (registry probe
809
- * outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
810
- * must never read as success. A green exit where no package moved but the
811
- * post-run check could not prove the install is fully current (a partial
812
- * probe failure hides whether a gate kept something back) is also
813
- * `verify-failed`: it must not collapse into a success either.
814
- * @param deps - the run and check seams.
815
- * @returns the run result; `stale` when exit 0 left every version in place,
816
- * `verify-failed` when the post-run check could not verify anything.
817
- */
818
- export async function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<UpdateRunResult> {
819
- // Pre-run snapshot: the installed versions the update starts from. Only
820
- // successfully-read versions are recorded — a read failure (VERSION_UNKNOWN)
821
- // is not a baseline and must not look like a version that "moved", or a
822
- // green exit that left everything in place could be mistaken for success.
823
- const before = new Map<string, string>()
824
- for (const name of deps.run.packages) {
825
- const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir)
826
- if (version !== VERSION_UNKNOWN) before.set(name, version)
827
- }
828
- const result = await runUpdate(deps.run)
829
- if (!result.ok) return result
830
- // Re-resolve the anchor after the run: pnpm removes the old version's
831
- // .pnpm directory, so a boot-time captured path no longer reads. Fall back
832
- // to the provided path only when nothing resolves now.
833
- const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath
834
- const status = await checkUpdates({ ...deps.check, anchorManifestPath })
835
- // A green exit with no comparable result is not a success: registry probe
836
- // outage, missing anchor, or a non-npm install mode all mean the post-run
837
- // check could not verify anything.
838
- if (status.error !== undefined || status.mode !== 'npm') {
839
- return {
840
- ...result,
841
- ok: false,
842
- errorCode: 'verify-failed',
843
- error: 'pnpm exited 0 but the post-run version check could not verify the install',
844
- }
845
- }
846
- // A package "moved" only when a known pre-run version differs from a known
847
- // post-run version. A read failure on either side (VERSION_UNKNOWN) is not
848
- // evidence of movement and must not turn a no-op update into a success; a
849
- // package without a pre-run baseline is ignored — only the packages pnpm
850
- // was told to update count as evidence.
851
- const moved = status.packages.some(packageStatus => {
852
- const beforeVersion = before.get(packageStatus.name)
853
- if (beforeVersion === undefined || packageStatus.current === VERSION_UNKNOWN) return false
854
- return packageStatus.current !== beforeVersion
855
- })
856
- if (moved) return result
857
- // Nothing moved. A green exit is only a true no-op when the post-run check
858
- // proves the install is already current: every probe succeeded and nothing
859
- // is outdated. If a probe failed we cannot tell "already up to date" from
860
- // "a gate silently kept it back", so report verify-failed rather than a
861
- // false success.
862
- if (status.outdated) {
863
- // stale: the check ran, a newer release exists, and no package moved
864
- // (e.g. the minimumReleaseAge gate kept everything in place).
865
- return {
866
- ...result,
867
- ok: false,
868
- errorCode: 'stale',
869
- error: 'pnpm exited 0 but the installed versions did not change',
870
- }
871
- }
872
- const unverifiable = status.packages.some(packageStatus => packageStatus.latest === undefined)
873
- if (unverifiable) {
874
- return {
875
- ...result,
876
- ok: false,
877
- errorCode: 'verify-failed',
878
- error: 'pnpm exited 0 but the post-run version check could not verify the install',
879
- }
880
- }
881
- return result
882
- }