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