@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
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Catalog matching for locally linked / file: installs. Shared by the host
3
+ * restore route and the Market client so both refuse the same wrong guesses.
4
+ */
5
+
6
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
7
+
8
+ function validSubpath(subpath: string): boolean {
9
+ if (!/^[A-Za-z0-9_./-]+$/.test(subpath)) return false
10
+ return !subpath.split('/').some(seg => seg === '' || seg === '.' || seg === '..')
11
+ }
12
+
13
+ /**
14
+ * Keys a catalog URL contributes to restore matching.
15
+ * A `/tree/` entry is ONLY its exact `#path:` id — never the bare repo —
16
+ * so a collection-root identity cannot select a sibling subpackage.
17
+ */
18
+ function catalogMatchKeys(url: string): { path: string | null; repo: string | null } {
19
+ const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url)
20
+ if (m === null || !REPO_RE.test(m[1]!)) return { path: null, repo: null }
21
+ const subpath = m[2] ?? null
22
+ if (subpath !== null && !validSubpath(subpath)) return { path: null, repo: null }
23
+ const repo = m[1]!.toLowerCase()
24
+ return subpath === null
25
+ ? { path: null, repo }
26
+ : { path: `${repo}#path:/${subpath.toLowerCase()}`, repo: null }
27
+ }
28
+
29
+ function catalogEntryMatchesHints(
30
+ url: string,
31
+ hintSet: ReadonlySet<string>,
32
+ ): boolean {
33
+ const keys = catalogMatchKeys(url)
34
+ return (keys.path !== null && hintSet.has(keys.path))
35
+ || (keys.repo !== null && hintSet.has(keys.repo))
36
+ }
37
+
38
+ /**
39
+ * The catalog entry a locally linked / file: install should restore to.
40
+ * Exact `#path:` identities win, then collection-root identities against
41
+ * root-only catalog rows, then a unique name/npm match when nothing
42
+ * contradicts it. A bare repo identity never selects a root row while
43
+ * `/tree/` siblings exist for that repo — the checkout did not say which
44
+ * package it is, and guessing wrong installs a different plugin.
45
+ * Same-named forks without identities or a matching hint stay unmatched
46
+ * rather than guessing; declared repo evidence that matches nothing in the
47
+ * catalog must not fall back to a coincidental unique name.
48
+ */
49
+ export function findCatalogEntryForLocal<T extends { name: string; npm?: string | null; url: string }>(
50
+ plugins: readonly T[],
51
+ name: string,
52
+ identities: readonly string[] = [],
53
+ hints: readonly string[] = [],
54
+ ): T | null {
55
+ const nameKey = name.toLowerCase()
56
+ const byName = plugins.filter(plugin =>
57
+ plugin.name.toLowerCase() === nameKey
58
+ || (typeof plugin.npm === 'string' && plugin.npm.toLowerCase() === nameKey),
59
+ )
60
+ const identitySet = new Set(identities.map(value => value.toLowerCase()))
61
+ const hintSet = new Set(hints.map(value => value.toLowerCase()))
62
+ const treeRepos = new Set<string>()
63
+ for (const plugin of plugins) {
64
+ const keys = catalogMatchKeys(plugin.url)
65
+ if (keys.path !== null) treeRepos.add(keys.path.slice(0, keys.path.indexOf('#path:/')))
66
+ }
67
+ if (identitySet.size > 0) {
68
+ const pathHit = plugins.find(plugin => {
69
+ const keys = catalogMatchKeys(plugin.url)
70
+ return keys.path !== null && identitySet.has(keys.path)
71
+ })
72
+ if (pathHit !== undefined) return pathHit
73
+ const rootHit = plugins.find(plugin => {
74
+ const keys = catalogMatchKeys(plugin.url)
75
+ if (keys.repo === null || !identitySet.has(keys.repo)) return false
76
+ return !treeRepos.has(keys.repo) || byName.includes(plugin)
77
+ })
78
+ if (rootHit !== undefined) return rootHit
79
+ return null
80
+ }
81
+ if (byName.length === 1) {
82
+ const only = byName[0]!
83
+ if (hintSet.size > 0 && !catalogEntryMatchesHints(only.url, hintSet)) return null
84
+ return only
85
+ }
86
+ if (byName.length > 1 && hintSet.size > 0) {
87
+ const hinted = byName.find(plugin => catalogEntryMatchesHints(plugin.url, hintSet))
88
+ if (hinted !== undefined) return hinted
89
+ }
90
+ return null
91
+ }
92
+
93
+ function catalogEntriesByName<T extends { name: string; npm?: string | null }>(
94
+ plugins: readonly T[],
95
+ name: string,
96
+ ): T[] {
97
+ const nameKey = name.toLowerCase()
98
+ return plugins.filter(plugin =>
99
+ plugin.name.toLowerCase() === nameKey
100
+ || (typeof plugin.npm === 'string' && plugin.npm.toLowerCase() === nameKey),
101
+ )
102
+ }
103
+
104
+ export type CatalogRestoreReason = 'no-catalog' | 'repo-mismatch'
105
+
106
+ /**
107
+ * Why a local restore was blocked, when findCatalogEntryForLocal returned
108
+ * null — and, when it matched, whether anything but the NAME agreed.
109
+ *
110
+ * `verified` is the difference between "put this back where it came from"
111
+ * and "install the catalog's plugin that happens to share this name". A
112
+ * local checkout with no declared repo — no git remote, no `repository`
113
+ * field — gives the market nothing to match on, so a unique same-named
114
+ * catalog entry is a guess. Usually a good one (#429: the local copy IS a
115
+ * tweaked copy of that entry). Not always: @liuwenji007 reported a fork of
116
+ * their own `dsh-humanizer` restored to a different author's plugin of the
117
+ * same name (#485), which is someone else's code arriving under a button
118
+ * labelled "restore".
119
+ *
120
+ * The match is kept, because refusing it would break the ordinary case, and
121
+ * the caller is told not to present it as a certainty.
122
+ */
123
+ export function resolveCatalogRestore<T extends { name: string; npm?: string | null; url: string }>(
124
+ plugins: readonly T[],
125
+ name: string,
126
+ identities: readonly string[] = [],
127
+ hints: readonly string[] = [],
128
+ ): { ok: true; entry: T; verified: boolean } | { ok: false; reason: CatalogRestoreReason } {
129
+ const entry = findCatalogEntryForLocal(plugins, name, identities, hints)
130
+ if (entry !== null) {
131
+ // Evidence is what every other branch of the matcher requires; the
132
+ // name-only branch is reachable only when there is none of it.
133
+ const verified = identities.length > 0 || hints.length > 0
134
+ return { ok: true, entry, verified }
135
+ }
136
+ const byName = catalogEntriesByName(plugins, name)
137
+ if (byName.length === 0) return { ok: false, reason: 'no-catalog' }
138
+ const identitySet = new Set(identities.map(value => value.toLowerCase()))
139
+ const hintSet = new Set(hints.map(value => value.toLowerCase()))
140
+ if (identitySet.size > 0 || hintSet.size > 0) return { ok: false, reason: 'repo-mismatch' }
141
+ // Only ambiguous same-name rows reach here: a unique name with no evidence
142
+ // would have matched inside findCatalogEntryForLocal.
143
+ return { ok: false, reason: 'no-catalog' }
144
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Reading the plugin catalog out of a published npm package.
3
+ *
4
+ * The catalog's own host is the thing being worked around. `plugins.json` is
5
+ * served from GitHub Pages, and the public GitHub proxies that make GitHub
6
+ * usable from mainland China refuse any hostname that is not github.com's
7
+ * own — measured: the project domain comes back 403. So the file cannot be
8
+ * carried through the same route as everything else while it lives there.
9
+ *
10
+ * Published to npm it can be. Every China npm mirror already carries it, it
11
+ * is the same infrastructure the plugins themselves arrive on, and it needs
12
+ * no service that did not already have to work. It also gains a version
13
+ * number, which the origin never had: a catalog build that ships bad data
14
+ * can be rolled back instead of only being fixed forwards.
15
+ *
16
+ * Mirrors rewrite `dist.tarball` to point at themselves, so following that
17
+ * field is what keeps the download on the mirror rather than bouncing back
18
+ * to the origin registry — verified against the Tencent mirror, which serves
19
+ * its own host in that field.
20
+ */
21
+
22
+ import { gunzipSync } from 'node:zlib'
23
+ import { marketFetch } from './net.ts'
24
+
25
+ /** Long enough for a multi-megabyte tarball on a slow link, short enough to fall back. */
26
+ const TIMEOUT_MS = 20_000
27
+
28
+ /**
29
+ * A tar entry header, as far as this needs to read one.
30
+ *
31
+ * Written out rather than pulled from a dependency: the format is 512-byte
32
+ * headers with the name at offset 0 and an octal size at 124, and a reader
33
+ * for one known filename is shorter than the argument for adding a package
34
+ * to a plugin's runtime.
35
+ */
36
+ const NAME_OFFSET = 0
37
+ const NAME_LENGTH = 100
38
+ const SIZE_OFFSET = 124
39
+ const SIZE_LENGTH = 12
40
+ const TYPE_OFFSET = 156
41
+ const BLOCK = 512
42
+
43
+ /**
44
+ * One file's bytes from a gzipped tar, or null when it is not in there.
45
+ *
46
+ * @param gz - the gzipped tarball.
47
+ * @param wanted - the exact entry name, npm-style (`package/plugins.json`).
48
+ */
49
+ export function fileFromTarball(gz: Buffer, wanted: string): Buffer | null {
50
+ const buf = gunzipSync(gz)
51
+ let offset = 0
52
+ while (offset + BLOCK <= buf.length) {
53
+ const name = buf.toString('utf8', offset + NAME_OFFSET, offset + NAME_OFFSET + NAME_LENGTH).replace(/\0.*$/s, '')
54
+ // Two consecutive empty headers end a tar; one is enough to stop here.
55
+ if (name === '') break
56
+ const rawSize = buf.toString('ascii', offset + SIZE_OFFSET, offset + SIZE_OFFSET + SIZE_LENGTH).replace(/\0.*$/s, '').trim()
57
+ const size = Number.parseInt(rawSize, 8)
58
+ if (!Number.isFinite(size) || size < 0) break
59
+ const type = String.fromCharCode(buf[offset + TYPE_OFFSET] ?? 0)
60
+ offset += BLOCK
61
+ // '0' and NUL both mean a regular file; anything else (directories,
62
+ // links, pax headers) is skipped rather than mistaken for content.
63
+ if ((type === '0' || type === '\0') && name === wanted) {
64
+ return buf.subarray(offset, offset + size)
65
+ }
66
+ offset += Math.ceil(size / BLOCK) * BLOCK
67
+ }
68
+ return null
69
+ }
70
+
71
+ /** What a registry says about a package's newest release. */
72
+ interface Packument {
73
+ version?: unknown
74
+ dist?: { tarball?: unknown }
75
+ }
76
+
77
+ /**
78
+ * The catalog carried by a published package, and the version it came from.
79
+ *
80
+ * The version doubles as the cache validator — a better one than an ETag,
81
+ * because it is meaningful to a human reading a log and it is the thing a
82
+ * rollback would change.
83
+ *
84
+ * @param registry - registry base, no trailing slash.
85
+ * @param pkg - package name.
86
+ * @param known - a version already held, to skip re-downloading.
87
+ * @returns the parsed JSON and its version, or `{ version, data: null }` when
88
+ * the published version is the one already held.
89
+ * @throws when the package, the tarball or the file inside it cannot be read.
90
+ */
91
+ export async function catalogFromPackage(
92
+ registry: string,
93
+ pkg: string,
94
+ known?: string,
95
+ /** The entry to extract; the catalog package carries `plugins.json`, companions theirs. */
96
+ file = 'package/plugins.json',
97
+ ): Promise<{ version: string; data: unknown | null }> {
98
+ const metaRes = await marketFetch(`${registry}/${encodeURIComponent(pkg)}/latest`, {
99
+ signal: AbortSignal.timeout(TIMEOUT_MS),
100
+ headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
101
+ })
102
+ if (!metaRes.ok) throw new Error(`HTTP ${String(metaRes.status)} reading ${pkg} metadata`)
103
+ const meta = (await metaRes.json()) as Packument
104
+ const version = typeof meta.version === 'string' ? meta.version : null
105
+ const tarball = typeof meta.dist?.tarball === 'string' ? meta.dist.tarball : null
106
+ if (version === null || tarball === null) throw new Error(`${pkg} metadata names no version or tarball`)
107
+ // Nothing changed, so nothing to download. The whole point of putting the
108
+ // catalog on a mirror is the bytes it saves; re-fetching a package we
109
+ // already hold would give most of them back.
110
+ if (known !== undefined && known === version) return { version, data: null }
111
+
112
+ // Follow `dist.tarball` rather than composing a URL. A mirror rewrites
113
+ // this field to its own host, and composing one would send the download
114
+ // back to the origin registry the region exists to avoid.
115
+ const tarRes = await marketFetch(tarball, { signal: AbortSignal.timeout(TIMEOUT_MS) })
116
+ if (!tarRes.ok) throw new Error(`HTTP ${String(tarRes.status)} reading ${pkg} tarball`)
117
+ const bytes = fileFromTarball(Buffer.from(await tarRes.arrayBuffer()), file)
118
+ if (bytes === null) throw new Error(`${pkg}@${version} carries no ${file}`)
119
+ return { version, data: JSON.parse(bytes.toString('utf8')) as unknown }
120
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Update notes: what changed between an installed version and HEAD.
3
+ *
4
+ * Every byte of GitHub data comes from the catalog side's daily probe,
5
+ * published as the `dsh-plugin-updates` package (origin fallback
6
+ * `updates.json` beside `plugins.json`). The market asks GitHub for nothing:
7
+ * anonymous REST quota is shared per egress IP and already unreliable behind
8
+ * common proxies, and this data is wanted by thousands of markets but
9
+ * produced perfectly well once a day by one probe.
10
+ *
11
+ * What the catalog cannot know is where each user's installed version sits,
12
+ * so slicing the commit tail at that boundary happens here: the same sha the
13
+ * update check resolved (`current`) marks where "new" begins. A tail that
14
+ * does not contain it means the range is wider than the tail — those are
15
+ * labelled recent commits rather than pretending to be an exact interval.
16
+ * Plugins with neither notes nor commits fall back to npm publish times, and
17
+ * a plugin nothing answers for gets a neutral "no notes" — absence of release
18
+ * notes is the ordinary condition of small plugins, not a defect.
19
+ */
20
+
21
+ import { fileFromTarball } from './catalog-npm.ts'
22
+ import { marketFetch } from './net.ts'
23
+ import { activeRegion, routesFor } from './regions.ts'
24
+ import { profileDir, readInstalled, readLockCommits } from './profile.ts'
25
+ import { lookupRepoFromUrl, repoOfTarget } from './sources.ts'
26
+ import { checkUpdates } from './updates.ts'
27
+ import { loadRegistry } from './registry.ts'
28
+ import { compatibilityEnv } from './home-paths.ts'
29
+
30
+ const UPDATES_PACKAGE = 'dsh-plugin-updates'
31
+ const UPDATES_FILE = 'package/updates.json'
32
+ /** The origin copy sits beside plugins.json on the catalog host. */
33
+ const ORIGIN_UPDATES = compatibilityEnv(
34
+ process.env,
35
+ 'CHATCODE_CLI_MARKET_UPDATES_ORIGIN',
36
+ 'DSHM_UPDATES_ORIGIN',
37
+ ) ?? 'https://awesome-dsh-plugin.com/updates.json'
38
+ /** Notes are produced daily; re-checking hourly bounds staleness without churn. */
39
+ const NOTES_TTL_MS = 60 * 60 * 1000
40
+ const TIMEOUT_MS = 15_000
41
+
42
+ export interface ReleaseNotes {
43
+ tag: string | null
44
+ name: string | null
45
+ publishedAt: string | null
46
+ url: string | null
47
+ body: string
48
+ }
49
+
50
+ export interface CommitNote {
51
+ sha: string
52
+ message: string
53
+ date: string | null
54
+ }
55
+
56
+ interface UpdatesEntry {
57
+ release?: ReleaseNotes | null
58
+ commits?: CommitNote[]
59
+ }
60
+
61
+ interface UpdatesPayload {
62
+ count?: number
63
+ updates?: Record<string, UpdatesEntry>
64
+ }
65
+
66
+ let notesCache: { at: number; data: UpdatesPayload } | null = null
67
+
68
+ /**
69
+ * The update-notes payload from whichever route the region serves.
70
+ *
71
+ * Same source order as the catalog itself — the npm package first where one
72
+ * exists (its mirror reach is the reason the package exists), then the
73
+ * origin. Version-keyed like the catalog too: an unchanged payload costs one
74
+ * packument read per TTL, not a tarball.
75
+ */
76
+ export async function loadUpdateNotes(force = false): Promise<UpdatesPayload> {
77
+ if (!force && notesCache !== null && Date.now() - notesCache.at < NOTES_TTL_MS) {
78
+ return notesCache.data
79
+ }
80
+ const routes = routesFor(activeRegion())
81
+ const attempt = async (): Promise<UpdatesPayload> => {
82
+ // The npm package, on the region's registry — mirrors rewrite dist.tarball
83
+ // to themselves, so following that field keeps the download local.
84
+ const metaRes = await marketFetch(`${routes.npmRegistry}/${encodeURIComponent(UPDATES_PACKAGE)}/latest`, {
85
+ signal: AbortSignal.timeout(TIMEOUT_MS),
86
+ headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
87
+ })
88
+ if (!metaRes.ok) throw new Error(`HTTP ${String(metaRes.status)} reading ${UPDATES_PACKAGE} metadata`)
89
+ const meta = await metaRes.json() as { dist?: { tarball?: unknown } }
90
+ const tarball = typeof meta.dist?.tarball === 'string' ? meta.dist.tarball : null
91
+ if (tarball === null) throw new Error(`${UPDATES_PACKAGE} names no tarball`)
92
+ const tarRes = await marketFetch(tarball, { signal: AbortSignal.timeout(TIMEOUT_MS) })
93
+ if (!tarRes.ok) throw new Error(`HTTP ${String(tarRes.status)} reading ${UPDATES_PACKAGE} tarball`)
94
+ const bytes = fileFromTarball(Buffer.from(await tarRes.arrayBuffer()), UPDATES_FILE)
95
+ if (bytes === null) throw new Error(`${UPDATES_PACKAGE} carries no ${UPDATES_FILE}`)
96
+ return JSON.parse(bytes.toString('utf8')) as UpdatesPayload
97
+ }
98
+ const attemptOrigin = async (): Promise<UpdatesPayload> => {
99
+ const res = await marketFetch(ORIGIN_UPDATES, {
100
+ signal: AbortSignal.timeout(TIMEOUT_MS),
101
+ headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
102
+ })
103
+ if (!res.ok) throw new Error(`HTTP ${String(res.status)} reading ${ORIGIN_UPDATES}`)
104
+ return await res.json() as UpdatesPayload
105
+ }
106
+ // Two independent sources, each tried twice — the same retry economics as
107
+ // the catalog loader, for the same transient-loss reasons.
108
+ let lastError: unknown = null
109
+ for (const attemptFn of [attempt, attemptOrigin]) {
110
+ for (let i = 0; i < 2; i++) {
111
+ try {
112
+ const data = await attemptFn()
113
+ notesCache = { at: Date.now(), data }
114
+ return data
115
+ } catch (error) {
116
+ lastError = error
117
+ }
118
+ }
119
+ }
120
+ throw lastError instanceof Error ? lastError : new Error('update notes unavailable')
121
+ }
122
+
123
+ /** Drop the cached payload (nothing currently invalidates it mid-process). */
124
+ export function invalidateUpdateNotes(): void {
125
+ notesCache = null
126
+ }
127
+
128
+ /**
129
+ * Slice the commit tail at the installed sha.
130
+ *
131
+ * @returns the commits newer than `installed`, and whether the boundary was
132
+ * actually found — `found === false` means the range is wider than the tail
133
+ * (or history diverged), so callers must label the result "recent" rather
134
+ * than exact.
135
+ */
136
+ export function sliceCommitsAt(items: CommitNote[], installed: string | null): { items: CommitNote[]; found: boolean } {
137
+ if (installed === null) return { items, found: false }
138
+ const out: CommitNote[] = []
139
+ for (const commit of items) {
140
+ if (commit.sha === installed) return { items: out, found: true }
141
+ out.push(commit)
142
+ }
143
+ return { items: out, found: false }
144
+ }
145
+
146
+ /** The repo url key an entry's update notes live under, or null. */
147
+ export function repoKeyOf(spec: string): string | null {
148
+ const repo = repoOfTarget(spec)?.split('#')[0] ?? null
149
+ return repo === null ? null : `https://github.com/${repo}`
150
+ }
151
+
152
+ /**
153
+ * Look an entry up by its catalog url.
154
+ *
155
+ * The catalog keys entries by the url as listed (author's casing preserved);
156
+ * the market resolves repos to lowercase (as every other consumer of
157
+ * `repoOfTarget` sees them). Match case-insensitively so the two sides never
158
+ * disagree over a capital letter.
159
+ */
160
+ export function entryForRepo(payload: UpdatesPayload, key: string): UpdatesEntry | undefined {
161
+ const direct = payload.updates?.[key]
162
+ if (direct !== undefined) return direct
163
+ const lower = key.toLowerCase()
164
+ for (const [k, v] of Object.entries(payload.updates ?? {})) {
165
+ if (k.toLowerCase() === lower) return v
166
+ }
167
+ return undefined
168
+ }
169
+
170
+ /**
171
+ * Publish times for an npm package's recent versions, newest first.
172
+ *
173
+ * The last resort before "no notes": authors who ship neither releases nor
174
+ * informative commits still usually tag versions, and "0.4.0 landed three
175
+ * days ago" beats nothing. Reads the full packument once and caches it —
176
+ * the doc is heavy, and a user may open the dialog twice.
177
+ */
178
+ const timesCache = new Map<string, { at: number; data: { version: string; date: string }[] }>()
179
+ const TIMES_TTL_MS = 30 * 60 * 1000
180
+
181
+ export async function npmPublishTimes(name: string): Promise<{ version: string; date: string }[]> {
182
+ const hit = timesCache.get(name)
183
+ if (hit !== undefined && Date.now() - hit.at < TIMES_TTL_MS) return hit.data
184
+ const registry = routesFor(activeRegion()).npmRegistry
185
+ const res = await marketFetch(`${registry}/${encodeURIComponent(name)}`, {
186
+ signal: AbortSignal.timeout(TIMEOUT_MS),
187
+ headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
188
+ })
189
+ if (!res.ok) throw new Error(`HTTP ${String(res.status)} reading ${name}`)
190
+ const doc = await res.json() as { time?: Record<string, string> }
191
+ const time = doc.time ?? {}
192
+ const data = Object.entries(time)
193
+ .filter(([version]) => version !== 'created' && version !== 'modified')
194
+ .sort(([, a], [, b]) => Date.parse(b) - Date.parse(a))
195
+ .slice(0, 5)
196
+ .map(([version, date]) => ({ version, date }))
197
+ timesCache.set(name, { at: Date.now(), data })
198
+ return data
199
+ }
200
+
201
+ /** Everything the dialog renders for one plugin, already resolved. */
202
+ export interface UpdateNotes {
203
+ kind: 'release' | 'commits' | 'npm' | 'none'
204
+ /** Present for `release`: the author's own notes for the latest release. */
205
+ release?: ReleaseNotes
206
+ /** Present for `commits`: the tail, sliced when the boundary was found. */
207
+ commits?: { items: CommitNote[]; found: boolean }
208
+ /** Present for `npm`: recent versions with their publish dates. */
209
+ npmTimes?: { version: string; date: string }[]
210
+ }
211
+
212
+ /**
213
+ * Resolve the notes for one installed plugin, or `{ kind: 'none' }`.
214
+ *
215
+ * Never throws: every failure along the way degrades to the next tier, the
216
+ * same contract the issue promised — a dialog that cannot load its data shows
217
+ * a neutral statement, not an error.
218
+ */
219
+ export async function updateNotesFor(
220
+ profile: string,
221
+ explicitDir: string | undefined,
222
+ name: string,
223
+ ): Promise<UpdateNotes> {
224
+ const activeProfileDir = profileDir(profile, explicitDir)
225
+ const spec = readInstalled(profile, activeProfileDir)[name]
226
+ if (spec === undefined || spec.startsWith('link:') || spec.startsWith('file:')) {
227
+ return { kind: 'none' }
228
+ }
229
+ try {
230
+ const payload = await loadUpdateNotes()
231
+ let key = repoKeyOf(spec)
232
+ // If the spec is a Release asset URL (catalog format), try to extract
233
+ // the repo from the URL directly. This handles npm-installed plugins
234
+ // whose profile spec is the npm name but the catalog entry uses a
235
+ // Release asset tarball URL.
236
+ if (key === null) {
237
+ key = lookupRepoFromUrl(spec)
238
+ }
239
+ // If the spec is an npm package name (not a GitHub URL), look up the
240
+ // catalog to find the corresponding GitHub repo URL.
241
+ if (key === null) {
242
+ try {
243
+ const registry = await loadRegistry()
244
+ const plugin = registry.plugins.find(p => p.name === name)
245
+ if (plugin !== undefined) {
246
+ key = plugin.url
247
+ }
248
+ } catch {
249
+ // Catalog unavailable; fall through to npm times.
250
+ }
251
+ }
252
+ const entry = key === null ? undefined : entryForRepo(payload, key)
253
+ // Both tiers below need the installed sha for github-kind installs.
254
+ // For Release asset URLs and catalog lookups, checkUpdates doesn't
255
+ // recognize the spec, so we read the lockfile directly using the repo key.
256
+ let current: string | null = null
257
+ if (key !== null && key.startsWith('https://github.com/')) {
258
+ const repo = key.slice('https://github.com/'.length).split('#')[0]!.toLowerCase()
259
+ current = readLockCommits(profile, activeProfileDir).get(repo) ?? null
260
+ } else {
261
+ const statuses = await checkUpdates(profile, false, explicitDir).catch(() => null)
262
+ current = statuses?.[name]?.current ?? null
263
+ }
264
+
265
+ if (entry?.release !== undefined && entry.release !== null && (entry.release.body !== '' || entry.release.tag !== null)) {
266
+ return { kind: 'release', release: entry.release }
267
+ }
268
+ if (entry?.commits !== undefined && entry.commits.length > 0) {
269
+ return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) }
270
+ }
271
+ if (key === null) {
272
+ // Not a github-sourced plugin at all: npm times are the only tier left.
273
+ return { kind: 'npm', npmTimes: await npmPublishTimes(name) }
274
+ }
275
+ // A github plugin whose repo answered nothing — releases 404 AND the log
276
+ // came back empty. Rare enough to just say so rather than ask npm about a
277
+ // name that may not even exist there.
278
+ return { kind: 'none' }
279
+ } catch {
280
+ return { kind: 'none' }
281
+ }
282
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The market's release channels: which build of ITSELF it offers.
3
+ *
4
+ * Only the market follows this. Other plugins are never pulled from a
5
+ * prerelease on the strength of a setting the user made about the market —
6
+ * opting into early builds is volunteering to try THIS plugin early, not to
7
+ * be handed every other author's unreleased work.
8
+ *
9
+ * The model lives in its own module because it is the part with rules
10
+ * rather than plumbing: three channels, one of them hidden, a mapping to
11
+ * npm dist-tags, and a resolution order that has already been got wrong
12
+ * once (see `resolveChannel`).
13
+ */
14
+
15
+ /** A channel the market can follow. */
16
+ export type Channel = 'stable' | 'beta' | 'dev'
17
+
18
+ /**
19
+ * The npm dist-tag each channel installs from.
20
+ *
21
+ * `dev` is published straight from a branch with no git tag behind it, so a
22
+ * version carries a timestamp and a short SHA (`1.15.0-dev.20260818-3f1432e`)
23
+ * and is never reused. That is what makes a dev build disposable: nothing in
24
+ * the repository's history refers to it.
25
+ */
26
+ export const DIST_TAG: Record<Channel, string> = {
27
+ stable: 'latest',
28
+ beta: 'beta',
29
+ dev: 'dev',
30
+ }
31
+
32
+ /**
33
+ * Every channel a user may pick. All three, always.
34
+ *
35
+ * `dev` was behind a developer-mode switch for one version, on the reasoning
36
+ * that a build published straight off a branch should not sit beside
37
+ * "stable" looking like a third degree of caution. The switch cost more than
38
+ * it bought: a stored mode, a route to change it, a rule for what happens to
39
+ * a dev choice when it is turned off, and a control whose own purpose needed
40
+ * explaining. A plainly labelled option a user can read is simpler than a
41
+ * hidden one plus the machinery that hides it — the label does the work the
42
+ * gate was doing.
43
+ */
44
+ export const CHANNELS: readonly Channel[] = ['stable', 'beta', 'dev']
45
+
46
+ /** Narrow an untrusted value to a Channel, or null. */
47
+ export function asChannel(value: unknown): Channel | null {
48
+ return value === 'stable' || value === 'beta' || value === 'dev' ? value : null
49
+ }
50
+
51
+ /**
52
+ * Which channel applies right now.
53
+ *
54
+ * A choice on record always wins — including "stable" while a prerelease is
55
+ * running, which is the only way back off a channel. Only the ABSENCE of a
56
+ * choice is derived, and then from what is actually running: installing
57
+ * `dshmarket@beta` by hand IS the subscription, and treating that as
58
+ * "stable" costs updates rather than just clarity — on the stable channel
59
+ * `latest` (1.13.1) is not newer than an installed 1.14.0-beta.1, so the
60
+ * market answers "up to date" and the next beta is never offered.
61
+ *
62
+ * Which makes `undefined` load-bearing: it has to survive both the settings
63
+ * schema (no `.default`) and state.json (field omitted) or "never chose"
64
+ * silently becomes "chose stable".
65
+ *
66
+ */
67
+ export function resolveChannel(setting: Channel | undefined, version: string): Channel {
68
+ if (setting !== undefined) return setting
69
+ return version.includes('-') ? 'beta' : 'stable'
70
+ }