@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,97 @@
1
+ /**
2
+ * Deciding a download region by asking the network, once.
3
+ *
4
+ * The alternative was reading the system time zone, and it is wrong for
5
+ * exactly the people who most need this to work: someone in Shanghai behind
6
+ * a corporate proxy or a VPN reaches the official registry perfectly well
7
+ * and would be routed onto mirrors they do not need, while a Chinese-locale
8
+ * machine sitting in Singapore would be routed onto mirrors that are further
9
+ * away than the origin. A time zone says where a clock is. It does not say
10
+ * what the network can reach, which is the only question here.
11
+ *
12
+ * So the probe measures the thing itself: it asks both registries for the
13
+ * same small document and takes whichever answers first. That is
14
+ * self-correcting in a way a lookup table cannot be — the machine behind the
15
+ * proxy measures fast official access and stays global, without anyone
16
+ * having to enumerate the exceptions.
17
+ *
18
+ * It runs ONCE, when no region has ever been decided, and its answer is
19
+ * persisted as the decision. Re-probing every boot would let a market
20
+ * silently change routes between runs, which is the kind of behaviour that
21
+ * makes "it was fast yesterday" impossible to debug.
22
+ */
23
+
24
+ import { logEvent } from './log.ts'
25
+ import { marketFetch } from './net.ts'
26
+ import { REGIONS, routesFor, type Region } from './regions.ts'
27
+
28
+ /**
29
+ * What to ask each registry for.
30
+ *
31
+ * The market's own package: present on every npm mirror worth using, and
32
+ * small — the `latest` document is a few KB, against ~320KB for the full
33
+ * packument. A probe that downloads a third of a megabyte to answer "which
34
+ * of these is closer" has spent more than the answer is worth.
35
+ */
36
+ const PROBE_PATH = 'dshmarket/latest'
37
+
38
+ /**
39
+ * Ask every region's registry the same question; return whichever answers
40
+ * first.
41
+ *
42
+ * First-past-the-post rather than a latency comparison, deliberately. From
43
+ * inside China the official registry usually does answer eventually, so
44
+ * "did it fail" is the wrong test and "which came back first" is the right
45
+ * one. From outside, the mainland mirror is reachable but further, and the
46
+ * same rule picks global. One rule, both directions, no thresholds to tune.
47
+ *
48
+ * @param timeoutMs - how long to wait before giving up on all of them.
49
+ * @param env - environment, for the registry overrides.
50
+ * @returns the winning region, or `global` when nothing answered — an
51
+ * unreachable network is not evidence for switching routes.
52
+ */
53
+ export async function probeRegion(timeoutMs = 2500, env: NodeJS.ProcessEnv = process.env): Promise<Region> {
54
+ const controller = new AbortController()
55
+ const timer = setTimeout(() => { controller.abort() }, timeoutMs)
56
+ const ask = async (region: Region): Promise<Region> => {
57
+ const base = routesFor(region, env).npmRegistry
58
+ const res = await marketFetch(`${base}/${PROBE_PATH}`, {
59
+ signal: controller.signal,
60
+ headers: { accept: 'application/json', 'user-agent': 'ChatCode-CLI-Market' },
61
+ })
62
+ if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
63
+ // Read the body before declaring a winner. Headers can arrive from a
64
+ // captive portal or a proxy that then stalls, and a region chosen on a
65
+ // response that never finished would be chosen on nothing.
66
+ await res.arrayBuffer()
67
+ return region
68
+ }
69
+ try {
70
+ return await Promise.any(REGIONS.map(ask))
71
+ } catch {
72
+ return 'global'
73
+ } finally {
74
+ clearTimeout(timer)
75
+ // Stop the losers. Their answer can no longer change anything, and a
76
+ // request left running past the decision is a request nobody will read.
77
+ controller.abort()
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The region to run under, probing only if nothing has ever decided one.
83
+ *
84
+ * @param stored - the region already on record, or undefined.
85
+ * @returns the region, and whether this call decided it (which is what earns
86
+ * the user a one-time notice explaining the choice).
87
+ */
88
+ export async function resolveRegion(
89
+ stored: Region | undefined,
90
+ timeoutMs?: number,
91
+ env?: NodeJS.ProcessEnv,
92
+ ): Promise<{ region: Region; probed: boolean }> {
93
+ if (stored !== undefined) return { region: stored, probed: false }
94
+ const region = await probeRegion(timeoutMs, env)
95
+ logEvent('info', 'region', `no region on record; network check chose ${region}`)
96
+ return { region, probed: true }
97
+ }
package/src/regions.ts ADDED
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Download regions: which route the market's own network requests take.
3
+ *
4
+ * Almost every external request the market makes lands on npm's registry or
5
+ * on GitHub — the plugin catalog, update checks, package downloads, author
6
+ * avatars, README screenshots. From mainland China several of those can be
7
+ * slow, which is why this is ONE setting rather than a
8
+ * row of them: "npm mirror", "GitHub proxy" and "image proxy" are three
9
+ * spellings of a single question the user is actually being asked, which is
10
+ * where they are.
11
+ *
12
+ * The routing table is the single source of truth. Every consumer asks it
13
+ * rather than reaching for a hardcoded host, so adding a region is a table
14
+ * entry instead of a search across six modules.
15
+ *
16
+ * Each route has a ChatCode-first environment escape hatch, with the former
17
+ * `DSHM_*` spelling retained only as a compatibility input.
18
+ * (src/registry.ts). The China route has service-specific public-proxy
19
+ * fallbacks; those come and go, and a user whose routes have died needs a way
20
+ * out that is not "wait for the next release".
21
+ */
22
+
23
+ /** A region the market can download from. */
24
+ export type Region = 'global' | 'china'
25
+
26
+ /** Every region a user may pick. */
27
+ export const REGIONS: readonly Region[] = ['global', 'china']
28
+
29
+ /** Narrow an untrusted value to a Region, or null. */
30
+ export function asRegion(value: unknown): Region | null {
31
+ return value === 'global' || value === 'china' ? value : null
32
+ }
33
+
34
+ /**
35
+ * The npm registry the market and pnpm read, no trailing slash.
36
+ *
37
+ * Exported because callers need to tell "this region uses the default" from
38
+ * "this region names a mirror" — the difference between leaving a spawned
39
+ * pnpm's registry alone and setting it.
40
+ */
41
+ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
42
+ const NPM_CHINA = 'https://mirrors.cloud.tencent.com/npm'
43
+
44
+ /**
45
+ * First public prefix used for unauthenticated GitHub reads, no trailing slash.
46
+ * Authenticated API calls and canonical codeload tarballs never use it.
47
+ *
48
+ * What this proxy accepts is a list of GitHub SERVICES, not a hostname test.
49
+ * The previous note here said it "refuses anything that is not a github.com
50
+ * hostname", which #460 by @Homplex measured as wrong in both directions —
51
+ * re-measured 2026-09-01:
52
+ *
53
+ * https://raw.githubusercontent.com/… 200 ← not a github.com hostname
54
+ * https://github.com/owner/repo 403 ← is one
55
+ * https://example.com/ 403
56
+ *
57
+ * So a plain repository page is refused while raw content is served. Do not
58
+ * reason about this proxy from the hostname; check the specific service, and
59
+ * re-measure rather than infer, because the policy is the operator's and can
60
+ * change under us. That fragility is the substance of #460's actual request
61
+ * (a mirror list and a visible setting), which is tracked separately.
62
+ */
63
+ const GITHUB_PROXY_CHINA = 'https://gh-proxy.com'
64
+ const GITHUB_PROXY_CHINA_FALLBACK = 'https://ghfast.top'
65
+
66
+ /** GitHub transports that fail independently on filtered networks. */
67
+ export type GithubService = 'git' | 'raw' | 'avatar'
68
+
69
+ /** A prefix proxy, or null for the canonical GitHub address. */
70
+ export type GithubRoute = string | null
71
+
72
+ /** Ordered candidates per GitHub service. */
73
+ export type GithubRoutes = Record<GithubService, GithubRoute[]>
74
+
75
+ /**
76
+ * The catalog's stable public address.
77
+ *
78
+ * A custom domain rather than the repository path, deliberately: it survives
79
+ * the repo being renamed or moved, and Pages puts a CDN in front of it.
80
+ */
81
+ const CATALOG_OFFICIAL = 'https://awesome-dsh-plugin.com/plugins.json'
82
+
83
+ /**
84
+ * One place the catalog can be read from.
85
+ *
86
+ * Two kinds because the two routes are genuinely different transports, not
87
+ * two URLs. The npm route reads a published package — which is what lets the
88
+ * catalog ride the same mirror as everything else, and gives it a version
89
+ * number that can be rolled back when a bad build ships.
90
+ */
91
+ export type CatalogSource =
92
+ | { kind: 'url'; url: string }
93
+ | { kind: 'npm'; registry: string; pkg: string }
94
+
95
+ /** Where one region sends each kind of request. `null` means "go direct". */
96
+ export interface RegionRoutes {
97
+ /** npm registry base, no trailing slash. */
98
+ npmRegistry: string
99
+ /** Prefix proxy for github.com-family URLs, or null to go direct. */
100
+ githubProxy: string | null
101
+ /** Ordered routes per service; every list ends in a direct escape path. */
102
+ githubRoutes: GithubRoutes
103
+ /**
104
+ * Where to look for the catalog, in order. Later entries are fallbacks.
105
+ *
106
+ * The catalog is the FIRST request the market makes, so a mirror that has
107
+ * gone down must mean a slow market rather than an empty one — every
108
+ * region ends its list at an address that has always worked.
109
+ */
110
+ catalog: CatalogSource[]
111
+ }
112
+
113
+ /**
114
+ * The npm package carrying `plugins.json`.
115
+ *
116
+ * A package rather than a file URL, because the catalog's own host is the
117
+ * problem being solved: it is served from GitHub Pages, and the public
118
+ * GitHub proxies refuse hostnames that are not github.com's own. Published
119
+ * to npm, it reaches mainland China through the same mirror as every plugin
120
+ * — no extra service to depend on, and nothing new that can go down.
121
+ *
122
+ * Its own package rather than a file added to `awesome-dsh-plugin`: npm
123
+ * force-includes README files whatever the `files` field says, and that
124
+ * package's two generated READMEs come to ~1MB. Attaching the catalog to it
125
+ * would have spent on the wire exactly what this exists to save (measured:
126
+ * 772KB attached, 413KB standing alone — the latter matching the gzipped
127
+ * origin almost exactly).
128
+ */
129
+ const CATALOG_PACKAGE = 'dsh-plugin-catalog'
130
+
131
+ const ROUTES: Record<Region, RegionRoutes> = {
132
+ global: {
133
+ npmRegistry: DEFAULT_NPM_REGISTRY,
134
+ githubProxy: null,
135
+ githubRoutes: { git: [null], raw: [null], avatar: [null] },
136
+ catalog: [{ kind: 'url', url: CATALOG_OFFICIAL }],
137
+ },
138
+ china: {
139
+ npmRegistry: NPM_CHINA,
140
+ githubProxy: GITHUB_PROXY_CHINA,
141
+ // Filtering is service-shaped, not GitHub-shaped. Raw content commonly
142
+ // needs help while git advertisements and avatars remain reachable, so
143
+ // putting one global order on all three only trades one outage for
144
+ // needless proxy traffic on the paths that still work.
145
+ githubRoutes: {
146
+ raw: [GITHUB_PROXY_CHINA, GITHUB_PROXY_CHINA_FALLBACK, null],
147
+ git: [null, GITHUB_PROXY_CHINA, GITHUB_PROXY_CHINA_FALLBACK],
148
+ avatar: [null, GITHUB_PROXY_CHINA, GITHUB_PROXY_CHINA_FALLBACK],
149
+ },
150
+ // The package, then the origin. There is deliberately no
151
+ // raw.githubusercontent step between them: `plugins.json` is a build
152
+ // artifact that the site publishes to Pages and never commits, so that
153
+ // path is a guaranteed 404 and would only spend two attempts proving it.
154
+ catalog: [
155
+ { kind: 'npm', registry: NPM_CHINA, pkg: CATALOG_PACKAGE },
156
+ { kind: 'url', url: CATALOG_OFFICIAL },
157
+ ],
158
+ },
159
+ }
160
+
161
+ /** Read a ChatCode-first environment override, treating blanks as unset. */
162
+ function override(env: NodeJS.ProcessEnv, currentName: string, legacyName: string): string | null {
163
+ const current = env[currentName]
164
+ if (current !== undefined && current.trim() !== '') return current.trim().replace(/\/+$/, '')
165
+ const legacy = env[legacyName]
166
+ return legacy !== undefined && legacy.trim() !== '' ? legacy.trim().replace(/\/+$/, '') : null
167
+ }
168
+
169
+ /**
170
+ * Normalize a user-maintained prefix, or reject it.
171
+ *
172
+ * Public mirrors receive the complete destination URL in their path. Only
173
+ * HTTPS prefixes without embedded credentials or query fragments are safe to
174
+ * persist and show again in the settings UI.
175
+ */
176
+ export function normalizeGithubProxy(value: unknown): string | null {
177
+ if (typeof value !== 'string') return null
178
+ const raw = value.trim()
179
+ if (raw === '' || raw.includes('\\')) return null
180
+ try {
181
+ const parsed = new URL(raw)
182
+ if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return null
183
+ if (parsed.search !== '' || parsed.hash !== '') return null
184
+ const path = parsed.pathname.replace(/\/+$/u, '')
185
+ return `${parsed.origin}${path}`
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+
191
+ let customGithubProxy: string | null = null
192
+ const preferredGithubRoutes = new Map<GithubService, GithubRoute>()
193
+
194
+ /** Apply (or clear) the persisted UI escape route. */
195
+ export function setCustomGithubProxy(proxy: string | null): void {
196
+ const next = proxy === null ? null : normalizeGithubProxy(proxy)
197
+ if (next === customGithubProxy) return
198
+ customGithubProxy = next
199
+ resetGithubRoutePreferences()
200
+ }
201
+
202
+ /** Whether the operator-owned environment variable disables UI changes. */
203
+ export function githubProxyManaged(env: NodeJS.ProcessEnv = process.env): boolean {
204
+ return override(env, 'CHATCODE_CLI_MARKET_GITHUB_PROXY', 'DSHM_GITHUB_PROXY') !== null
205
+ }
206
+
207
+ /** Remember one verified route without changing other GitHub services. */
208
+ export function rememberGithubRoute(service: GithubService, route: GithubRoute): void {
209
+ preferredGithubRoutes.set(service, route)
210
+ }
211
+
212
+ /** Forget learned winners after the configured candidates change. */
213
+ export function resetGithubRoutePreferences(): void {
214
+ preferredGithubRoutes.clear()
215
+ }
216
+
217
+ /**
218
+ * The routes for a region, with environment overrides applied.
219
+ *
220
+ * Overrides win over the table because they are the user's statement about
221
+ * their own network, and they are the way out when a public proxy dies.
222
+ *
223
+ * `CHATCODE_CLI_MARKET_REGISTRY_URL` names the catalog URL. The former
224
+ * `DSHM_REGISTRY_URL` spelling keeps the same meaning as a compatibility
225
+ * input. When set, either form
226
+ * when set it REPLACES the source list rather than heading it: someone
227
+ * pointing the market at their own catalog does not want it quietly
228
+ * reverting to ours.
229
+ */
230
+ export function routesFor(region: Region, env: NodeJS.ProcessEnv = process.env): RegionRoutes {
231
+ const base = ROUTES[region]
232
+ const npmMirror = override(env, 'CHATCODE_CLI_MARKET_NPM_MIRROR', 'DSHM_NPM_MIRROR')
233
+ const githubProxy = override(env, 'CHATCODE_CLI_MARKET_GITHUB_PROXY', 'DSHM_GITHUB_PROXY') ?? customGithubProxy
234
+ const catalog = override(env, 'CHATCODE_CLI_MARKET_REGISTRY_URL', 'DSHM_REGISTRY_URL')
235
+ const registry = npmMirror ?? base.npmRegistry
236
+ const githubRoutes: GithubRoutes = githubProxy === null
237
+ ? {
238
+ git: [...base.githubRoutes.git],
239
+ raw: [...base.githubRoutes.raw],
240
+ avatar: [...base.githubRoutes.avatar],
241
+ }
242
+ : { git: [githubProxy, null], raw: [githubProxy, null], avatar: [githubProxy, null] }
243
+ return {
244
+ npmRegistry: registry,
245
+ githubProxy: githubProxy ?? base.githubProxy,
246
+ githubRoutes,
247
+ // A named catalog REPLACES the list rather than joining it. Someone
248
+ // pointing the market at their own catalog does not want it quietly
249
+ // reverting to ours when theirs is briefly unreachable — that is how a
250
+ // fixture-backed test ends up asserting against the live registry.
251
+ catalog: catalog !== null
252
+ ? [{ kind: 'url', url: catalog }]
253
+ // Rebuilt against the resolved registry, so an npm override moves the
254
+ // catalog to the same mirror it moved everything else to.
255
+ : base.catalog.map(source => (source.kind === 'npm' ? { ...source, registry } : source)),
256
+ }
257
+ }
258
+
259
+ /** Ordered candidates with this process's last verified winner first. */
260
+ export function githubRoutesFor(
261
+ service: GithubService,
262
+ region: Region = activeRegion(),
263
+ env: NodeJS.ProcessEnv = process.env,
264
+ ): GithubRoute[] {
265
+ const routes = routesFor(region, env).githubRoutes[service]
266
+ if (!preferredGithubRoutes.has(service)) return routes
267
+ const preferred = preferredGithubRoutes.get(service)!
268
+ const index = routes.findIndex(route => route === preferred)
269
+ if (index <= 0) return routes
270
+ return [routes[index]!, ...routes.slice(0, index), ...routes.slice(index + 1)]
271
+ }
272
+
273
+ /**
274
+ * The region this process is running under.
275
+ *
276
+ * One piece of module state rather than a parameter threaded through the
277
+ * catalog, the theme manager, update checks and every pnpm spawn: the region
278
+ * is a property of the running market, not of any single question asked of
279
+ * it, and the call graphs that need it are several frames deep.
280
+ *
281
+ * Consumers that must react to a CHANGE (dropping a cache gathered from the
282
+ * other registry) keep their own setter beside this one; this holds the
283
+ * answer for everyone who only needs to read it.
284
+ */
285
+ let active: Region = 'global'
286
+
287
+ /** The region in force. */
288
+ export function activeRegion(): Region {
289
+ return active
290
+ }
291
+
292
+ /** Set the region in force. Callers are responsible for their own caches. */
293
+ export function setActiveRegion(region: Region): void {
294
+ active = region
295
+ }
296
+
297
+ /**
298
+ * Wrap a github.com-family URL in a prefix proxy.
299
+ *
300
+ * The proxy takes the full absolute URL as its path (`{proxy}/{url}`) rather
301
+ * than a rewritten hostname, so the same joining rule works for every
302
+ * GitHub service a caller has explicitly allowed through public routes.
303
+ *
304
+ * @param proxy - the prefix, or null to go direct.
305
+ * @param url - an absolute https URL on a github.com-family host.
306
+ * @returns the proxied URL, or `url` unchanged when there is no proxy.
307
+ */
308
+ export function throughProxy(proxy: string | null, url: string): string {
309
+ return proxy === null ? url : `${proxy}/${url}`
310
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Registry access: the curated list from awesome-dsh-plugin.com, fetched
3
+ * fresh on every request. See `loadRegistry` for why there is nothing
4
+ * behind it any more.
5
+ */
6
+
7
+ import { configuredProxy, marketFetch } from './net.ts'
8
+ import { catalogFromPackage } from './catalog-npm.ts'
9
+ import { activeRegion, routesFor, type CatalogSource, type Region } from './regions.ts'
10
+
11
+ export interface RegistryPlugin {
12
+ name: string
13
+ owner: string
14
+ url: string
15
+ /** One legacy category id or several category ids. */
16
+ category: string | string[]
17
+ description: Record<string, string>
18
+ npm?: string | null
19
+ tarball?: string | null
20
+ stars?: number | null
21
+ /**
22
+ * npm downloads in the last 30 days, when the entry has a published
23
+ * package. `null`/absent means "no npm package" — a coverage gap, not a
24
+ * zero — so sorting must not read it as "less popular than 0".
25
+ */
26
+ downloads?: number | null
27
+ install: string
28
+ added: string
29
+ /**
30
+ * Catalog-side deprecation flags (#60): supplied by awesome-dsh-plugin,
31
+ * absent for every normal entry — the market only consumes them, so a
32
+ * catalog without the fields behaves exactly as before.
33
+ */
34
+ deprecated?: boolean
35
+ /** Catalog name of the suggested replacement plugin, when deprecated. */
36
+ replacement?: string
37
+ }
38
+
39
+ /**
40
+ * Category ids for one catalog entry, de-duplicated in declaration order.
41
+ *
42
+ * Catalog JSON is an external input, so malformed array members are omitted
43
+ * here and an entry with no usable category is rejected by `asRegistry`.
44
+ */
45
+ export function pluginCategories(plugin: Pick<RegistryPlugin, 'category'>): string[] {
46
+ const values: unknown[] = Array.isArray(plugin.category) ? plugin.category : [plugin.category]
47
+ const categories: string[] = []
48
+ const seen = new Set<string>()
49
+ for (const value of values) {
50
+ if (typeof value !== 'string' || value === '' || seen.has(value)) continue
51
+ seen.add(value)
52
+ categories.push(value)
53
+ }
54
+ return categories
55
+ }
56
+
57
+ export interface Registry {
58
+ updated: string
59
+ count: number
60
+ categories: Record<string, Record<string, string>>
61
+ plugins: RegistryPlugin[]
62
+ }
63
+
64
+ /**
65
+ * Where the curated list comes from now lives in the region routing table
66
+ * (src/regions.ts), because it is one of several addresses that move
67
+ * together when a user changes download region.
68
+ *
69
+ * `DSHM_REGISTRY_URL` keeps its meaning there, unchanged: overridable
70
+ * through the process environment ONLY — the layer-3 e2e points it at a
71
+ * local fixture catalog so the install route can be driven end to end
72
+ * without publishing anything.
73
+ *
74
+ * This does not weaken the install route's registry check. That check exists
75
+ * to stop a malicious PAGE from POSTing an arbitrary source at the local
76
+ * server; a page cannot set environment variables, and anyone who can set
77
+ * this process's environment already controls the process. What the override
78
+ * changes is WHICH list is curated, never WHETHER the check runs.
79
+ */
80
+
81
+ /**
82
+ * How long to wait for the catalog.
83
+ *
84
+ * Generous on purpose. It used to be 4s with a bundled snapshot behind it,
85
+ * so a slow link quietly became a 39%-smaller catalog. Now that a failure is
86
+ * reported rather than papered over, cutting off a link that WOULD have
87
+ * answered is the expensive mistake — 282KB over TLS from a far-away network
88
+ * is not a 4-second job.
89
+ */
90
+ const FETCH_TIMEOUT_MS = 15_000
91
+
92
+ /**
93
+ * The catalog we were last served, with the validator identifying it.
94
+ *
95
+ * This is NOT the cache that was removed, and the difference is the whole
96
+ * point. That cache SKIPPED the request for an hour and answered from
97
+ * memory — it asserted freshness without ever asking. This asks the origin
98
+ * every single time; the validator only lets the origin answer "still the
99
+ * same" (304) instead of resending a megabyte. Freshness is verified on
100
+ * every call either way, so `data` below is only ever returned when the
101
+ * server has just confirmed it is current.
102
+ *
103
+ * In memory rather than on disk: a restart is rare enough that paying one
104
+ * full download for it costs nothing, and a file would be one more thing
105
+ * that can be found on a machine and mistaken for the catalog itself.
106
+ *
107
+ * Measured against the live origin (GitHub Pages behind Fastly, which
108
+ * serves both `etag` and `last-modified`): 295 KB and 1.3s unconditional,
109
+ * 0 bytes and 0.5s for a 304. The reporter whose fetch took 9.9s was
110
+ * downloading the full 1.07 MB every time they opened the market.
111
+ */
112
+ let served: {
113
+ /** Which source issued this, so a validator is never sent to another one. */
114
+ key: string
115
+ etag: string | null
116
+ modified: string | null
117
+ /** The published version, for the npm route — its equivalent of an ETag. */
118
+ version: string | null
119
+ data: Registry
120
+ } | null = null
121
+
122
+ /** Identity of a catalog source, for scoping the validator to its origin. */
123
+ function sourceKey(source: CatalogSource): string {
124
+ return source.kind === 'npm' ? `npm:${source.registry}/${source.pkg}` : `url:${source.url}`
125
+ }
126
+
127
+ /** A parsed catalog, or a thrown explanation of why it is not one. */
128
+ function asRegistry(value: unknown): Registry {
129
+ const data = value as Registry
130
+ if (!Array.isArray(data.plugins) || data.plugins.length === 0) throw new Error('the catalog came back empty')
131
+ const plugins = data.plugins.map((plugin, index) => {
132
+ const category = pluginCategories(plugin)
133
+ if (category.length === 0) throw new Error(`catalog plugin ${String(index)} carries no usable category`)
134
+ return { ...plugin, category }
135
+ })
136
+ return { ...data, plugins }
137
+ }
138
+
139
+ /**
140
+ * Drop what we remember, so the next call is unconditional.
141
+ *
142
+ * Exists for tests: the memo is module state, and a spec that asserted a
143
+ * 304 would otherwise leak a validator into the next one.
144
+ */
145
+ export function forgetCatalog(): void {
146
+ served = null
147
+ }
148
+
149
+ /**
150
+ * The catalog, revalidated every time it is asked for.
151
+ *
152
+ * There used to be three answers here — live, a one-hour in-memory cache,
153
+ * and a snapshot bundled into the npm package — and only the first was
154
+ * correct. The other two were indistinguishable from it on screen, so a
155
+ * machine that could not reach the registry browsed the publish-time file
156
+ * (839 entries against 1367 live, and frozen forever for anyone on an older
157
+ * release), while a machine that COULD reach it still saw an hour-old
158
+ * listing of a catalog that grows by ~250 entries a day.
159
+ *
160
+ * For a catalog, stale is not a degraded answer, it is a wrong one: a plugin
161
+ * published this morning reads as "does not exist". So there is one source
162
+ * now, and a failure is a failure — the caller reports it and offers a
163
+ * retry, which is a state the user can act on. In particular a network
164
+ * failure is NEVER answered from `served`: an origin that cannot be reached
165
+ * has not confirmed anything, and quietly handing back the last catalog
166
+ * would rebuild exactly the fallback this replaced.
167
+ * @throws when the catalog cannot be fetched or does not look like one.
168
+ */
169
+ export async function loadRegistry(region: Region = activeRegion()): Promise<Registry> {
170
+ const started = Date.now()
171
+ let last: unknown
172
+ let attempts = 0
173
+ // Sources in order, each a fallback for the one before it. The catalog is
174
+ // the FIRST request the market makes, so a mirror that has gone down must
175
+ // mean a slow market rather than an empty one — the list ends at the
176
+ // address that has always worked.
177
+ for (const source of routesFor(region).catalog) {
178
+ const key = sourceKey(source)
179
+ // Two attempts each. A catalog fetch crossing a long, lossy path fails
180
+ // transiently often enough that one retry is worth more than the second
181
+ // or two it costs — and with nothing behind this call any more, a
182
+ // transient failure is a market with no plugins in it.
183
+ for (let attempt = 0; attempt < 2; attempt++) {
184
+ attempts += 1
185
+ try {
186
+ // A validator only ever goes back to the source that issued it.
187
+ // Carried across a region switch it could earn a "not modified" from
188
+ // an origin whose body we have never seen.
189
+ const reusable = served?.key === key ? served : null
190
+ if (source.kind === 'npm') {
191
+ const { version, data } = await catalogFromPackage(
192
+ source.registry, source.pkg, reusable?.version ?? undefined,
193
+ )
194
+ // `data === null` means the published version is the one in hand.
195
+ if (data === null && reusable !== null) return reusable.data
196
+ if (data === null) throw new Error('the catalog package reported no change with nothing to reuse')
197
+ const parsed = asRegistry(data)
198
+ served = { key, etag: null, modified: null, version, data: parsed }
199
+ return parsed
200
+ }
201
+ // ETag first: it is exact, while a date has one-second resolution and
202
+ // a catalog republished twice within the same second would validate
203
+ // as unchanged. Only one is sent — an origin given both must satisfy
204
+ // both, which turns a weak ETag match into an unnecessary 200.
205
+ const headers: Record<string, string> = {}
206
+ if (reusable?.etag != null) headers['if-none-match'] = reusable.etag
207
+ else if (reusable?.modified != null) headers['if-modified-since'] = reusable.modified
208
+
209
+ const res = await marketFetch(source.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers })
210
+ if (res.status === 304) {
211
+ // Only reachable when we sent a validator, so `reusable` is present.
212
+ // Guarded anyway: answering a 304 with nothing to reuse would
213
+ // otherwise surface as a confusing parse error on an empty body.
214
+ if (reusable === null) throw new Error('the catalog answered "not modified" with nothing to revalidate')
215
+ return reusable.data
216
+ }
217
+ if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
218
+ const data = asRegistry(await res.json())
219
+ served = {
220
+ key, etag: res.headers.get('etag'), modified: res.headers.get('last-modified'), version: null, data,
221
+ }
222
+ return data
223
+ } catch (error) {
224
+ last = error
225
+ }
226
+ }
227
+ }
228
+ throw new Error(describeFetchFailure(last, Date.now() - started, attempts))
229
+ }
230
+
231
+ /**
232
+ * A catalog failure with the facts needed to classify it, in the message
233
+ * itself.
234
+ *
235
+ * The market shows this string and the log export carries it, so it is the
236
+ * whole of what a bug report will contain. "The operation was aborted due to
237
+ * timeout" alone cannot distinguish a slow link from a blocked one from a
238
+ * proxy this process cannot use — and Node's `fetch` ignores HTTP_PROXY
239
+ * entirely (measured on Node 25), so a machine whose only route out is a
240
+ * proxy fails here every time while every other tool on it works.
241
+ */
242
+ export function describeFetchFailure(error: unknown, elapsedMs: number, attempts = 2): string {
243
+ const reason = error instanceof Error ? error.message : String(error)
244
+ const proxy = configuredProxy()
245
+ const parts = [`${reason} (${String(Math.round(elapsedMs / 1000))}s, ${String(attempts)} attempts)`]
246
+ if (proxy !== null) {
247
+ parts.push(`tried through the configured proxy ${proxy.replace(/\/\/[^@]*@/u, '//***@')}`)
248
+ }
249
+ return parts.join(' · ')
250
+ }