@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.
Files changed (43) hide show
  1. package/README.md +17 -6
  2. package/lib/client.js +403 -14
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +406 -17
  5. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  6. package/lib/types/client/RemoteEntry.js +9 -7
  7. package/lib/types/client/UpdateEntry.d.ts +14 -0
  8. package/lib/types/client/UpdateEntry.d.ts.map +1 -0
  9. package/lib/types/client/UpdateEntry.js +68 -0
  10. package/lib/types/client/UpdatePanel.d.ts +40 -0
  11. package/lib/types/client/UpdatePanel.d.ts.map +1 -0
  12. package/lib/types/client/UpdatePanel.js +82 -0
  13. package/lib/types/client/index.d.ts +2 -0
  14. package/lib/types/client/index.d.ts.map +1 -1
  15. package/lib/types/client/locales.d.ts +56 -0
  16. package/lib/types/client/locales.d.ts.map +1 -1
  17. package/lib/types/client/locales.js +58 -0
  18. package/lib/types/client/update-api.d.ts +19 -0
  19. package/lib/types/client/update-api.d.ts.map +1 -0
  20. package/lib/types/client/update-api.js +27 -0
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +45 -1
  23. package/lib/types/routes.d.ts +15 -0
  24. package/lib/types/routes.d.ts.map +1 -1
  25. package/lib/types/routes.js +1 -1
  26. package/lib/types/update-routes.d.ts +30 -0
  27. package/lib/types/update-routes.d.ts.map +1 -0
  28. package/lib/types/update-routes.js +52 -0
  29. package/lib/types/update.d.ts +191 -0
  30. package/lib/types/update.d.ts.map +1 -0
  31. package/lib/types/update.js +333 -0
  32. package/package.json +1 -1
  33. package/src/client/RemoteEntry.tsx +12 -7
  34. package/src/client/UpdateEntry.tsx +96 -0
  35. package/src/client/UpdatePanel.tsx +203 -0
  36. package/src/client/index.ts +2 -0
  37. package/src/client/locales.ts +58 -0
  38. package/src/client/remote.module.css +109 -0
  39. package/src/client/update-api.ts +29 -0
  40. package/src/index.ts +51 -1
  41. package/src/routes.ts +1 -1
  42. package/src/update-routes.ts +71 -0
  43. 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
+ }