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