@chatcode/cco-market 1.45.1

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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/updates.ts ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Update detection: per-plugin comparison of what the profile has against
3
+ * the source of truth — git HEAD for github installs, the npm latest
4
+ * dist-tag for registry installs — with a TTL cache.
5
+ */
6
+
7
+ import { DIST_TAG, type Channel } from './channels.ts'
8
+ import { resolveHeadCommit } from './accelerate.ts'
9
+ import { marketFetch } from './net.ts'
10
+ import { activeRegion } from './regions.ts'
11
+ import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
12
+ import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGitHostedSpec, repoOfTarget } from './sources.ts'
13
+
14
+ export interface UpdateStatus {
15
+ kind: 'github' | 'npm' | 'linked'
16
+ version: string | null
17
+ current: string | null
18
+ latest: string | null
19
+ /**
20
+ * A NEWER version exists. Forwards only, always — every caller reads it
21
+ * as "there is an upgrade" and labels a button accordingly.
22
+ */
23
+ updateAvailable: boolean
24
+ /** Taking this update replaces a local package source with its matched online release. */
25
+ restoreRequired?: boolean
26
+ /** Independent, explicit source switch offered for verified legacy Git installs. */
27
+ sourceMigration?: { kind: 'git-to-npm'; repo: string; target: string }
28
+ /**
29
+ * The version this package's channel points at, when it differs from what
30
+ * is installed and is NOT newer.
31
+ *
32
+ * A separate field rather than a second meaning for `updateAvailable`,
33
+ * which was tried and leaked immediately: the market page has three
34
+ * consumers of that flag (the header banner, "update all", the row
35
+ * button) and all three announced a DOWNGRADE as "a new version is
36
+ * available". One field, one meaning; a caller that has not been taught
37
+ * about channel switches simply does not offer one.
38
+ *
39
+ * Only a channel-following package can be in this state, and it is the
40
+ * state that used to be unreachable: picking "stable" while a prerelease
41
+ * was installed compared 1.13.1 against 1.14.0-beta.1, found nothing
42
+ * newer, and answered "up to date" — so there was no way back off a
43
+ * channel the user had just left.
44
+ */
45
+ channelSwitch?: string
46
+ }
47
+
48
+ const UPDATES_TTL_MS = 30 * 60 * 1000
49
+ const GIT_REMOTE_HEAD_TIMEOUT_MS = 6000
50
+ let updatesCache: { key: string; at: number; data: Record<string, UpdateStatus> } | null = null
51
+
52
+ /**
53
+ * HEAD for an arbitrary smart-HTTP git remote (Gitea, GitLab, …). Same
54
+ * advertisement format as GitHub's info/refs; kept here so accelerate.ts
55
+ * does not need to import sources.ts.
56
+ */
57
+ async function resolveGitRemoteHead(spec: string, ref?: string): Promise<string | null> {
58
+ const url = gitUploadPackUrl(spec)
59
+ if (url === null) return null
60
+ const controller = new AbortController()
61
+ const timer = setTimeout(() => controller.abort(), GIT_REMOTE_HEAD_TIMEOUT_MS)
62
+ try {
63
+ const res = await marketFetch(url, {
64
+ signal: controller.signal,
65
+ headers: { 'user-agent': 'git/2.40.0' },
66
+ })
67
+ if (!res.ok) return null
68
+ const body = await res.text()
69
+ const contentType = res.headers?.get?.('content-type')?.toLowerCase() ?? ''
70
+ if (contentType.includes('text/html')
71
+ || !body.includes('# service=git-upload-pack')
72
+ || !/[0-9a-f]{40} (?:HEAD|refs\/(?:heads|tags)\/)/u.test(body)) {
73
+ return null
74
+ }
75
+ if (ref === undefined) {
76
+ const found = /([0-9a-f]{40}) HEAD/.exec(body)
77
+ return found === null ? null : found[1]!
78
+ }
79
+ const quoted = ref.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)
80
+ for (const namespace of ['heads', 'tags']) {
81
+ const found = new RegExp(String.raw`([0-9a-f]{40}) refs/${namespace}/${quoted}(?![^\s])`, 'u').exec(body)
82
+ if (found !== null) return found[1]!
83
+ }
84
+ return null
85
+ } catch {
86
+ return null
87
+ } finally {
88
+ clearTimeout(timer)
89
+ }
90
+ }
91
+
92
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
93
+ function parseSemver(v: string): { core: number[]; pre: string[] } | null {
94
+ const m = SEMVER.exec(v.trim())
95
+ if (m === null) return null
96
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] === undefined ? [] : m[4].split('.') }
97
+ }
98
+
99
+ /**
100
+ * Semver precedence: negative / 0 / positive like a comparator, or null when
101
+ * either side isn't a plain semver version. Build metadata is ignored, a
102
+ * release outranks any prerelease of the same core, and prerelease
103
+ * identifiers compare numerically when both are numeric (so `rc.10` > `rc.9`).
104
+ */
105
+ export function compareVersions(a: string, b: string): number | null {
106
+ const pa = parseSemver(a)
107
+ const pb = parseSemver(b)
108
+ if (pa === null || pb === null) return null
109
+ for (let i = 0; i < 3; i++) {
110
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
111
+ }
112
+ if (pa.pre.length === 0 || pb.pre.length === 0) return pb.pre.length - pa.pre.length
113
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
114
+ const x = pa.pre[i]
115
+ const y = pb.pre[i]
116
+ if (x === undefined) return -1
117
+ if (y === undefined) return 1
118
+ if (x === y) continue
119
+ const nx = /^\d+$/.test(x)
120
+ const ny = /^\d+$/.test(y)
121
+ if (nx && ny) return Number(x) - Number(y)
122
+ if (nx !== ny) return nx ? -1 : 1
123
+ return x < y ? -1 : 1
124
+ }
125
+ return 0
126
+ }
127
+
128
+ /**
129
+ * True only when the registry's `latest` is semantically HIGHER than what the
130
+ * profile has (#64 by @ZeroOrigin64). A plain `!==` also fires when a
131
+ * package's `latest` dist-tag is left pointing at an OLDER release than the
132
+ * pinned install — clicking "update" then rewrote the exact pin to `@latest`
133
+ * and downgraded the profile until it no longer booted.
134
+ *
135
+ * Undecidable inputs (missing or non-semver versions) report no update:
136
+ * without a direction we cannot promise the "update" isn't a downgrade.
137
+ */
138
+ export function isUpgrade(installed: string | null, latest: string | null): boolean {
139
+ if (installed === null || latest === null) return false
140
+ const cmp = compareVersions(latest, installed)
141
+ return cmp !== null && cmp > 0
142
+ }
143
+
144
+ /** Drop the cached listing (after a successful install/update/uninstall). */
145
+ export function invalidateUpdates(): void {
146
+ updatesCache = null
147
+ }
148
+
149
+ /**
150
+ * The npm registry update checks read, no trailing slash.
151
+ *
152
+ * Module state driven from the routes, like `updatesCache` beside it, rather
153
+ * than a parameter on all five call sites: the registry is a property of the
154
+ * running market, not of any one question asked of it, and threading it
155
+ * through would put the same value in five signatures and every test that
156
+ * calls them.
157
+ */
158
+ let registryBase = 'https://registry.npmjs.org'
159
+
160
+ /**
161
+ * Point update checks at a registry. Called when the download region
162
+ * resolves and whenever it changes.
163
+ *
164
+ * Dropping the cache is the load-bearing half. A mirror can lag the official
165
+ * registry by minutes, so answers gathered from one are not answers from the
166
+ * other — keeping them across a switch would report a version this registry
167
+ * cannot yet serve.
168
+ */
169
+ export function setUpdateRegistry(base: string): void {
170
+ const next = base.replace(/\/+$/, '')
171
+ if (next === registryBase) return
172
+ registryBase = next
173
+ updatesCache = null
174
+ }
175
+
176
+ /** A registry URL for `path`, on whichever registry is currently in force. */
177
+ function npmUrl(path: string): string {
178
+ return `${registryBase}/${path}`
179
+ }
180
+
181
+ async function fetchJson(url: string): Promise<unknown> {
182
+ // Through the proxy when one is configured: Node's global fetch ignores
183
+ // HTTP_PROXY, so on a machine whose route out is a local proxy every
184
+ // update check silently took the slow path — or none at all.
185
+ const res = await marketFetch(url, {
186
+ headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
187
+ signal: AbortSignal.timeout(10_000),
188
+ })
189
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
190
+ return res.json() as unknown
191
+ }
192
+
193
+ /**
194
+ * Evidence check behind the "wait a day" stale diagnosis (#45): whether the
195
+ * package's CURRENT latest release was published recently enough to sit
196
+ * inside pnpm's default fresh-release window. pnpm's silent hold leaves no
197
+ * trace in its output, so the publish time is the only verifiable signal.
198
+ * @returns true/false when the npm time metadata answers, null when it
199
+ * can't be determined (offline, unpublished, non-npm) — callers must NOT
200
+ * claim the safety wait on null.
201
+ */
202
+ export async function latestPublishedRecently(name: string, windowMs = 26 * 60 * 60 * 1000): Promise<boolean | null> {
203
+ try {
204
+ const doc = (await fetchJson(npmUrl(`${encodeURIComponent(name)}`))) as {
205
+ 'dist-tags'?: Record<string, string>
206
+ time?: Record<string, string>
207
+ }
208
+ const latest = doc['dist-tags']?.latest
209
+ const published = latest !== undefined ? doc.time?.[latest] : undefined
210
+ if (published === undefined) return null
211
+ const age = Date.now() - Date.parse(published)
212
+ return Number.isFinite(age) ? age < windowMs : null
213
+ } catch {
214
+ return null
215
+ }
216
+ }
217
+
218
+ /** The registry's current `latest` version for a package, or null when it can't be read. */
219
+ /**
220
+ * The version a channel subscriber should be offered: the newest build in
221
+ * the set that channel is willing to receive.
222
+ *
223
+ * A channel is a SET, not a tag. Someone on beta has not stopped accepting
224
+ * releases — they accept releases and prereleases — so beta means
225
+ * {latest, beta} and dev means {latest, beta, dev}. Reading it as one tag
226
+ * gets a real case wrong: once 1.14.0 ships, `beta` still points at
227
+ * 1.14.0-beta.1 until the next prerelease is cut, and following that tag
228
+ * literally would walk a subscriber BACKWARDS onto a build their channel
229
+ * has already moved past.
230
+ *
231
+ * The nesting is also what makes a channel leavable. Going backwards is
232
+ * only ever offered when the user narrows the set — picking stable while a
233
+ * prerelease is installed drops `beta` out of it, so the answer becomes
234
+ * `latest` and the market can finally offer the way back. That case used to
235
+ * be unreachable: comparing 1.13.1 against an installed 1.14.0-beta.1 found
236
+ * nothing newer and answered "up to date", so the control the user had just
237
+ * used appeared to do nothing.
238
+ *
239
+ * @param stable - the `latest` version, already fetched by the caller.
240
+ */
241
+ export async function versionOnChannel(
242
+ name: string,
243
+ channel: Channel,
244
+ stable: string | null,
245
+ ): Promise<string | null> {
246
+ let best = stable
247
+ for (const tag of EXTRA_TAGS[channel]) {
248
+ const candidate = await tagVersion(name, tag)
249
+ if (candidate !== null && (best === null || isUpgrade(best, candidate))) best = candidate
250
+ }
251
+ return best
252
+ }
253
+
254
+ /** Tags a channel adds on top of `latest`, widest channel last. */
255
+ const EXTRA_TAGS: Record<Channel, string[]> = {
256
+ stable: [],
257
+ beta: [DIST_TAG.beta],
258
+ dev: [DIST_TAG.beta, DIST_TAG.dev],
259
+ }
260
+
261
+ /** One dist-tag's version, or null when it isn't published or can't be read. */
262
+ async function tagVersion(name: string, tag: string): Promise<string | null> {
263
+ try {
264
+ const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/${tag}`))) as { version?: string }
265
+ return typeof meta.version === 'string' ? meta.version : null
266
+ } catch {
267
+ // An unpublished tag is the ordinary case for a channel nobody has cut
268
+ // a build on yet, and a registry hiccup must not take the whole update
269
+ // check down with it.
270
+ return null
271
+ }
272
+ }
273
+
274
+ export async function fetchNpmLatest(name: string): Promise<string | null> {
275
+ try {
276
+ const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`))) as { version?: string }
277
+ return typeof meta.version === 'string' ? meta.version : null
278
+ } catch {
279
+ return null
280
+ }
281
+ }
282
+
283
+ /** Per-plugin update checks; a failed check reports no update rather than failing the listing. */
284
+ export async function checkUpdates(
285
+ profile: string,
286
+ force = false,
287
+ explicitDir?: string,
288
+ /**
289
+ * Packages that follow a release channel instead of plain `latest`. Only
290
+ * ever the market itself: opting into early builds is volunteering to try
291
+ * THIS plugin early, not a licence to pull every other author's
292
+ * unreleased work.
293
+ */
294
+ channelFor: ReadonlyMap<string, Channel> = new Map(),
295
+ /**
296
+ * Curated npm sources for `file:` installs that were matched to the market
297
+ * catalog. `link:` workspaces remain development sources and are never
298
+ * opted into online updates.
299
+ */
300
+ onlineSourceFor: ReadonlyMap<string, string> = new Map(),
301
+ ): Promise<Record<string, UpdateStatus>> {
302
+ const activeProfileDir = profileDir(profile, explicitDir)
303
+ // The channel is part of the key: switching to betas has to change the
304
+ // answer immediately, and a cache keyed on the profile alone would serve
305
+ // the stable verdict for the rest of the TTL — reading as "the setting did
306
+ // nothing".
307
+ const cacheKey = `${activeProfileDir}\u0000${[...channelFor].map(([n, c]) => `${n}:${c}`).sort().join(',')}\u0000${[...onlineSourceFor].map(([n, s]) => `${n}:${s}`).sort().join(',')}`
308
+ if (!force && updatesCache?.key === cacheKey && Date.now() - updatesCache.at < UPDATES_TTL_MS) {
309
+ return updatesCache.data
310
+ }
311
+ const installed = readInstalled(profile, activeProfileDir)
312
+ const lockCommits = readLockCommits(profile, activeProfileDir)
313
+ const result: Record<string, UpdateStatus> = {}
314
+ await Promise.all(Object.entries(installed).map(async ([name, spec]) => {
315
+ const version = readInstalledVersion(profile, name, activeProfileDir)
316
+ const normalizedSpec = spec.toLowerCase()
317
+ if (normalizedSpec.startsWith('file:')) {
318
+ const onlineSource = onlineSourceFor.get(name)
319
+ if (onlineSource !== undefined) {
320
+ const latest = await fetchNpmLatest(onlineSource)
321
+ const updateAvailable = isUpgrade(version, latest)
322
+ result[name] = {
323
+ kind: 'linked', version, current: version, latest, updateAvailable,
324
+ ...(updateAvailable ? { restoreRequired: true } : {}),
325
+ }
326
+ return
327
+ }
328
+ result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false }
329
+ return
330
+ }
331
+ if (normalizedSpec.startsWith('link:')) {
332
+ result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false }
333
+ return
334
+ }
335
+ // The repo behind the spec, in every supported spelling. Older regional
336
+ // installs can carry a proxied codeload URL rather than a `github:`
337
+ // shortcut, and asking only about the shortcut sent those through the npm
338
+ // branch below — where a GitHub-only plugin
339
+ // either 404s or, far worse, matches an unrelated package that happens
340
+ // to share its name.
341
+ const repo = repoOfTarget(spec)?.split('#')[0] ?? null
342
+ try {
343
+ if (repo !== null) {
344
+ // A proxied legacy URL or exact `github:#sha` carries its pin in the
345
+ // spec; a mutable `github:` shortcut keeps it in the lockfile. Prefer
346
+ // the spec because it is authoritative even if the lockfile is stale.
347
+ const current = githubCommitOfTarget(spec) ?? lockCommits.get(repo) ?? null
348
+ // git's own ref advertisement, NOT api.github.com/repos/…/commits.
349
+ // The REST API allows 60 requests an hour per IP unauthenticated,
350
+ // shared across every plugin AND every check — a handful of
351
+ // github-installed plugins exhausts it and the whole list silently
352
+ // stops reporting updates (#349). The ref endpoint git itself uses
353
+ // has no such quota; this is the same call `acceleratedTarget`
354
+ // already makes to resolve a commit for the China region.
355
+ // Ask about the ref the install actually names. Answering with the
356
+ // default branch for a `#branch` install compares two lines that
357
+ // never converge, so the row reported an update forever (#446).
358
+ const latest = await resolveHeadCommit(
359
+ repo, activeRegion(), process.env, githubRefOfTarget(spec) ?? undefined,
360
+ )
361
+ result[name] = {
362
+ kind: 'github', version, current, latest,
363
+ updateAvailable: current !== null && latest !== null && current !== latest,
364
+ }
365
+ } else if (isGitHostedSpec(spec)) {
366
+ // Private / self-hosted git (Gitea, GitLab, raw git+https). Never ask
367
+ // npm by package name — a colliding registry package would be offered
368
+ // as an update and then installed over the git source (#525).
369
+ // Reuse kind `github` as "git-sourced" so the existing Installed UI
370
+ // keeps treating the row as a VCS install without a client change.
371
+ const current = gitCommitOfTarget(spec)
372
+ ?? readGitResolutionCommit(profile, spec, activeProfileDir)
373
+ const latest = await resolveGitRemoteHead(spec)
374
+ result[name] = {
375
+ kind: 'github', version, current, latest,
376
+ updateAvailable: current !== null && latest !== null && current !== latest,
377
+ }
378
+ } else {
379
+ const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`))) as { version?: string }
380
+ const stable = typeof meta.version === 'string' ? meta.version : null
381
+ const channel = channelFor.get(name)
382
+ const latest = channel === undefined ? stable : await versionOnChannel(name, channel, stable)
383
+ // Forwards is an update; a difference in the other direction is a
384
+ // channel switch and is reported as one, under its own field.
385
+ const upgrade = isUpgrade(version, latest)
386
+ const sideways = channel !== undefined && !upgrade
387
+ && version !== null && latest !== null && version !== latest
388
+ result[name] = {
389
+ kind: 'npm', version, current: version, latest,
390
+ updateAvailable: upgrade,
391
+ ...(sideways ? { channelSwitch: latest } : {}),
392
+ }
393
+ }
394
+ } catch {
395
+ result[name] = { kind: (spec.startsWith('github:') || isGitHostedSpec(spec)) ? 'github' : 'npm', version, current: null, latest: null, updateAvailable: false }
396
+ }
397
+ }))
398
+ updatesCache = { key: cacheKey, at: Date.now(), data: result }
399
+ return result
400
+ }