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