@linxin666/dsh-remote-web-ui 0.1.10 → 0.1.11
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.md +17 -6
- package/lib/client.js +403 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +406 -17
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +9 -7
- package/lib/types/client/UpdateEntry.d.ts +14 -0
- package/lib/types/client/UpdateEntry.d.ts.map +1 -0
- package/lib/types/client/UpdateEntry.js +68 -0
- package/lib/types/client/UpdatePanel.d.ts +40 -0
- package/lib/types/client/UpdatePanel.d.ts.map +1 -0
- package/lib/types/client/UpdatePanel.js +82 -0
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +56 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +58 -0
- package/lib/types/client/update-api.d.ts +19 -0
- package/lib/types/client/update-api.d.ts.map +1 -0
- package/lib/types/client/update-api.js +27 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +45 -1
- package/lib/types/routes.d.ts +15 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +1 -1
- package/lib/types/update-routes.d.ts +30 -0
- package/lib/types/update-routes.d.ts.map +1 -0
- package/lib/types/update-routes.js +52 -0
- package/lib/types/update.d.ts +191 -0
- package/lib/types/update.d.ts.map +1 -0
- package/lib/types/update.js +333 -0
- package/package.json +1 -1
- package/src/client/RemoteEntry.tsx +12 -7
- package/src/client/UpdateEntry.tsx +96 -0
- package/src/client/UpdatePanel.tsx +203 -0
- package/src/client/index.ts +2 -0
- package/src/client/locales.ts +58 -0
- package/src/client/remote.module.css +109 -0
- package/src/client/update-api.ts +29 -0
- package/src/index.ts +51 -1
- package/src/routes.ts +1 -1
- package/src/update-routes.ts +71 -0
- package/src/update.ts +435 -0
package/src/update.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote update support for the dsh-web-ui family — host half. Detects the
|
|
3
|
+
* installed aggregate package (@linxin666/dsh-web-ui-all) and its family
|
|
4
|
+
* children, probes the npm registry for newer releases, and runs the actual
|
|
5
|
+
* update as `pnpm update` inside the owning dsh profile directory.
|
|
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
|
+
|
|
16
|
+
/** npm registry base used for version probes. */
|
|
17
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
18
|
+
|
|
19
|
+
/** The family scope every dsh-web-ui package is published under. */
|
|
20
|
+
export const FAMILY_SCOPE = '@linxin666/'
|
|
21
|
+
|
|
22
|
+
/** The aggregate package that is the canonical update entry point. */
|
|
23
|
+
export const AGGREGATE_PACKAGE = '@linxin666/dsh-web-ui-all'
|
|
24
|
+
|
|
25
|
+
/** Fallback anchor: this plugin's own package when the aggregate is absent. */
|
|
26
|
+
export const SELF_PACKAGE = '@linxin666/dsh-remote-web-ui'
|
|
27
|
+
|
|
28
|
+
/** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
|
|
29
|
+
const PROFILE_NAME_PREFIX = 'dsh-profile-'
|
|
30
|
+
|
|
31
|
+
/** How many ancestor directories a profile search walks before giving up. */
|
|
32
|
+
const PROFILE_WALK_DEPTH = 12
|
|
33
|
+
|
|
34
|
+
/** A parsed semantic version (prerelease identifiers kept as strings). */
|
|
35
|
+
export interface SemverParts {
|
|
36
|
+
major: number
|
|
37
|
+
minor: number
|
|
38
|
+
patch: number
|
|
39
|
+
/** Dot-split prerelease identifiers; empty when absent. */
|
|
40
|
+
prerelease: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a semantic version string (leading `v` tolerated, build metadata
|
|
45
|
+
* ignored). Returns undefined for unparseable input.
|
|
46
|
+
* @param value - the version string, e.g. `0.1.10` or `0.1.11-rc.1`.
|
|
47
|
+
* @returns the parsed parts, or undefined.
|
|
48
|
+
*/
|
|
49
|
+
export function parseSemver(value: string): SemverParts | undefined {
|
|
50
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim())
|
|
51
|
+
if (match === null) return undefined
|
|
52
|
+
return {
|
|
53
|
+
major: Number(match[1]),
|
|
54
|
+
minor: Number(match[2]),
|
|
55
|
+
patch: Number(match[3]),
|
|
56
|
+
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compare two semantic versions per the semver precedence rules (a release
|
|
62
|
+
* outranks any of its prereleases; numeric prerelease identifiers compare
|
|
63
|
+
* numerically and sort below alphanumeric ones). An unparseable version sorts
|
|
64
|
+
* below every parseable one; two unparseable versions compare equal.
|
|
65
|
+
* @param a - first version.
|
|
66
|
+
* @param b - second version.
|
|
67
|
+
* @returns negative when a < b, 0 when equal, positive when a > b.
|
|
68
|
+
*/
|
|
69
|
+
export function compareVersions(a: string, b: string): number {
|
|
70
|
+
const pa = parseSemver(a)
|
|
71
|
+
const pb = parseSemver(b)
|
|
72
|
+
if (pa === undefined && pb === undefined) return 0
|
|
73
|
+
if (pa === undefined) return -1
|
|
74
|
+
if (pb === undefined) return 1
|
|
75
|
+
for (const key of ['major', 'minor', 'patch'] as const) {
|
|
76
|
+
if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1
|
|
77
|
+
}
|
|
78
|
+
if (pa.prerelease.length === 0 && pb.prerelease.length === 0) return 0
|
|
79
|
+
if (pa.prerelease.length === 0) return 1
|
|
80
|
+
if (pb.prerelease.length === 0) return -1
|
|
81
|
+
for (let index = 0; index < Math.max(pa.prerelease.length, pb.prerelease.length); index++) {
|
|
82
|
+
const ra = pa.prerelease[index]
|
|
83
|
+
const rb = pb.prerelease[index]
|
|
84
|
+
if (ra === undefined) return -1
|
|
85
|
+
if (rb === undefined) return 1
|
|
86
|
+
if (ra === rb) continue
|
|
87
|
+
const numericA = /^\d+$/.test(ra)
|
|
88
|
+
const numericB = /^\d+$/.test(rb)
|
|
89
|
+
if (numericA && numericB) return Number(ra) < Number(rb) ? -1 : 1
|
|
90
|
+
// Numeric identifiers always sort below alphanumeric ones.
|
|
91
|
+
if (numericA) return -1
|
|
92
|
+
if (numericB) return 1
|
|
93
|
+
return ra < rb ? -1 : 1
|
|
94
|
+
}
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read a package.json at a path, tolerating any parse/IO failure. */
|
|
99
|
+
function readManifest(path: string): Record<string, unknown> | undefined {
|
|
100
|
+
try {
|
|
101
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
102
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The found dsh profile owning an installed package. */
|
|
109
|
+
export interface FoundProfile {
|
|
110
|
+
/** Profile name (e.g. `web`). */
|
|
111
|
+
name: string
|
|
112
|
+
/** Absolute profile directory. */
|
|
113
|
+
dir: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Locate the owning dsh profile by walking up from an installed package's
|
|
118
|
+
* manifest until a manifest named `dsh-profile-*` appears (the profile
|
|
119
|
+
* directory is the first ancestor whose package.json carries that name).
|
|
120
|
+
* @param anchorManifestPath - absolute path of the anchor package.json.
|
|
121
|
+
* @returns the profile name/dir, or undefined when not profile-installed.
|
|
122
|
+
*/
|
|
123
|
+
export function findProfile(anchorManifestPath: string): FoundProfile | undefined {
|
|
124
|
+
let dir = dirname(anchorManifestPath)
|
|
125
|
+
for (let depth = 0; depth < PROFILE_WALK_DEPTH; depth++) {
|
|
126
|
+
const manifest = readManifest(join(dir, 'package.json'))
|
|
127
|
+
const name = manifest?.name
|
|
128
|
+
if (typeof name === 'string' && name.startsWith(PROFILE_NAME_PREFIX)) {
|
|
129
|
+
return { name: name.slice(PROFILE_NAME_PREFIX.length), dir }
|
|
130
|
+
}
|
|
131
|
+
const parent = dirname(dir)
|
|
132
|
+
if (parent === dir) return undefined
|
|
133
|
+
dir = parent
|
|
134
|
+
}
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A package version spec in a manifest dependencies map. */
|
|
139
|
+
type DependencySpec = string | { version: string } | undefined
|
|
140
|
+
|
|
141
|
+
/** Whether a dependency spec is a local link/file/dev-mode install. */
|
|
142
|
+
export function isLinkedSpec(spec: DependencySpec): boolean {
|
|
143
|
+
if (typeof spec !== 'string') return false
|
|
144
|
+
return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One package's current-vs-latest comparison. */
|
|
148
|
+
export interface UpdatePackageStatus {
|
|
149
|
+
/** Package name. */
|
|
150
|
+
name: string
|
|
151
|
+
/** Locally installed version. */
|
|
152
|
+
current: string
|
|
153
|
+
/** Latest npm release — undefined when the registry probe failed. */
|
|
154
|
+
latest?: string
|
|
155
|
+
/** Whether npm carries a strictly newer release. */
|
|
156
|
+
outdated: boolean
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The full update-status snapshot served to the browser half. */
|
|
160
|
+
export interface UpdateStatus {
|
|
161
|
+
/** npm = registry-managed (updatable); link = local dev install; missing = no anchor package. */
|
|
162
|
+
mode: 'npm' | 'link' | 'missing'
|
|
163
|
+
/** Owning profile name (npm mode). */
|
|
164
|
+
profileName?: string
|
|
165
|
+
/** The anchor package the update targets. */
|
|
166
|
+
anchor?: string
|
|
167
|
+
/** Per-package version comparison (anchor first). */
|
|
168
|
+
packages: UpdatePackageStatus[]
|
|
169
|
+
/** True when any package has a newer npm release. */
|
|
170
|
+
outdated: boolean
|
|
171
|
+
/** Whole-check failure (e.g. registry unreachable). */
|
|
172
|
+
error?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Dependency-injection seam for checkUpdates (testable without network). */
|
|
176
|
+
export interface UpdateCheckDeps {
|
|
177
|
+
/** Absolute path of the anchor package manifest, when resolvable. */
|
|
178
|
+
anchorManifestPath?: string
|
|
179
|
+
/** Resolve a package.json specifier to its absolute path (host require). */
|
|
180
|
+
resolve(specifier: string): string | undefined
|
|
181
|
+
/** Probe one package's latest npm version; undefined on failure. */
|
|
182
|
+
fetchLatest(name: string): Promise<string | undefined>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve the anchor package's manifest path. The aggregate package is the
|
|
187
|
+
* canonical entry point; this plugin's own package is the fallback.
|
|
188
|
+
* @param resolve - a Node resolve implementation scoped to the host process.
|
|
189
|
+
* @returns the absolute manifest path, or undefined when neither is installed.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveAnchorManifest(
|
|
192
|
+
resolve: (specifier: string) => string,
|
|
193
|
+
): string | undefined {
|
|
194
|
+
for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) {
|
|
195
|
+
try {
|
|
196
|
+
return resolve(name + '/package.json')
|
|
197
|
+
} catch {
|
|
198
|
+
// Not installed — try the next candidate.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The resolved update target: the profile pnpm runs in plus the package list. */
|
|
205
|
+
export interface UpdateTarget {
|
|
206
|
+
/** Owning profile name. */
|
|
207
|
+
profileName: string
|
|
208
|
+
/** Absolute profile directory pnpm runs in. */
|
|
209
|
+
profileDir: string
|
|
210
|
+
/** The package names pnpm updates (anchor first). */
|
|
211
|
+
packages: string[]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve what an update would touch: the owning profile directory and the
|
|
216
|
+
* family package list. Fails with an error code when the anchor is missing
|
|
217
|
+
* ('not-found') or is a local dev install ('link').
|
|
218
|
+
* @param deps - the anchor manifest path (resolveAnchorManifest output).
|
|
219
|
+
* @returns the target, or the failure code.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveUpdateTarget(
|
|
222
|
+
deps: { anchorManifestPath?: string },
|
|
223
|
+
): UpdateTarget | { error: 'not-found' | 'link' } {
|
|
224
|
+
const manifestPath = deps.anchorManifestPath
|
|
225
|
+
if (manifestPath === undefined) return { error: 'not-found' }
|
|
226
|
+
const manifest = readManifest(manifestPath)
|
|
227
|
+
if (manifest === undefined) return { error: 'not-found' }
|
|
228
|
+
const anchor = typeof manifest.name === 'string' ? manifest.name : undefined
|
|
229
|
+
if (anchor === undefined) return { error: 'not-found' }
|
|
230
|
+
const profile = findProfile(manifestPath)
|
|
231
|
+
if (profile === undefined) return { error: 'link' }
|
|
232
|
+
const profileManifest = readManifest(join(profile.dir, 'package.json'))
|
|
233
|
+
const spec = (profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor]
|
|
234
|
+
if (isLinkedSpec(spec)) return { error: 'link' }
|
|
235
|
+
return {
|
|
236
|
+
profileName: profile.name,
|
|
237
|
+
profileDir: profile.dir,
|
|
238
|
+
packages: [anchor, ...familyChildren(manifest)],
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Family children of the anchor: its dependencies under the family scope. */
|
|
243
|
+
export function familyChildren(anchorManifest: Record<string, unknown>): string[] {
|
|
244
|
+
const dependencies = anchorManifest.dependencies
|
|
245
|
+
if (typeof dependencies !== 'object' || dependencies === null) return []
|
|
246
|
+
const names: string[] = []
|
|
247
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
248
|
+
if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string') names.push(name)
|
|
249
|
+
}
|
|
250
|
+
return names
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Probe the npm registry for one package's latest release.
|
|
255
|
+
* @param name - the package name (scope slash URL-encoded).
|
|
256
|
+
* @param fetchImpl - the fetch implementation (global fetch in the host).
|
|
257
|
+
* @param timeoutMs - probe timeout.
|
|
258
|
+
* @returns the latest version string, or undefined on any failure.
|
|
259
|
+
*/
|
|
260
|
+
export async function fetchLatestVersion(
|
|
261
|
+
name: string,
|
|
262
|
+
fetchImpl: (url: string) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
|
|
263
|
+
timeoutMs = 10_000,
|
|
264
|
+
): Promise<string | undefined> {
|
|
265
|
+
try {
|
|
266
|
+
const controller = new AbortController()
|
|
267
|
+
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
|
|
268
|
+
try {
|
|
269
|
+
const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest')
|
|
270
|
+
if (!response.ok) return undefined
|
|
271
|
+
const body = await response.json()
|
|
272
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
273
|
+
const version = (body as Record<string, unknown>).version
|
|
274
|
+
return typeof version === 'string' ? version : undefined
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(timer)
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
return undefined
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The resolved current version of one family package (probe failure tolerated). */
|
|
284
|
+
function readInstalledVersion(resolve: (specifier: string) => string | undefined, name: string): string {
|
|
285
|
+
try {
|
|
286
|
+
const path = resolve(name + '/package.json')
|
|
287
|
+
const version = path === undefined ? undefined : readManifest(path)?.version
|
|
288
|
+
return typeof version === 'string' ? version : '0.0.0'
|
|
289
|
+
} catch {
|
|
290
|
+
return '0.0.0'
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build the update status: locate the anchor, detect the install mode, and
|
|
296
|
+
* compare every family package against the npm registry.
|
|
297
|
+
* @param deps - manifest resolution + registry probe seams.
|
|
298
|
+
* @returns the status snapshot.
|
|
299
|
+
*/
|
|
300
|
+
export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus> {
|
|
301
|
+
const manifestPath = deps.anchorManifestPath
|
|
302
|
+
if (manifestPath === undefined) {
|
|
303
|
+
return { mode: 'missing', packages: [], outdated: false }
|
|
304
|
+
}
|
|
305
|
+
const manifest = readManifest(manifestPath)
|
|
306
|
+
if (manifest === undefined) {
|
|
307
|
+
return { mode: 'missing', packages: [], outdated: false }
|
|
308
|
+
}
|
|
309
|
+
const anchor = typeof manifest.name === 'string' ? manifest.name : undefined
|
|
310
|
+
if (anchor === undefined) {
|
|
311
|
+
return { mode: 'missing', packages: [], outdated: false }
|
|
312
|
+
}
|
|
313
|
+
// A package inside a dsh profile directory is a registry install; one that
|
|
314
|
+
// lives outside every profile (e.g. a repo checkout wired through
|
|
315
|
+
// link-profile.mjs) is a local dev install pnpm cannot update.
|
|
316
|
+
const profile = findProfile(manifestPath)
|
|
317
|
+
const profileManifest = profile === undefined ? undefined : readManifest(join(profile.dir, "package.json"))
|
|
318
|
+
const linked = profile === undefined
|
|
319
|
+
|| isLinkedSpec((profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor])
|
|
320
|
+
if (profile === undefined) {
|
|
321
|
+
return { mode: 'link', packages: [], outdated: false }
|
|
322
|
+
}
|
|
323
|
+
const names = [anchor, ...familyChildren(manifest)]
|
|
324
|
+
const packages: UpdatePackageStatus[] = []
|
|
325
|
+
let probeFailures = 0
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const latest = await deps.fetchLatest(name)
|
|
328
|
+
if (latest === undefined) probeFailures++
|
|
329
|
+
const current = readInstalledVersion(deps.resolve, name)
|
|
330
|
+
packages.push({
|
|
331
|
+
name,
|
|
332
|
+
current,
|
|
333
|
+
latest,
|
|
334
|
+
outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
// Registry unreachable: every probe failed — report the outage distinctly
|
|
338
|
+
// instead of a misleading "all up to date" (the panel needs the reason).
|
|
339
|
+
const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
|
|
340
|
+
return {
|
|
341
|
+
mode: linked ? 'link' : 'npm',
|
|
342
|
+
profileName: profile.name,
|
|
343
|
+
anchor,
|
|
344
|
+
packages,
|
|
345
|
+
outdated: packages.some(packageStatus => packageStatus.outdated),
|
|
346
|
+
...(error !== undefined ? { error } : {}),
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Structured failure codes the browser half translates. */
|
|
351
|
+
export type UpdateErrorCode =
|
|
352
|
+
/** pnpm is not on PATH. */
|
|
353
|
+
| 'pnpm-missing'
|
|
354
|
+
/** The install exceeded the hard timeout. */
|
|
355
|
+
| 'timeout'
|
|
356
|
+
/** The anchor package is not installed. */
|
|
357
|
+
| 'not-found'
|
|
358
|
+
/** The anchor is a local link/dev install pnpm cannot update. */
|
|
359
|
+
| 'link'
|
|
360
|
+
/** pnpm exited non-zero. */
|
|
361
|
+
| 'pnpm-failed'
|
|
362
|
+
|
|
363
|
+
/** Result of one update run. */
|
|
364
|
+
export interface UpdateRunResult {
|
|
365
|
+
ok: boolean
|
|
366
|
+
/** pnpm exit code (null when the process never started or was killed). */
|
|
367
|
+
exitCode: number | null
|
|
368
|
+
/** Captured pnpm output tail (diagnostics for the panel). */
|
|
369
|
+
output: string
|
|
370
|
+
/** Human-readable failure description (fallback copy). */
|
|
371
|
+
error?: string
|
|
372
|
+
/** Structured failure code (translated by the browser half). */
|
|
373
|
+
errorCode?: UpdateErrorCode
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Dependency-injection seam for runUpdate. */
|
|
377
|
+
export interface UpdateRunDeps {
|
|
378
|
+
/** The profile directory pnpm runs in. */
|
|
379
|
+
profileDir: string
|
|
380
|
+
/** The package names pnpm updates. */
|
|
381
|
+
packages: readonly string[]
|
|
382
|
+
/** Spawn seam (defaults to child_process.spawn). */
|
|
383
|
+
spawnImpl?: typeof spawn
|
|
384
|
+
/** Hard timeout; the child is killed on expiry. */
|
|
385
|
+
timeoutMs?: number
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Cap on captured pnpm output (keeps error payloads bounded). */
|
|
389
|
+
const OUTPUT_CAP = 16 * 1024
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Run the update: `pnpm update <packages>` inside the profile directory.
|
|
393
|
+
* @param deps - profile dir, package list, and spawn/timeout seams.
|
|
394
|
+
* @returns the outcome with captured output.
|
|
395
|
+
*/
|
|
396
|
+
export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
const child = (deps.spawnImpl ?? spawn)('pnpm', ['update', ...deps.packages], {
|
|
399
|
+
cwd: deps.profileDir,
|
|
400
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
401
|
+
})
|
|
402
|
+
let output = ''
|
|
403
|
+
const append = (chunk: Buffer): void => {
|
|
404
|
+
output += chunk.toString('utf8')
|
|
405
|
+
if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP)
|
|
406
|
+
}
|
|
407
|
+
child.stdout?.on('data', append)
|
|
408
|
+
child.stderr?.on('data', append)
|
|
409
|
+
const timer = setTimeout(() => {
|
|
410
|
+
child.kill('SIGTERM')
|
|
411
|
+
resolve({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' })
|
|
412
|
+
}, deps.timeoutMs ?? 10 * 60_000)
|
|
413
|
+
child.on('error', (error: Error) => {
|
|
414
|
+
clearTimeout(timer)
|
|
415
|
+
const missing = (error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
416
|
+
resolve({
|
|
417
|
+
ok: false,
|
|
418
|
+
exitCode: null,
|
|
419
|
+
output,
|
|
420
|
+
error: missing ? 'pnpm not found on PATH' : error.message,
|
|
421
|
+
errorCode: missing ? 'pnpm-missing' : undefined,
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
child.on('close', (code) => {
|
|
425
|
+
clearTimeout(timer)
|
|
426
|
+
resolve({
|
|
427
|
+
ok: code === 0,
|
|
428
|
+
exitCode: code,
|
|
429
|
+
output,
|
|
430
|
+
error: code === 0 ? undefined : 'pnpm exited with code ' + String(code),
|
|
431
|
+
...(code === 0 ? {} : { errorCode: 'pnpm-failed' as const }),
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
}
|