@linxin666/dsh-remote-web-ui 0.4.2 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +8 -6
- package/README.zh.md +8 -6
- package/lib/client.js +154 -594
- package/lib/client.js.map +1 -1
- package/lib/index.js +323 -908
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +8 -9
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/index.d.ts +32 -3
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +51 -8
- package/lib/types/client/locales.d.ts +0 -84
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +0 -84
- package/lib/types/client/settings-entry-form.d.ts +50 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -0
- package/lib/types/client/settings-entry-form.js +98 -0
- package/lib/types/index.d.ts +99 -7
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +104 -137
- package/lib/types/lan-bind-plan.d.ts +14 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -1
- package/lib/types/lan-bind-plan.js +16 -0
- package/lib/types/lan-bind.d.ts +1 -1
- package/lib/types/lan-bind.js +1 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/mount-once.d.ts.map +1 -1
- package/lib/types/mount-once.js +96 -12
- package/lib/types/tunnel.d.ts +60 -3
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +136 -3
- package/package.json +2 -2
- package/src/client/RemoteEntry.tsx +7 -9
- package/src/client/RemoteSettingsCard.tsx +0 -2
- package/src/client/index.ts +53 -9
- package/src/client/locales.ts +0 -84
- package/src/client/remote.module.css +50 -200
- package/src/client/settings-entry-form.ts +142 -0
- package/src/index.ts +156 -152
- package/src/lan-bind-plan.ts +22 -0
- package/src/lan-bind.ts +1 -1
- package/src/mount-once.ts +107 -13
- package/src/tunnel.ts +142 -3
- package/lib/types/client/UpdateEntry.d.ts +0 -14
- package/lib/types/client/UpdateEntry.d.ts.map +0 -1
- package/lib/types/client/UpdateEntry.js +0 -112
- package/lib/types/client/UpdatePanel.d.ts +0 -42
- package/lib/types/client/UpdatePanel.d.ts.map +0 -1
- package/lib/types/client/UpdatePanel.js +0 -101
- package/lib/types/client/update-api.d.ts +0 -29
- package/lib/types/client/update-api.d.ts.map +0 -1
- package/lib/types/client/update-api.js +0 -47
- package/lib/types/console-output.d.ts +0 -41
- package/lib/types/console-output.d.ts.map +0 -1
- package/lib/types/console-output.js +0 -89
- package/lib/types/update-routes.d.ts +0 -30
- package/lib/types/update-routes.d.ts.map +0 -1
- package/lib/types/update-routes.js +0 -47
- package/lib/types/update.d.ts +0 -282
- package/lib/types/update.d.ts.map +0 -1
- package/lib/types/update.js +0 -773
- package/src/client/UpdateEntry.tsx +0 -144
- package/src/client/UpdatePanel.tsx +0 -256
- package/src/client/update-api.ts +0 -49
- package/src/console-output.ts +0 -97
- package/src/update-routes.ts +0 -65
- package/src/update.ts +0 -915
package/lib/types/update.js
DELETED
|
@@ -1,773 +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
|
-
/** Concurrent registry probes in flight for one update-status call. */
|
|
223
|
-
export const UPDATE_PROBE_CONCURRENCY = 4;
|
|
224
|
-
/**
|
|
225
|
-
* Probe every package's latest version with a bounded number of requests in
|
|
226
|
-
* flight, preserving the caller's order in the result.
|
|
227
|
-
* @param names - package names to probe.
|
|
228
|
-
* @param fetchLatest - the probe for one package (returns undefined on failure).
|
|
229
|
-
* @returns one result per name, in the input order.
|
|
230
|
-
*/
|
|
231
|
-
export async function probeLatestVersions(names, fetchLatest) {
|
|
232
|
-
const results = new Array(names.length);
|
|
233
|
-
let next = 0;
|
|
234
|
-
const worker = async () => {
|
|
235
|
-
for (;;) {
|
|
236
|
-
const index = next++;
|
|
237
|
-
if (index >= names.length)
|
|
238
|
-
return;
|
|
239
|
-
results[index] = await fetchLatest(names[index]);
|
|
240
|
-
}
|
|
241
|
-
};
|
|
242
|
-
const workers = Math.min(UPDATE_PROBE_CONCURRENCY, names.length);
|
|
243
|
-
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
244
|
-
return results;
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Probe the npm registry for one package's latest release.
|
|
248
|
-
* @param name - the package name (scope slash URL-encoded).
|
|
249
|
-
* @param fetchImpl - the fetch implementation (global fetch in the host).
|
|
250
|
-
* @param timeoutMs - probe timeout.
|
|
251
|
-
* @returns the latest version string, or undefined on any failure.
|
|
252
|
-
*/
|
|
253
|
-
export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
|
|
254
|
-
try {
|
|
255
|
-
const controller = new AbortController();
|
|
256
|
-
const timer = setTimeout(() => { controller.abort(); }, timeoutMs);
|
|
257
|
-
try {
|
|
258
|
-
const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', withIdentityEncoding({ signal: controller.signal }));
|
|
259
|
-
if (!response.ok)
|
|
260
|
-
return undefined;
|
|
261
|
-
const body = await response.json();
|
|
262
|
-
if (typeof body !== 'object' || body === null)
|
|
263
|
-
return undefined;
|
|
264
|
-
const version = body.version;
|
|
265
|
-
return typeof version === 'string' ? version : undefined;
|
|
266
|
-
}
|
|
267
|
-
finally {
|
|
268
|
-
clearTimeout(timer);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
/** Normalize one release-note bullet. */
|
|
276
|
-
function cleanNoteItem(text) {
|
|
277
|
-
// Keep the visible label and drop Markdown link targets; issue references
|
|
278
|
-
// become plain #123 because the panel has no Markdown renderer.
|
|
279
|
-
return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^[-*]\s+/, '').trim();
|
|
280
|
-
}
|
|
281
|
-
/** Determine the section kind for a Markdown heading line. */
|
|
282
|
-
function noteSectionOf(line) {
|
|
283
|
-
const heading = /^#{1,6}\s+(.+)$/.exec(line.trim());
|
|
284
|
-
if (heading === null)
|
|
285
|
-
return undefined;
|
|
286
|
-
const title = heading[1].replace(/[::].*$/, '').trim().toLowerCase();
|
|
287
|
-
if (title === '新功能' || title === 'new features')
|
|
288
|
-
return 'features';
|
|
289
|
-
if (title === '修复' || title === 'bug fixes' || title === 'bugfixes')
|
|
290
|
-
return 'fixes';
|
|
291
|
-
if (title === '其他改动' || title === 'other changes')
|
|
292
|
-
return 'other';
|
|
293
|
-
return undefined;
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Parse the GitHub Release body into the three conventional sections.
|
|
297
|
-
*
|
|
298
|
-
* The committed release notes are bilingual: Chinese is the default view and
|
|
299
|
-
* English lives inside a `<details>` block. Prefer the Chinese sections when
|
|
300
|
-
* present and fall back to the whole body for English-only releases. Bullets
|
|
301
|
-
* are escaped as plain text for the panel (no Markdown renderer).
|
|
302
|
-
*/
|
|
303
|
-
function parseReleaseNotesSource(version, source) {
|
|
304
|
-
const notes = { version, features: [], fixes: [], other: [] };
|
|
305
|
-
let current;
|
|
306
|
-
for (const rawLine of source.split(/\r?\n/)) {
|
|
307
|
-
const line = rawLine.trim();
|
|
308
|
-
const section = noteSectionOf(line);
|
|
309
|
-
if (section !== undefined) {
|
|
310
|
-
current = section;
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (current === undefined || !/^-\s+/.test(line))
|
|
314
|
-
continue;
|
|
315
|
-
const item = cleanNoteItem(line);
|
|
316
|
-
if (item !== '')
|
|
317
|
-
notes[current].push(item);
|
|
318
|
-
}
|
|
319
|
-
if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0)
|
|
320
|
-
return undefined;
|
|
321
|
-
return notes;
|
|
322
|
-
}
|
|
323
|
-
export function parseReleaseNotesBody(version, body) {
|
|
324
|
-
// The committed release notes put Chinese in the default view and English in
|
|
325
|
-
// a <details> block. Parse only the default view first, then fall back to
|
|
326
|
-
// the full body for English-only releases.
|
|
327
|
-
return parseReleaseNotesSource(version, body.split('<details>')[0] ?? body)
|
|
328
|
-
?? parseReleaseNotesSource(version, body);
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Fetch and parse the GitHub Release for one `dsh-web` version.
|
|
332
|
-
* @param version - the npm release version (without the `v` prefix).
|
|
333
|
-
* @param fetchImpl - injected fetch; global `fetch` in the host.
|
|
334
|
-
* @param timeoutMs - hard timeout for the GitHub API probe.
|
|
335
|
-
* @returns structured notes, or undefined when unavailable.
|
|
336
|
-
*/
|
|
337
|
-
export async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 10_000) {
|
|
338
|
-
const controller = new AbortController();
|
|
339
|
-
const timer = setTimeout(() => { controller.abort(); }, timeoutMs);
|
|
340
|
-
try {
|
|
341
|
-
const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, withIdentityEncoding({
|
|
342
|
-
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-web-update' },
|
|
343
|
-
signal: controller.signal,
|
|
344
|
-
}));
|
|
345
|
-
if (!response.ok)
|
|
346
|
-
return undefined;
|
|
347
|
-
const body = await response.json();
|
|
348
|
-
if (typeof body !== 'object' || body === null)
|
|
349
|
-
return undefined;
|
|
350
|
-
const text = body.body;
|
|
351
|
-
return typeof text === 'string' ? parseReleaseNotesBody(version, text) : undefined;
|
|
352
|
-
}
|
|
353
|
-
catch {
|
|
354
|
-
return undefined;
|
|
355
|
-
}
|
|
356
|
-
finally {
|
|
357
|
-
clearTimeout(timer);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
/**
|
|
361
|
-
* Sentinel for an installed version that could not be read (missing manifest
|
|
362
|
-
* or resolve failure). It is a real-looking version so `checkUpdates` can
|
|
363
|
-
* render the affected row, but the verified-update comparison must never
|
|
364
|
-
* treat it as evidence — a read failure is not a version that "moved".
|
|
365
|
-
*/
|
|
366
|
-
const VERSION_UNKNOWN = '0.0.0';
|
|
367
|
-
/** The resolved current version of one family package (probe failure tolerated). */
|
|
368
|
-
function readInstalledVersion(resolve, name, profileDir) {
|
|
369
|
-
try {
|
|
370
|
-
const path = resolve(name + '/package.json');
|
|
371
|
-
const version = path === undefined ? undefined : readManifest(path)?.version;
|
|
372
|
-
if (typeof version === 'string')
|
|
373
|
-
return version;
|
|
374
|
-
}
|
|
375
|
-
catch { /* fall through to the profile's direct dependency path */ }
|
|
376
|
-
// Names originate in the profile dependency map, but still validate the
|
|
377
|
-
// npm package shape before using one as path segments.
|
|
378
|
-
if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
379
|
-
return VERSION_UNKNOWN;
|
|
380
|
-
const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version;
|
|
381
|
-
return typeof version === 'string' ? version : VERSION_UNKNOWN;
|
|
382
|
-
}
|
|
383
|
-
/**
|
|
384
|
-
* Build the update status: locate the anchor, detect the install mode, and
|
|
385
|
-
* compare every family package against the npm registry.
|
|
386
|
-
* @param deps - manifest resolution + registry probe seams.
|
|
387
|
-
* @returns the status snapshot.
|
|
388
|
-
*/
|
|
389
|
-
export async function checkUpdates(deps) {
|
|
390
|
-
const manifestPath = deps.anchorManifestPath;
|
|
391
|
-
if (manifestPath === undefined) {
|
|
392
|
-
return { mode: 'missing', packages: [], outdated: false };
|
|
393
|
-
}
|
|
394
|
-
const manifest = readManifest(manifestPath);
|
|
395
|
-
if (manifest === undefined) {
|
|
396
|
-
return { mode: 'missing', packages: [], outdated: false };
|
|
397
|
-
}
|
|
398
|
-
const anchor = typeof manifest.name === 'string' ? manifest.name : undefined;
|
|
399
|
-
if (anchor === undefined) {
|
|
400
|
-
return { mode: 'missing', packages: [], outdated: false };
|
|
401
|
-
}
|
|
402
|
-
// A package inside a dsh profile directory is a registry install; one that
|
|
403
|
-
// lives outside every profile (e.g. a repo checkout wired through
|
|
404
|
-
// link-profile.mjs) is a local dev install pnpm cannot update.
|
|
405
|
-
const profile = findProfile(manifestPath);
|
|
406
|
-
const profileManifest = profile === undefined ? undefined : readManifest(join(profile.dir, "package.json"));
|
|
407
|
-
const linked = profile === undefined
|
|
408
|
-
|| isLinkedSpec(profileManifest?.dependencies?.[anchor])
|
|
409
|
-
|| hasLinkedFamilyOverride(manifest, profileManifest);
|
|
410
|
-
if (profile === undefined) {
|
|
411
|
-
return { mode: 'link', packages: [], outdated: false };
|
|
412
|
-
}
|
|
413
|
-
// Union the profile's direct family deps so standalone installs (no
|
|
414
|
-
// aggregate) still check every installed @linxin666/* plugin (#377);
|
|
415
|
-
// familyUpdatePackages skips link:/file: development dependencies.
|
|
416
|
-
const names = familyUpdatePackages(anchor, manifest, profileManifest);
|
|
417
|
-
// The registry probes are independent, so they run concurrently — but with a
|
|
418
|
-
// small ceiling rather than all at once. A full install fans out ~20 probes,
|
|
419
|
-
// and on hosts behind an application-aware security middlebox that burst is
|
|
420
|
-
// what gets the node process's outbound connections throttled (the update
|
|
421
|
-
// check itself only ever loses latency: one probe is 10s-bounded and a
|
|
422
|
-
// failure is shown as a probe failure). Four keeps the status call quick
|
|
423
|
-
// while staying inside the concurrency such a middlebox tolerates (#1677).
|
|
424
|
-
const latestList = await probeLatestVersions(names, deps.fetchLatest);
|
|
425
|
-
const packages = [];
|
|
426
|
-
let probeFailures = 0;
|
|
427
|
-
names.forEach((name, index) => {
|
|
428
|
-
const latest = latestList[index];
|
|
429
|
-
if (latest === undefined)
|
|
430
|
-
probeFailures++;
|
|
431
|
-
const current = readInstalledVersion(deps.resolve, name, profile.dir);
|
|
432
|
-
packages.push({
|
|
433
|
-
name,
|
|
434
|
-
current,
|
|
435
|
-
latest,
|
|
436
|
-
outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
// Registry unreachable: every probe failed — report the outage distinctly
|
|
440
|
-
// instead of a misleading "all up to date" (the panel needs the reason).
|
|
441
|
-
const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined;
|
|
442
|
-
const targetVersion = packages.find(packageStatus => packageStatus.latest !== undefined)?.latest;
|
|
443
|
-
const notes = targetVersion !== undefined && deps.fetchReleaseNotes !== undefined
|
|
444
|
-
? await deps.fetchReleaseNotes(targetVersion).catch(() => undefined)
|
|
445
|
-
: undefined;
|
|
446
|
-
return {
|
|
447
|
-
mode: linked ? 'link' : 'npm',
|
|
448
|
-
profileName: profile.name,
|
|
449
|
-
anchor,
|
|
450
|
-
packages,
|
|
451
|
-
outdated: packages.some(packageStatus => packageStatus.outdated),
|
|
452
|
-
...(notes !== undefined ? { notes } : {}),
|
|
453
|
-
...(error !== undefined ? { error } : {}),
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
/** Cap on captured pnpm output (keeps error payloads bounded). */
|
|
457
|
-
const OUTPUT_CAP = 16 * 1024;
|
|
458
|
-
/**
|
|
459
|
-
* Characters a package name may contain before it is handed to the update
|
|
460
|
-
* command line. Deliberately looser than the strict family shape (the tests
|
|
461
|
-
* and future callers use short names) but it admits no whitespace, quotes,
|
|
462
|
-
* shell metacharacters, or path separators beyond the scope slash.
|
|
463
|
-
*/
|
|
464
|
-
const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
465
|
-
/**
|
|
466
|
-
* Windows cmd command-not-found stderr. With shell:true a missing shim
|
|
467
|
-
* exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
|
|
468
|
-
* detects this message instead of the spawn error event. Supports both English
|
|
469
|
-
* and localized (e.g. Chinese) cmd error messages.
|
|
470
|
-
*/
|
|
471
|
-
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
|
|
472
|
-
/**
|
|
473
|
-
* Fold one decoded candidate output into the running total under the display
|
|
474
|
-
* cap (the tail carries the failure reason).
|
|
475
|
-
* @param output - the accumulated text.
|
|
476
|
-
* @param text - the candidate's decoded text.
|
|
477
|
-
*/
|
|
478
|
-
function capOutput(output, text) {
|
|
479
|
-
const combined = output + text;
|
|
480
|
-
return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
|
|
481
|
-
}
|
|
482
|
-
/**
|
|
483
|
-
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
484
|
-
* silently skips same-day releases — `pnpm update --latest` then exits 0
|
|
485
|
-
* without moving anything and the post-run verification would misreport a
|
|
486
|
-
* stale no-op. The update here is explicitly user-initiated and the panel
|
|
487
|
-
* shows exactly which versions are being installed, so the gate only adds a
|
|
488
|
-
* confusing silent no-op; override it per-invocation instead of asking every
|
|
489
|
-
* user to edit the profile's pnpm-workspace.yaml.
|
|
490
|
-
*/
|
|
491
|
-
const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
|
|
492
|
-
/**
|
|
493
|
-
* Run the update inside the profile directory. Tries pnpm first, falls back
|
|
494
|
-
* to corepack and then npx when the previous command is missing (ENOENT);
|
|
495
|
-
* all candidates share one hard timeout and keep accumulating output.
|
|
496
|
-
* @param deps - profile dir, package list, and spawn/timeout seams.
|
|
497
|
-
* @returns the outcome with captured output.
|
|
498
|
-
*/
|
|
499
|
-
export function runUpdate(deps) {
|
|
500
|
-
return new Promise((resolve) => {
|
|
501
|
-
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
502
|
-
const platform = deps.platform ?? process.platform;
|
|
503
|
-
// The names come from the profile/anchor manifests and the win32 candidate
|
|
504
|
-
// runs through a shell, so anything outside the npm name shape is dropped
|
|
505
|
-
// instead of being concatenated into a command line (a dependency key
|
|
506
|
-
// containing shell metacharacters would otherwise run as a second command).
|
|
507
|
-
const packages = deps.packages.filter(name => SHELL_SAFE_PACKAGE_RE.test(name));
|
|
508
|
-
// Windows ships pnpm/corepack/npx as .cmd shims; Node's spawn cannot
|
|
509
|
-
// start .cmd files directly (ENOENT even when installed), so route
|
|
510
|
-
// them through cmd.exe there. POSIX spawns stay shell-free.
|
|
511
|
-
// POSIX: the child leads its own process group (detached) so the timeout
|
|
512
|
-
// can signal pnpm's whole tree — pnpm spawns lifecycle-script children that
|
|
513
|
-
// a direct SIGTERM to the wrapper leaves running against the profile.
|
|
514
|
-
const spawnOptions = {
|
|
515
|
-
cwd: deps.profileDir,
|
|
516
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
517
|
-
...(platform === 'win32' ? { shell: true } : { detached: true }),
|
|
518
|
-
};
|
|
519
|
-
const killImpl = deps.killImpl ?? process.kill;
|
|
520
|
-
// Ordered fallback chain: each is tried only when the previous one is
|
|
521
|
-
// missing on PATH (ENOENT on the spawn error event, never on close).
|
|
522
|
-
// `--latest` is required: dsh plugin writes exact-version specs (e.g.
|
|
523
|
-
// "0.1.12" without a range), and plain `pnpm update` treats an exact spec
|
|
524
|
-
// as pinned — it prints "Already up to date" and exits 0 without moving
|
|
525
|
-
// the installed version, so the panel would report a false success.
|
|
526
|
-
// `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
|
|
527
|
-
// pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
|
|
528
|
-
// releases in place, which the post-run verification would then have to
|
|
529
|
-
// surface as `stale` even though the update is legitimate.
|
|
530
|
-
const candidates = [
|
|
531
|
-
{ command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
532
|
-
{ command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
533
|
-
{ command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
534
|
-
];
|
|
535
|
-
// `output` accumulates across candidates for UI display; `currentOutput`
|
|
536
|
-
// is reset per candidate and carries only that candidate's own diagnostics
|
|
537
|
-
// (its tail is subject to OUTPUT_CAP). The win32 missing-command test must
|
|
538
|
-
// run against `currentOutput` so a previous candidate's 'not recognized'
|
|
539
|
-
// stderr cannot misclassify a real failure in the next one.
|
|
540
|
-
// Each candidate accumulates raw bytes and is decoded exactly once
|
|
541
|
-
// (console-output.ts): decoding per `data` event splits a multi-byte
|
|
542
|
-
// character that straddles two reads into replacement characters, which is
|
|
543
|
-
// what the panel then shows as garbled pnpm diagnostics.
|
|
544
|
-
let output = '';
|
|
545
|
-
let capture = createOutputCapture(OUTPUT_CAP);
|
|
546
|
-
let candidateFolded = false;
|
|
547
|
-
const append = (chunk) => { capture.push(chunk); };
|
|
548
|
-
/** This candidate's decoded text (idempotent). */
|
|
549
|
-
const candidateText = () => capture.read();
|
|
550
|
-
/** Fold this candidate's text into the running output exactly once. */
|
|
551
|
-
const foldCandidate = () => {
|
|
552
|
-
if (candidateFolded)
|
|
553
|
-
return;
|
|
554
|
-
candidateFolded = true;
|
|
555
|
-
output = capOutput(output, capture.read());
|
|
556
|
-
};
|
|
557
|
-
let currentChild;
|
|
558
|
-
// Terminal guard: once a result is produced the promise is settled and no
|
|
559
|
-
// further candidate is started (a killed child's late close must not
|
|
560
|
-
// respawn anything after a timeout resolution).
|
|
561
|
-
let finished = false;
|
|
562
|
-
const finish = (result) => {
|
|
563
|
-
if (finished)
|
|
564
|
-
return;
|
|
565
|
-
finished = true;
|
|
566
|
-
clearTimeout(timer);
|
|
567
|
-
resolve(result);
|
|
568
|
-
};
|
|
569
|
-
const timer = setTimeout(() => {
|
|
570
|
-
if (finished)
|
|
571
|
-
return;
|
|
572
|
-
if (platform === 'win32') {
|
|
573
|
-
// cmd.exe under shell:true only kills the shell wrapper; kill the whole
|
|
574
|
-
// process tree best-effort so pnpm/npx do not keep running.
|
|
575
|
-
const pid = currentChild?.pid;
|
|
576
|
-
if (pid !== undefined && pid > 0) {
|
|
577
|
-
try {
|
|
578
|
-
// A spawn failure arrives as an asynchronous 'error' event, which
|
|
579
|
-
// the try/catch cannot see; an emitter with no error listener throws
|
|
580
|
-
// and would take the host process down on this path.
|
|
581
|
-
const killer = spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
|
|
582
|
-
killer.on?.('error', () => { });
|
|
583
|
-
}
|
|
584
|
-
catch {
|
|
585
|
-
// Best-effort kill; fall through to the timeout result.
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
else {
|
|
590
|
-
// Detached child: its process-group id is its pid, so the negative pid
|
|
591
|
-
// reaches pnpm's grandchildren too. A group kill throws when the child
|
|
592
|
-
// already exited; fall back to the direct kill.
|
|
593
|
-
const pid = currentChild?.pid;
|
|
594
|
-
if (pid !== undefined && pid > 0) {
|
|
595
|
-
try {
|
|
596
|
-
killImpl(-pid, 'SIGTERM');
|
|
597
|
-
}
|
|
598
|
-
catch {
|
|
599
|
-
try {
|
|
600
|
-
currentChild?.kill('SIGTERM');
|
|
601
|
-
}
|
|
602
|
-
catch { /* already gone */ }
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
else {
|
|
606
|
-
currentChild?.kill('SIGTERM');
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
foldCandidate();
|
|
610
|
-
finish({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' });
|
|
611
|
-
}, deps.timeoutMs ?? 10 * 60_000);
|
|
612
|
-
const runCandidate = (index) => {
|
|
613
|
-
if (finished)
|
|
614
|
-
return;
|
|
615
|
-
if (index >= candidates.length) {
|
|
616
|
-
finish({
|
|
617
|
-
ok: false,
|
|
618
|
-
exitCode: null,
|
|
619
|
-
output,
|
|
620
|
-
error: 'pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app',
|
|
621
|
-
errorCode: 'pnpm-missing',
|
|
622
|
-
});
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
const candidate = candidates[index];
|
|
626
|
-
capture = createOutputCapture(OUTPUT_CAP);
|
|
627
|
-
candidateFolded = false;
|
|
628
|
-
const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
|
|
629
|
-
currentChild = child;
|
|
630
|
-
let settled = false;
|
|
631
|
-
const once = (fn) => {
|
|
632
|
-
if (settled)
|
|
633
|
-
return;
|
|
634
|
-
settled = true;
|
|
635
|
-
fn();
|
|
636
|
-
};
|
|
637
|
-
child.stdout?.on('data', append);
|
|
638
|
-
child.stderr?.on('data', append);
|
|
639
|
-
child.on('error', (error) => {
|
|
640
|
-
once(() => {
|
|
641
|
-
foldCandidate();
|
|
642
|
-
if (error.code === 'ENOENT') {
|
|
643
|
-
// Command missing: try the next candidate in the chain.
|
|
644
|
-
runCandidate(index + 1);
|
|
645
|
-
}
|
|
646
|
-
else {
|
|
647
|
-
finish({ ok: false, exitCode: null, output, error: error.message, errorCode: undefined });
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
child.on('close', (code) => {
|
|
652
|
-
if (settled || finished)
|
|
653
|
-
return;
|
|
654
|
-
// Windows + shell: a missing command reports exit 1 or 9009 with
|
|
655
|
-
// 'not recognized' or localized message instead of ENOENT — keep the
|
|
656
|
-
// chain going. Checked against this candidate's own output so a prior
|
|
657
|
-
// fallback's stderr cannot misclassify a real failure here.
|
|
658
|
-
if (platform === 'win32' && (code === 9009 || (code !== 0 && WIN_CMD_MISSING_RE.test(candidateText())))) {
|
|
659
|
-
foldCandidate();
|
|
660
|
-
runCandidate(index + 1);
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
settled = true;
|
|
664
|
-
foldCandidate();
|
|
665
|
-
finish({
|
|
666
|
-
ok: code === 0,
|
|
667
|
-
exitCode: code,
|
|
668
|
-
output,
|
|
669
|
-
error: code === 0 ? undefined : `${candidate.command} exited with code ` + String(code),
|
|
670
|
-
...(code === 0 ? {} : { errorCode: 'pnpm-failed' }),
|
|
671
|
-
});
|
|
672
|
-
});
|
|
673
|
-
};
|
|
674
|
-
runCandidate(0);
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Run the update, then verify the installed versions actually moved. pnpm
|
|
679
|
-
* exits 0 even when it silently kept the installed versions — runUpdate
|
|
680
|
-
* overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
|
|
681
|
-
* `--config.minimumReleaseAge=0`, but an older pnpm without the override or
|
|
682
|
-
* another silent no-op can still keep versions in place — so a green exit
|
|
683
|
-
* alone must not report a misleading "update complete". Re-read the
|
|
684
|
-
* installed versions afterwards and surface a `stale` failure (with the
|
|
685
|
-
* captured pnpm output) when nothing moved, so the panel can tell the user
|
|
686
|
-
* how to unblock the gate instead of claiming success.
|
|
687
|
-
*
|
|
688
|
-
* The stale decision anchors on the pre-run installed versions, not on the
|
|
689
|
-
* registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
|
|
690
|
-
* latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
|
|
691
|
-
* path is re-resolved rather than reused from boot time: pnpm removes the old
|
|
692
|
-
* version's .pnpm directory on update, so a boot-time captured path would
|
|
693
|
-
* fail to read and collapse a successful update into a bogus 'missing'.
|
|
694
|
-
*
|
|
695
|
-
* A green exit whose verification has nothing to compare (registry probe
|
|
696
|
-
* outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
|
|
697
|
-
* must never read as success. A green exit where no package moved but the
|
|
698
|
-
* post-run check could not prove the install is fully current (a partial
|
|
699
|
-
* probe failure hides whether a gate kept something back) is also
|
|
700
|
-
* `verify-failed`: it must not collapse into a success either.
|
|
701
|
-
* @param deps - the run and check seams.
|
|
702
|
-
* @returns the run result; `stale` when exit 0 left every version in place,
|
|
703
|
-
* `verify-failed` when the post-run check could not verify anything.
|
|
704
|
-
*/
|
|
705
|
-
export async function runUpdateVerified(deps) {
|
|
706
|
-
// Pre-run snapshot: the installed versions the update starts from. Only
|
|
707
|
-
// successfully-read versions are recorded — a read failure (VERSION_UNKNOWN)
|
|
708
|
-
// is not a baseline and must not look like a version that "moved", or a
|
|
709
|
-
// green exit that left everything in place could be mistaken for success.
|
|
710
|
-
const before = new Map();
|
|
711
|
-
for (const name of deps.run.packages) {
|
|
712
|
-
const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
|
|
713
|
-
if (version !== VERSION_UNKNOWN)
|
|
714
|
-
before.set(name, version);
|
|
715
|
-
}
|
|
716
|
-
const result = await runUpdate(deps.run);
|
|
717
|
-
if (!result.ok)
|
|
718
|
-
return result;
|
|
719
|
-
// Re-resolve the anchor after the run: pnpm removes the old version's
|
|
720
|
-
// .pnpm directory, so a boot-time captured path no longer reads. Fall back
|
|
721
|
-
// to the provided path only when nothing resolves now.
|
|
722
|
-
const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath;
|
|
723
|
-
const status = await checkUpdates({ ...deps.check, anchorManifestPath });
|
|
724
|
-
// A green exit with no comparable result is not a success: registry probe
|
|
725
|
-
// outage, missing anchor, or a non-npm install mode all mean the post-run
|
|
726
|
-
// check could not verify anything.
|
|
727
|
-
if (status.error !== undefined || status.mode !== 'npm') {
|
|
728
|
-
return {
|
|
729
|
-
...result,
|
|
730
|
-
ok: false,
|
|
731
|
-
errorCode: 'verify-failed',
|
|
732
|
-
error: 'pnpm exited 0 but the post-run version check could not verify the install',
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
// A package "moved" only when a known pre-run version differs from a known
|
|
736
|
-
// post-run version. A read failure on either side (VERSION_UNKNOWN) is not
|
|
737
|
-
// evidence of movement and must not turn a no-op update into a success; a
|
|
738
|
-
// package without a pre-run baseline is ignored — only the packages pnpm
|
|
739
|
-
// was told to update count as evidence.
|
|
740
|
-
const moved = status.packages.some(packageStatus => {
|
|
741
|
-
const beforeVersion = before.get(packageStatus.name);
|
|
742
|
-
if (beforeVersion === undefined || packageStatus.current === VERSION_UNKNOWN)
|
|
743
|
-
return false;
|
|
744
|
-
return packageStatus.current !== beforeVersion;
|
|
745
|
-
});
|
|
746
|
-
if (moved)
|
|
747
|
-
return result;
|
|
748
|
-
// Nothing moved. A green exit is only a true no-op when the post-run check
|
|
749
|
-
// proves the install is already current: every probe succeeded and nothing
|
|
750
|
-
// is outdated. If a probe failed we cannot tell "already up to date" from
|
|
751
|
-
// "a gate silently kept it back", so report verify-failed rather than a
|
|
752
|
-
// false success.
|
|
753
|
-
if (status.outdated) {
|
|
754
|
-
// stale: the check ran, a newer release exists, and no package moved
|
|
755
|
-
// (e.g. the minimumReleaseAge gate kept everything in place).
|
|
756
|
-
return {
|
|
757
|
-
...result,
|
|
758
|
-
ok: false,
|
|
759
|
-
errorCode: 'stale',
|
|
760
|
-
error: 'pnpm exited 0 but the installed versions did not change',
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
const unverifiable = status.packages.some(packageStatus => packageStatus.latest === undefined);
|
|
764
|
-
if (unverifiable) {
|
|
765
|
-
return {
|
|
766
|
-
...result,
|
|
767
|
-
ok: false,
|
|
768
|
-
errorCode: 'verify-failed',
|
|
769
|
-
error: 'pnpm exited 0 but the post-run version check could not verify the install',
|
|
770
|
-
};
|
|
771
|
-
}
|
|
772
|
-
return result;
|
|
773
|
-
}
|