@linxin666/dsh-remote-web-ui 0.1.15 → 0.1.17

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/src/update.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Remote update support for the dsh-web-ui family — host half. Detects the
3
3
  * installed aggregate package (@linxin666/dsh-web-ui-all) and its family
4
4
  * children, probes the npm registry for newer releases, and runs the actual
5
- * update as `pnpm update` inside the owning dsh profile directory.
5
+ * update as `pnpm update --latest` inside the owning dsh profile directory.
6
6
  *
7
7
  * Pure logic with injected seams (manifest reading, registry fetches, process
8
8
  * spawning) so the whole surface is unit-testable without touching disk,
@@ -184,16 +184,19 @@ export interface UpdateCheckDeps {
184
184
 
185
185
  /**
186
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.
187
+ * canonical entry point; this plugin's own package is the fallback. Both a
188
+ * throwing resolve and an undefined return mean "not installed" and move on
189
+ * to the next candidate.
188
190
  * @param resolve - a Node resolve implementation scoped to the host process.
189
191
  * @returns the absolute manifest path, or undefined when neither is installed.
190
192
  */
191
193
  export function resolveAnchorManifest(
192
- resolve: (specifier: string) => string,
194
+ resolve: (specifier: string) => string | undefined,
193
195
  ): string | undefined {
194
196
  for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) {
195
197
  try {
196
- return resolve(name + '/package.json')
198
+ const path = resolve(name + '/package.json')
199
+ if (path !== undefined) return path
197
200
  } catch {
198
201
  // Not installed — try the next candidate.
199
202
  }
@@ -280,14 +283,22 @@ export async function fetchLatestVersion(
280
283
  }
281
284
  }
282
285
 
286
+ /**
287
+ * Sentinel for an installed version that could not be read (missing manifest
288
+ * or resolve failure). It is a real-looking version so `checkUpdates` can
289
+ * render the affected row, but the verified-update comparison must never
290
+ * treat it as evidence — a read failure is not a version that "moved".
291
+ */
292
+ const VERSION_UNKNOWN = '0.0.0'
293
+
283
294
  /** The resolved current version of one family package (probe failure tolerated). */
284
295
  function readInstalledVersion(resolve: (specifier: string) => string | undefined, name: string): string {
285
296
  try {
286
297
  const path = resolve(name + '/package.json')
287
298
  const version = path === undefined ? undefined : readManifest(path)?.version
288
- return typeof version === 'string' ? version : '0.0.0'
299
+ return typeof version === 'string' ? version : VERSION_UNKNOWN
289
300
  } catch {
290
- return '0.0.0'
301
+ return VERSION_UNKNOWN
291
302
  }
292
303
  }
293
304
 
@@ -359,6 +370,10 @@ export type UpdateErrorCode =
359
370
  | 'link'
360
371
  /** pnpm exited non-zero. */
361
372
  | 'pnpm-failed'
373
+ /** pnpm exited 0 but the installed versions did not move. */
374
+ | 'stale'
375
+ /** pnpm exited 0 but the post-run version check could not verify the install. */
376
+ | 'verify-failed'
362
377
 
363
378
  /** Result of one update run. */
364
379
  export interface UpdateRunResult {
@@ -397,6 +412,17 @@ const OUTPUT_CAP = 16 * 1024
397
412
  */
398
413
  const WIN_CMD_MISSING_RE = /not recognized as an internal or external command/i
399
414
 
415
+ /**
416
+ * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
417
+ * silently skips same-day releases — `pnpm update --latest` then exits 0
418
+ * without moving anything and the post-run verification would misreport a
419
+ * stale no-op. The update here is explicitly user-initiated and the panel
420
+ * shows exactly which versions are being installed, so the gate only adds a
421
+ * confusing silent no-op; override it per-invocation instead of asking every
422
+ * user to edit the profile's pnpm-workspace.yaml.
423
+ */
424
+ const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
425
+
400
426
  /**
401
427
  * Run the update inside the profile directory. Tries pnpm first, falls back
402
428
  * to corepack and then npx when the previous command is missing (ENOENT);
@@ -419,10 +445,18 @@ export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
419
445
  }
420
446
  // Ordered fallback chain: each is tried only when the previous one is
421
447
  // missing on PATH (ENOENT on the spawn error event, never on close).
448
+ // `--latest` is required: dsh plugin writes exact-version specs (e.g.
449
+ // "0.1.12" without a range), and plain `pnpm update` treats an exact spec
450
+ // as pinned — it prints "Already up to date" and exits 0 without moving
451
+ // the installed version, so the panel would report a false success.
452
+ // `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
453
+ // pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
454
+ // releases in place, which the post-run verification would then have to
455
+ // surface as `stale` even though the update is legitimate.
422
456
  const candidates: ReadonlyArray<{ command: string; args: string[] }> = [
423
- { command: 'pnpm', args: ['update', ...packages] },
424
- { command: 'corepack', args: ['pnpm', 'update', ...packages] },
425
- { command: 'npx', args: ['--yes', 'pnpm', 'update', ...packages] },
457
+ { command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
458
+ { command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
459
+ { command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
426
460
  ]
427
461
  // `output` accumulates across candidates for UI display; `currentOutput`
428
462
  // is reset per candidate and carries only that candidate's own diagnostics
@@ -523,3 +557,105 @@ export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
523
557
  runCandidate(0)
524
558
  })
525
559
  }
560
+
561
+ /** Seam set for the verified update run (run + post-run status check). */
562
+ export interface UpdateRunVerifiedDeps {
563
+ /** The pnpm run (profile dir + package list). */
564
+ run: UpdateRunDeps
565
+ /** The post-run status check (same seams as checkUpdates). */
566
+ check: UpdateCheckDeps
567
+ }
568
+
569
+ /**
570
+ * Run the update, then verify the installed versions actually moved. pnpm
571
+ * exits 0 even when it silently kept the installed versions — runUpdate
572
+ * overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
573
+ * `--config.minimumReleaseAge=0`, but an older pnpm without the override or
574
+ * another silent no-op can still keep versions in place — so a green exit
575
+ * alone must not report a misleading "update complete". Re-read the
576
+ * installed versions afterwards and surface a `stale` failure (with the
577
+ * captured pnpm output) when nothing moved, so the panel can tell the user
578
+ * how to unblock the gate instead of claiming success.
579
+ *
580
+ * The stale decision anchors on the pre-run installed versions, not on the
581
+ * registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
582
+ * latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
583
+ * path is re-resolved rather than reused from boot time: pnpm removes the old
584
+ * version's .pnpm directory on update, so a boot-time captured path would
585
+ * fail to read and collapse a successful update into a bogus 'missing'.
586
+ *
587
+ * A green exit whose verification has nothing to compare (registry probe
588
+ * outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
589
+ * must never read as success. A green exit where no package moved but the
590
+ * post-run check could not prove the install is fully current (a partial
591
+ * probe failure hides whether a gate kept something back) is also
592
+ * `verify-failed`: it must not collapse into a success either.
593
+ * @param deps - the run and check seams.
594
+ * @returns the run result; `stale` when exit 0 left every version in place,
595
+ * `verify-failed` when the post-run check could not verify anything.
596
+ */
597
+ export async function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<UpdateRunResult> {
598
+ // Pre-run snapshot: the installed versions the update starts from. Only
599
+ // successfully-read versions are recorded — a read failure (VERSION_UNKNOWN)
600
+ // is not a baseline and must not look like a version that "moved", or a
601
+ // green exit that left everything in place could be mistaken for success.
602
+ const before = new Map<string, string>()
603
+ for (const name of deps.run.packages) {
604
+ const version = readInstalledVersion(deps.check.resolve, name)
605
+ if (version !== VERSION_UNKNOWN) before.set(name, version)
606
+ }
607
+ const result = await runUpdate(deps.run)
608
+ if (!result.ok) return result
609
+ // Re-resolve the anchor after the run: pnpm removes the old version's
610
+ // .pnpm directory, so a boot-time captured path no longer reads. Fall back
611
+ // to the provided path only when nothing resolves now.
612
+ const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath
613
+ const status = await checkUpdates({ ...deps.check, anchorManifestPath })
614
+ // A green exit with no comparable result is not a success: registry probe
615
+ // outage, missing anchor, or a non-npm install mode all mean the post-run
616
+ // check could not verify anything.
617
+ if (status.error !== undefined || status.mode !== 'npm') {
618
+ return {
619
+ ...result,
620
+ ok: false,
621
+ errorCode: 'verify-failed',
622
+ error: 'pnpm exited 0 but the post-run version check could not verify the install',
623
+ }
624
+ }
625
+ // A package "moved" only when a known pre-run version differs from a known
626
+ // post-run version. A read failure on either side (VERSION_UNKNOWN) is not
627
+ // evidence of movement and must not turn a no-op update into a success; a
628
+ // package without a pre-run baseline is ignored — only the packages pnpm
629
+ // was told to update count as evidence.
630
+ const moved = status.packages.some(packageStatus => {
631
+ const beforeVersion = before.get(packageStatus.name)
632
+ if (beforeVersion === undefined || packageStatus.current === VERSION_UNKNOWN) return false
633
+ return packageStatus.current !== beforeVersion
634
+ })
635
+ if (moved) return result
636
+ // Nothing moved. A green exit is only a true no-op when the post-run check
637
+ // proves the install is already current: every probe succeeded and nothing
638
+ // is outdated. If a probe failed we cannot tell "already up to date" from
639
+ // "a gate silently kept it back", so report verify-failed rather than a
640
+ // false success.
641
+ if (status.outdated) {
642
+ // stale: the check ran, a newer release exists, and no package moved
643
+ // (e.g. the minimumReleaseAge gate kept everything in place).
644
+ return {
645
+ ...result,
646
+ ok: false,
647
+ errorCode: 'stale',
648
+ error: 'pnpm exited 0 but the installed versions did not change',
649
+ }
650
+ }
651
+ const unverifiable = status.packages.some(packageStatus => packageStatus.latest === undefined)
652
+ if (unverifiable) {
653
+ return {
654
+ ...result,
655
+ ok: false,
656
+ errorCode: 'verify-failed',
657
+ error: 'pnpm exited 0 but the post-run version check could not verify the install',
658
+ }
659
+ }
660
+ return result
661
+ }