@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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- 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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|