dsh-plugin 2.2.0 → 2.2.2

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.
@@ -657,6 +657,11 @@ function spawnMutation(options: {
657
657
  task.status = result.timedOut || result.exitCode !== 0 ? 'failed' : 'done'
658
658
  if (task.status === 'done') {
659
659
  task.progress = 100
660
+ // 实际装上的版本:目录版本可能滞后,客户端账本必须记这个值,否则「有更新」永久失真
661
+ if (!isGlobalNpm && action !== 'remove') {
662
+ const installed = installedVersionOf(profile, target)
663
+ if (installed !== null) task.installedVersion = installed
664
+ }
660
665
  // 市场自更新:若 bundle 已被取消勾选,重启后市场不会出现,结果页要明确提示
661
666
  if ((action === 'add' || action === 'update') && (target === 'dsh-plugin' || target.startsWith('dsh-plugin@'))
662
667
  && !bundleIncludes(profile, 'dsh-plugin')) {
@@ -699,10 +704,38 @@ function spawnMutation(options: {
699
704
  pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`)
700
705
  }
701
706
  }
702
- return result
707
+ // 带上最终生效的源:后续注解(如 [dsh-too-old])读元数据必须与安装同源,
708
+ // 否则换过源之后会去另一个源查,查不到就静默漏报
709
+ return activeRegistry === undefined ? result : { ...result, registry: activeRegistry }
703
710
  })()
704
711
  }
705
712
 
713
+ /**
714
+ * 安装成功后从 profile 的 `node_modules/<pkg>/package.json` 读**实际**版本;
715
+ * 定位不到包名或读不到返回 null。客户端拿它写版本账本 —— 目录里的版本可能滞后。
716
+ */
717
+ export function installedVersionOf(profile: string, target: string): string | null {
718
+ const profileDir = profileDirectory(profile)
719
+ let deps: Record<string, string> = {}
720
+ try {
721
+ const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> }
722
+ deps = pkg.dependencies ?? {}
723
+ } catch {
724
+ return null
725
+ }
726
+ const targetRepo = githubRepoOf(target)?.toLowerCase()
727
+ const name = targetRepo !== undefined
728
+ ? Object.keys(deps).find((k) => githubRepoOf(deps[k])?.toLowerCase() === targetRepo)
729
+ : deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target)
730
+ if (!name) return null
731
+ try {
732
+ const meta = JSON.parse(readFileSync(join(profileDir, 'node_modules', name, 'package.json'), 'utf8')) as { version?: unknown }
733
+ return typeof meta.version === 'string' && meta.version !== '' ? meta.version : null
734
+ } catch {
735
+ return null
736
+ }
737
+ }
738
+
706
739
  /**
707
740
  * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
708
741
  * dsh.bundle.patch)是否真实存在。npm 包也可能漏发构建产物(lib/ 等),pnpm 装完没有
@@ -805,8 +838,9 @@ async function annotateDshTooOld(result: InstallResult, options: {
805
838
  if (options.action === 'remove' || result.exitCode === 0) return result
806
839
  // 全局 npm 装的是本机 CLI 工具,不进 profile,没有宿主版本要求
807
840
  if ((options.globalNpm ?? []).length > 0) return result
808
- // 用安装实际使用的源读元数据:私有源/镜像里也能读到同一个包的声明
809
- const line = await dshTooOldMarker(options.target, { registry: options.registries?.[0] })
841
+ // 用安装**实际**使用的源读元数据:换源重试后候选表首位可能并不是真正生效的那个,
842
+ // 用错源会读不到包而静默漏报;私有源/镜像里同样能读到包声明的 engines
843
+ const line = await dshTooOldMarker(options.target, { registry: result.registry ?? options.registries?.[0] })
810
844
  if (line === null) return result
811
845
  if (options.task && options.task.status !== 'cancelled') pushLine(options.task, line)
812
846
  return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line }
@@ -25,8 +25,11 @@ export const RELEASE_AGE_TIMEOUT_MS = 8000
25
25
  const MAX_PACKUMENT_BYTES = 10 * 1024 * 1024
26
26
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
27
27
 
28
- /** 会话内缓存(包名 → 版本与发布时间):只存成功结果,一次网络抖动不会把判断永久粘住。 */
29
- const releaseCache = new Map<string, NpmRelease>()
28
+ /** 会话内缓存(源 + 包名 → 结果):只存成功结果,一次网络抖动不会把判断永久粘住。
29
+ * 必须带过期时间 —— 插件中心可能连续开着几小时,作者发了新版本后不能一直沿用旧结果;
30
+ * 也必须把源算进键 —— 同一个包在官方源与镜像上的 latest / 发布时间不同,混用会算错门禁。 */
31
+ const CACHE_TTL_MS = 10 * 60 * 1000
32
+ const releaseCache = new Map<string, { at: number; release: NpmRelease }>()
30
33
 
31
34
  /** 从完整 packument 取 dist-tags.latest 对应版本及其发布时间;缺任一字段返回 null。 */
32
35
  export function latestReleaseOf(packument: unknown): NpmRelease | null {
@@ -87,21 +90,22 @@ async function fetchPackument(url: string, proxy: string, timeoutMs: number): Pr
87
90
  return getJson(url, timeoutMs)
88
91
  }
89
92
 
90
- /** 查询一个包的最新版本与发布时间(成功结果缓存,同会话内同包不重复请求);registry 为空回退官方源,失败返回 null。 */
93
+ /** 查询一个包的最新版本与发布时间(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
91
94
  export async function lookupReleaseAge(
92
95
  packageName: string,
93
96
  registry: string,
94
97
  proxy: string,
95
98
  timeoutMs = RELEASE_AGE_TIMEOUT_MS,
96
99
  ): Promise<NpmRelease | null> {
97
- const cached = releaseCache.get(packageName)
98
- if (cached !== undefined) return cached
99
100
  const base = registry.replace(/\/+$/, '') || DEFAULT_NPM_REGISTRY
101
+ const key = `${base}\n${packageName}`
102
+ const cached = releaseCache.get(key)
103
+ if (cached !== undefined && Date.now() - cached.at < CACHE_TTL_MS) return cached.release
100
104
  const body = await fetchPackument(`${base}/${encodeURIComponent(packageName)}`, proxy, timeoutMs)
101
105
  if (body === null) return null
102
106
  let parsed: unknown
103
107
  try { parsed = JSON.parse(body) } catch { return null }
104
108
  const release = latestReleaseOf(parsed)
105
- if (release !== null) releaseCache.set(packageName, release)
109
+ if (release !== null) releaseCache.set(key, { at: Date.now(), release })
106
110
  return release
107
111
  }
@@ -17,7 +17,7 @@
17
17
  * 存储:`DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
18
18
  * { "<owner>/<repo>"(小写): { version, updatedAt, installedAt } }
19
19
  */
20
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
20
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
21
21
  import { homedir } from 'node:os'
22
22
  import { dirname, join } from 'node:path'
23
23
 
@@ -38,10 +38,25 @@ export function versionsFilePath(profile: string): string {
38
38
  return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile, 'gro.ngilp-hsd-versions.json')
39
39
  }
40
40
 
41
+ /** 读取某 profile 的版本账本;缺失/损坏/字段不全一律归一化成完整形状。
42
+ * 安装一开始只会写入 npm 包名映射(版本要等任务结束才知道),所以磁盘上可能存在
43
+ * 只有 npmPackage 的记录 —— 原样下发会让客户端的版本比较拿到 undefined 直接抛错。 */
41
44
  export function readInstalledVersions(profile: string): InstalledVersionTable {
42
45
  try {
43
46
  const raw = JSON.parse(readFileSync(versionsFilePath(profile), 'utf8')) as unknown
44
- return raw !== null && typeof raw === 'object' ? raw as InstalledVersionTable : {}
47
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {}
48
+ const table: InstalledVersionTable = {}
49
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
50
+ if (value === null || typeof value !== 'object') continue
51
+ const rec = value as Partial<InstalledVersionRecord>
52
+ table[key] = {
53
+ version: typeof rec.version === 'string' ? rec.version : '',
54
+ updatedAt: typeof rec.updatedAt === 'string' ? rec.updatedAt : '',
55
+ installedAt: typeof rec.installedAt === 'string' ? rec.installedAt : '',
56
+ ...(typeof rec.npmPackage === 'string' && rec.npmPackage !== '' ? { npmPackage: rec.npmPackage } : {}),
57
+ }
58
+ }
59
+ return table
45
60
  } catch {
46
61
  return {}
47
62
  }
@@ -64,7 +79,14 @@ export function recordInstalledVersion(profile: string, repo: string, version: s
64
79
  export function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void {
65
80
  const table = readInstalledVersions(profile)
66
81
  const key = repo.toLowerCase()
67
- table[key] = { ...table[key], npmPackage }
82
+ // 显式写全字段:这一步发生在安装开始时,版本还不知道 —— 但落盘记录必须是完整形状,
83
+ // 否则只带 npmPackage 的残缺记录会在客户端版本比较时崩掉整块界面
84
+ table[key] = {
85
+ version: table[key]?.version ?? '',
86
+ updatedAt: table[key]?.updatedAt ?? '',
87
+ installedAt: table[key]?.installedAt ?? '',
88
+ npmPackage,
89
+ }
68
90
  writeVersions(profile, table)
69
91
  }
70
92
 
@@ -78,5 +100,9 @@ export function removeInstalledVersion(profile: string, repo: string): void {
78
100
  function writeVersions(profile: string, table: InstalledVersionTable): void {
79
101
  const file = versionsFilePath(profile)
80
102
  mkdirSync(dirname(file), { recursive: true })
81
- writeFileSync(file, JSON.stringify(table, null, 2) + '\n', 'utf8')
103
+ // 先写临时文件再原子替换:直接覆盖时进程被杀/磁盘满会留下截断的 JSON,
104
+ // 下次读取整份账本都会失效(更新检测静默停摆),而用户看不到任何报错
105
+ const tmp = `${file}.tmp`
106
+ writeFileSync(tmp, JSON.stringify(table, null, 2) + '\n', 'utf8')
107
+ renameSync(tmp, file)
82
108
  }