@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/sources.ts ADDED
@@ -0,0 +1,565 @@
1
+ /**
2
+ * Registry-source knowledge: how a curated registry entry's URL maps to an
3
+ * installable pnpm target. Pure string logic, no I/O.
4
+ */
5
+
6
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
7
+
8
+ function validSubpath(subpath: string): boolean {
9
+ if (!/^[A-Za-z0-9_./-]+$/.test(subpath)) return false
10
+ return !subpath.split('/').some(seg => seg === '' || seg === '.' || seg === '..')
11
+ }
12
+
13
+ /** Registry tarball names must be plain npm package names, nothing fancier. */
14
+ export const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
15
+
16
+ /**
17
+ * A curated, prebuilt GitHub Release archive accepted as a pnpm target —
18
+ * but only one belonging to `repo`, the entry's own `owner/name`.
19
+ *
20
+ * The binding is the whole point. `npm` gets the same treatment one branch
21
+ * up (repo-verified, "name-squatting protection"); without it here, an entry
22
+ * could name a trusted repo and install an archive from somewhere else:
23
+ *
24
+ * url: https://github.com/good/plugin
25
+ * tarball: https://github.com/evil/repo/releases/download/v1/p.tgz
26
+ *
27
+ * That is also why the release CDNs (`objects.githubusercontent.com`,
28
+ * `release-assets.githubusercontent.com`) are not accepted: their paths
29
+ * carry no owner or repo, so there is nothing to bind the archive to and no
30
+ * way to tell whose release it is. All 70 entries carrying `tarball` today
31
+ * use github.com and match their own repo, so nothing real is turned away.
32
+ */
33
+ function releaseTarballTarget(value: unknown, repo: string): string | null {
34
+ if (typeof value !== 'string') return null
35
+ const target = value.trim()
36
+ let url: URL
37
+ try {
38
+ url = new URL(target)
39
+ } catch {
40
+ return null
41
+ }
42
+ if (url.protocol !== 'https:' || url.hostname !== 'github.com') return null
43
+ if (!url.pathname.endsWith('.tgz') && !url.pathname.endsWith('.tar.gz')) return null
44
+ // /{owner}/{repo}/releases/... — the two leading segments must be the
45
+ // entry's own. GitHub treats them case-insensitively, so we do too.
46
+ const segments = url.pathname.split('/').filter(segment => segment !== '')
47
+ if (segments.length < 4 || segments[2] !== 'releases') return null
48
+ return `${segments[0]}/${segments[1]}`.toLowerCase() === repo.toLowerCase() ? target : null
49
+ }
50
+
51
+ /**
52
+ * Parse a registry source url: a github repo, optionally with a
53
+ * `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
54
+ * subpackages, e.g. dsh-plugins#theme-gallery).
55
+ */
56
+ export function parseSourceUrl(url: string): { repo: string; subpath: string | null } | null {
57
+ const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url)
58
+ if (m === null || !REPO_RE.test(m[1])) return null
59
+ const subpath = m[2] ?? null
60
+ if (subpath !== null) {
61
+ // No empty/dot segments: `..` would escape the repo in the #path: selector.
62
+ if (!validSubpath(subpath)) return null
63
+ }
64
+ return { repo: m[1], subpath }
65
+ }
66
+
67
+ function repoFromParts(owner: string, name: string): { repo: string } | null {
68
+ const repoName = name.replace(/\.git$/i, '')
69
+ const repo = `${owner}/${repoName}`
70
+ return REPO_RE.test(repo) ? { repo } : null
71
+ }
72
+
73
+ /** Parse repository forms accepted by package.json.repository. */
74
+ export function parseGitHubRepository(value: string): { repo: string } | null {
75
+ const input = value.trim()
76
+ const shortcut = /^(?:github:)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:#.*)?$/i.exec(input)
77
+ if (shortcut !== null) return repoFromParts(shortcut[1]!, shortcut[2]!)
78
+
79
+ const remote = input.replace(/^git\+/i, '')
80
+ const web = /^(?:https?|git|ssh):\/\/(?:git@)?github\.com[/:]([^/]+)\/([^/?#]+)\/?(?:[?#].*)?$/i.exec(remote)
81
+ const scp = /^git@github\.com:([^/]+)\/([^/?#]+)$/i.exec(remote)
82
+ const match = web ?? scp
83
+ return match === null ? null : repoFromParts(match[1]!, match[2]!)
84
+ }
85
+
86
+ /**
87
+ * Parse a Git remote. Unlike package metadata, a local origin may contain a
88
+ * proxy prefix (for example `https://proxy/https://github.com/o/r.git`). In
89
+ * that case only the last GitHub occurrence is considered.
90
+ */
91
+ export function parseGitHubRemote(url: string): { repo: string } | null {
92
+ const exact = parseGitHubRepository(url)
93
+ if (exact !== null) return exact
94
+ const matches = [...url.matchAll(/github\.com[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?=$|[/?#])/ig)]
95
+ const match = matches.at(-1)
96
+ return match === undefined ? null : repoFromParts(match[1]!, match[2]!)
97
+ }
98
+
99
+ /** Normalized repo identity shared by server discovery and client matching. */
100
+ export function githubRepoIdentity(url: string, directory?: string | null): string | null {
101
+ const source = parseGitHubRepository(url)
102
+ if (source === null) return null
103
+ const repo = source.repo.toLowerCase()
104
+ if (directory === undefined || directory === null || directory.trim() === '') return repo
105
+ const subpath = directory.trim().replaceAll('\\', '/').replace(/^\/+|\/+$/g, '')
106
+ return validSubpath(subpath) ? `${repo}#path:/${subpath.toLowerCase()}` : null
107
+ }
108
+
109
+ /**
110
+ * Repository evidence used for installed-source matching. A monorepo package
111
+ * contributes both its collection root and exact subpath, mirroring the
112
+ * identities extracted from `github:owner/repo#path:/package` specs.
113
+ */
114
+ export function githubRepoIdentities(url: string, directory?: string | null): string[] {
115
+ const identity = githubRepoIdentity(url, directory)
116
+ if (identity === null) return []
117
+ const pathAt = identity.indexOf('#path:/')
118
+ return pathAt === -1 ? [identity] : [identity.slice(0, pathAt), identity]
119
+ }
120
+
121
+ /** Weak identity hints from a local Git origin; never used to reject a unique match. */
122
+ export function githubRemoteIdentities(url: string, directory?: string | null): string[] {
123
+ const source = parseGitHubRemote(url)
124
+ if (source === null) return []
125
+ return githubRepoIdentities(`https://github.com/${source.repo}`, directory)
126
+ }
127
+
128
+ /** GitHub `owner/repo` for a registry URL, or null when it is not a GitHub repo URL. */
129
+ export function repoOf(url: string): string | null {
130
+ return parseSourceUrl(url)?.repo ?? null
131
+ }
132
+
133
+ /**
134
+ * A commit-pinned codeload tarball URL, optionally behind a prefix proxy.
135
+ *
136
+ * Pinned to a SHA rather than `HEAD` on purpose. pnpm records whatever URL
137
+ * it was given, and the profile's version detection reads the installed
138
+ * commit back out of the lockfile by matching `codeload.github.com/owner/
139
+ * repo/tar.gz/<40 hex>` (src/profile.ts). A `HEAD` URL installs fine and
140
+ * then reports no version forever, which is a worse outcome than being slow.
141
+ *
142
+ * @param repo - `owner/repo`.
143
+ * @param sha - full 40-character commit SHA.
144
+ * @param proxy - prefix proxy, or null to address codeload directly.
145
+ */
146
+ export function codeloadTarball(repo: string, sha: string, proxy: string | null): string {
147
+ const direct = `https://codeload.github.com/${repo}/tar.gz/${sha}`
148
+ return proxy === null ? direct : `${proxy}/${direct}`
149
+ }
150
+
151
+ /**
152
+ * The GitHub source behind an install target, in whatever spelling that
153
+ * target uses — `owner/repo` in its ORIGINAL case, plus any `#path:`
154
+ * subpath.
155
+ *
156
+ * Current installs use `github:` shortcuts. Older regional installs can
157
+ * still carry a prefix-proxied codeload tarball, so both spellings resolve
158
+ * here during update, duplicate detection, and recovery.
159
+ */
160
+ function githubShortcut(spec: string): { repo: string; subpath: string | null } | null {
161
+ const shortcut = /^github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?(?:#(.*))?$/.exec(spec)
162
+ if (shortcut === null) return null
163
+ // Any non-path fragment is accepted (`#semver:`, a bare ref) because only
164
+ // `path:` changes which plugin this is; the rest select a version of the
165
+ // same one. pnpm permits a revision and subpath together as
166
+ // `#<revision>&path:/sub`, which is what a mirror-resolved collection
167
+ // install uses (#385).
168
+ let subpath: string | null = null
169
+ for (const selector of (shortcut[2] ?? '').split('&')) {
170
+ const pathMatch = /^path:\/(.+)$/.exec(selector)
171
+ if (pathMatch === null) continue
172
+ const candidate = pathMatch[1]!
173
+ if (subpath !== null || !validSubpath(candidate)) return null
174
+ subpath = candidate
175
+ }
176
+ return { repo: shortcut[1]!, subpath }
177
+ }
178
+
179
+ function repoFromTarget(spec: string): { repo: string; subpath: string | null } | null {
180
+ const shortcut = githubShortcut(spec)
181
+ if (shortcut !== null) return shortcut
182
+ // A codeload tarball, direct or proxied. Matched as a substring for the
183
+ // same reason profile.ts does: the proxy sits in FRONT of the real URL,
184
+ // so anchoring the pattern would see only the proxy's own hostname.
185
+ const tarball = /codeload\.github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/tar\.gz\/[0-9a-f]{40}/.exec(spec)
186
+ if (tarball !== null) return { repo: tarball[1]!, subpath: null }
187
+ return null
188
+ }
189
+
190
+ /**
191
+ * Extract a GitHub repo URL from a URL that may be a Release asset tarball
192
+ * (the format used by the catalog: releases/latest/download/ or
193
+ * releases/download/vX.Y.Z/).
194
+ *
195
+ * THIS IS FOR DISPLAY/LOOKUP PURPOSES ONLY — e.g., finding update notes for a
196
+ * plugin installed via npm. It MUST NOT be used for any decision that affects
197
+ * installation, rollback, duplicate detection, or build-script approval.
198
+ * Those paths use `repoFromTarget` / `repoOfTarget` which are stricter and
199
+ * intentionally do NOT recognize Release asset URLs (because the same asset
200
+ * URL can serve different bytes at different times).
201
+ */
202
+ export function lookupRepoFromUrl(url: string): string | null {
203
+ // A GitHub Release asset tarball (used by the catalog), e.g.
204
+ // https://github.com/owner/repo/releases/latest/download/name.tgz
205
+ // https://github.com/owner/repo/releases/download/v1.0.0/name.tgz
206
+ const releaseAsset = /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/releases\/(?:latest\/download|download\/[^/]+)\/[^/]+\.(?:tgz|tar\.gz)/i.exec(url)
207
+ if (releaseAsset !== null) return `https://github.com/${releaseAsset[1]!}`
208
+ return null
209
+ }
210
+
211
+ /**
212
+ * Normalized identity of an install target, for comparing two targets that
213
+ * may be spelled differently — lowercased, matching `githubRepoIdentity`.
214
+ *
215
+ * @returns the identity, or null when the spec is not a GitHub source (an
216
+ * npm package name, a `file:` link, anything else).
217
+ */
218
+ export function repoOfTarget(spec: string): string | null {
219
+ const parsed = repoFromTarget(spec)
220
+ if (parsed === null) return null
221
+ const repo = parsed.repo.toLowerCase()
222
+ return parsed.subpath === null ? repo : `${repo}#path:/${parsed.subpath.toLowerCase()}`
223
+ }
224
+
225
+ /**
226
+ * The branch or tag a GitHub spec selects, or null for the default branch.
227
+ *
228
+ * Update detection has to ask about the same ref the install used, or it
229
+ * compares the installed commit against a line the user never chose (#446).
230
+ * A commit pin yields null: the answer for a pinned install is the default
231
+ * branch, which is what "is there something newer" means there.
232
+ */
233
+ export function githubRefOfTarget(spec: string): string | null {
234
+ if (!spec.startsWith('github:')) return null
235
+ const fragmentAt = spec.indexOf('#')
236
+ if (fragmentAt === -1) return null
237
+ for (const selector of spec.slice(fragmentAt + 1).split('&')) {
238
+ if (selector === '' || selector.startsWith('path:/')) continue
239
+ if (/^[0-9a-f]{40}$/i.test(selector)) continue
240
+ if (selector.startsWith('semver:')) continue
241
+ return selector
242
+ }
243
+ return null
244
+ }
245
+
246
+ /**
247
+ * An immutable GitHub commit already carried by an install target.
248
+ *
249
+ * Build-script approval on pnpm below 11.21 needs the exact commit-pinned
250
+ * codeload key. Re-resolving HEAD after an install can race a repository push
251
+ * and approve a different URL, so consume an existing pin whenever the spec
252
+ * has one (#285/#385).
253
+ */
254
+ export function githubCommitOfTarget(spec: string): string | null {
255
+ const shortcut = githubShortcut(spec)
256
+ const revision = shortcut === null ? null : /^github:[^#]+#([0-9a-f]{40})(?:&|$)/.exec(spec)
257
+ if (revision !== null) return revision[1]!
258
+ const tarball = /codeload\.github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/tar\.gz\/([0-9a-f]{40})(?:[?#][^\s]*)?$/.exec(spec)
259
+ return tarball === null ? null : tarball[1]!
260
+ }
261
+
262
+ /**
263
+ * Pin a GitHub shortcut to one immutable commit without losing its subpath.
264
+ * Revision selectors are replaced; one valid `path:` selector is preserved.
265
+ */
266
+ export function githubTargetAtCommit(spec: string, sha: string): string | null {
267
+ if (!/^[0-9a-f]{40}$/.test(sha)) return null
268
+ const parsed = githubShortcut(spec)
269
+ if (parsed === null) return null
270
+ return `github:${parsed.repo}#${sha}${parsed.subpath === null ? '' : `&path:/${parsed.subpath}`}`
271
+ }
272
+
273
+ /**
274
+ * The allowBuilds key that actually authorizes a git-hosted dependency's
275
+ * build scripts. Verified against pnpm 11.21 (#68 by @yzr278892): for a
276
+ * `github:owner/repo` install, a bare `name: true` entry does NOT match —
277
+ * pnpm's own hint names a commit-pinned codeload URL that changes on every
278
+ * push; the stable form that matches is `name@git+https://github.com/owner/repo.git`.
279
+ *
280
+ * A legacy China-region install may address the SAME repo through a proxied
281
+ * codeload URL, and must authorize under the same key: the plugin a user
282
+ * approved build scripts for does not become a different plugin because its
283
+ * stored source spelling differs.
284
+ *
285
+ * @param name - installed package name.
286
+ * @param spec - the dependency spec from package.json, or the install target.
287
+ * @returns the stable key, or null when the spec is not github-hosted.
288
+ */
289
+ export function gitAllowBuildsKey(name: string, spec: string): string | null {
290
+ const parsed = repoFromTarget(spec)
291
+ // Original case, not the normalized identity: this key is matched by pnpm
292
+ // as a literal string, so it has to name the repo the way the spec did.
293
+ // Subpath entries authorize under the repo itself — the `#path:` selector
294
+ // picks a directory out of the same download.
295
+ return parsed === null ? null : `${name}@git+https://github.com/${parsed.repo}.git`
296
+ }
297
+
298
+ /**
299
+ * The OTHER allowBuilds key form, for pnpm below 11.21 (#285 by @omdsh-dev,
300
+ * following #267).
301
+ *
302
+ * The stable `name@git+https://…` key above is what pnpm 11.21+ matches, and
303
+ * it is the better key precisely because it does not change when the
304
+ * repository is pushed to. Older pnpm does not match it at all: 11.8.0 — the
305
+ * version DSH Desktop still bundles — matches only the commit-pinned
306
+ * codeload URL it names in its own `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`
307
+ * message. On those versions the "allow build scripts and retry" button
308
+ * could never work, because the key it wrote was one pnpm would never read.
309
+ *
310
+ * Both are written. The pinned form goes stale the moment the repository
311
+ * moves, which is why it cannot REPLACE the stable one — but a stale entry
312
+ * costs a line in a YAML file, and a missing one costs the user the only
313
+ * button that could have unblocked them.
314
+ *
315
+ * @param sha - the commit the install will actually fetch.
316
+ * @returns the key, or null when the spec is not github-hosted.
317
+ */
318
+ export function codeloadAllowBuildsKey(name: string, spec: string, sha: string): string | null {
319
+ const parsed = repoFromTarget(spec)
320
+ if (parsed === null || !/^[0-9a-f]{40}$/.test(sha)) return null
321
+ return `${name}@https://codeload.github.com/${parsed.repo}/tar.gz/${sha}`
322
+ }
323
+
324
+ /**
325
+ * The pnpm install target for a registry entry. Repo-verified npm packages
326
+ * win, followed by author-supplied prebuilt GitHub Release tarballs; both avoid
327
+ * full-repo downloads and local build scripts.
328
+ * @returns the target spec, or null when the source url is unsupported.
329
+ */
330
+ export function installTargetFor(entry: { url: string; npm?: unknown; tarball?: unknown }): string | null {
331
+ const source = parseSourceUrl(entry.url)
332
+ if (source === null) return null
333
+ if (typeof entry.npm === 'string' && NPM_NAME_RE.test(entry.npm)) return entry.npm
334
+ const tarball = releaseTarballTarget(entry.tarball, source.repo)
335
+ if (tarball !== null) return tarball
336
+ return source.subpath !== null
337
+ ? `github:${source.repo}#path:/${source.subpath}`
338
+ : `github:${source.repo}`
339
+ }
340
+
341
+ /** True for profile specs that are a local checkout or tarball, not a registry pin. */
342
+ export function isLocalSpec(spec: string): boolean {
343
+ return /^(?:link|file):/i.test(spec)
344
+ }
345
+
346
+ /**
347
+ * True when the install came from a git remote — GitHub shortcuts, codeload
348
+ * tarballs, and any other host (Gitea, GitLab self-host, raw `git+https://…`).
349
+ *
350
+ * Update detection used to ask only `repoOfTarget` (GitHub spellings). A
351
+ * private-host URL then fell through to the npm branch and was looked up by
352
+ * package name; a colliding registry package read as an "update" and
353
+ * `name@latest` replaced the git install (#525).
354
+ */
355
+ export function isGitHostedSpec(spec: string): boolean {
356
+ const s = spec.trim()
357
+ if (s === '' || isLocalSpec(s)) return false
358
+ if (s.startsWith('github:')) return true
359
+ if (repoFromTarget(s) !== null) return true
360
+ if (/^git\+/i.test(s) || /^git:\/\//i.test(s) || /^ssh:\/\//i.test(s)) return true
361
+ if (/^git@[^/\s:]+:\S+/.test(s)) return true
362
+ // https://host/…/repo.git with optional fragment / query — not a Release
363
+ // archive and not a bare registry name.
364
+ if (/^https?:\/\/[^\s]+\/[^\s]+?\.git(?:[#?].*)?$/i.test(s)) return true
365
+ // Same shape without the `.git` suffix (Gitea/GitLab often omit it).
366
+ if (looksLikeHttpsGitRemote(s)) return true
367
+ return false
368
+ }
369
+
370
+ /**
371
+ * `https://host/owner/repo` (optional `.git`, fragment, query) that is not an
372
+ * npm registry, Release archive, or codeload tarball.
373
+ */
374
+ function looksLikeHttpsGitRemote(spec: string): boolean {
375
+ const bare = spec.trim().split(/[?#]/)[0] ?? ''
376
+ let url: URL
377
+ try {
378
+ url = new URL(bare)
379
+ } catch {
380
+ return false
381
+ }
382
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
383
+ const host = url.hostname.toLowerCase()
384
+ if (host === 'registry.npmjs.org' || host.endsWith('.npmjs.org')) return false
385
+ if (host === 'registry.npmmirror.com' || host === 'cdn.npmmirror.com') return false
386
+ if (host === 'codeload.github.com') return false
387
+ if (/\.(?:tgz|tar\.gz|zip)$/i.test(url.pathname)) return false
388
+ if (/\/(?:releases|archive)\//i.test(url.pathname)) return false
389
+ // Exactly owner/repo — deeper paths are too ambiguous to treat as git.
390
+ const segments = url.pathname.split('/').filter(seg => seg !== '')
391
+ if (segments.length !== 2) return false
392
+ const repo = segments[1]!.replace(/\.git$/i, '')
393
+ return /^[A-Za-z0-9_.-]+$/.test(segments[0]!) && /^[A-Za-z0-9_.-]+$/.test(repo)
394
+ }
395
+
396
+ /**
397
+ * Immutable commit already carried by a non-shortcut git URL (`…git#<sha>`).
398
+ * GitHub shortcuts keep using `githubCommitOfTarget`.
399
+ */
400
+ export function gitCommitOfTarget(spec: string): string | null {
401
+ const github = githubCommitOfTarget(spec)
402
+ if (github !== null) return github
403
+ const hash = spec.indexOf('#')
404
+ if (hash === -1) return null
405
+ const frag = spec.slice(hash + 1).split(/[?&]/)[0] ?? ''
406
+ return /^[0-9a-f]{40}$/i.test(frag) ? frag.toLowerCase() : null
407
+ }
408
+
409
+ /**
410
+ * pnpm add target for updating a non-shortcut git install: drop a full-SHA
411
+ * pin so the remote re-resolves to HEAD, keep any branch/tag fragment.
412
+ * GitHub-hosted `git+https://github.com/…` is rewritten to `github:` — the
413
+ * market's canonical spelling — so a successful update rematerializes the
414
+ * dependency that way and later check/update/rollback can use the first-class
415
+ * GitHub path. This turn still installs through the generic-git target slot
416
+ * (no region acceleration on the rewritten shortcut itself).
417
+ */
418
+ export function gitUpdateTarget(spec: string): string | null {
419
+ if (!isGitHostedSpec(spec) || spec.startsWith('github:') || repoFromTarget(spec) !== null) return null
420
+ const github = parseGitHubRemote(spec)
421
+ if (github !== null) {
422
+ const hash = spec.indexOf('#')
423
+ if (hash === -1) return `github:${github.repo}`
424
+ const frag = spec.slice(hash + 1).split(/[?&]/)[0] ?? ''
425
+ if (/^[0-9a-f]{40}$/i.test(frag)) return `github:${github.repo}`
426
+ // Preserve branch/tag / path selectors the same way githubUpdateTarget does
427
+ // for shortcuts — only full-SHA pins are dropped.
428
+ if (frag.startsWith('path:/') || frag.startsWith('semver:')) {
429
+ return `github:${github.repo}#${frag}`
430
+ }
431
+ if (frag !== '') return `github:${github.repo}#${frag}`
432
+ return `github:${github.repo}`
433
+ }
434
+ const hash = spec.indexOf('#')
435
+ if (hash === -1) return spec
436
+ const frag = spec.slice(hash + 1).split(/[?&]/)[0] ?? ''
437
+ return /^[0-9a-f]{40}$/i.test(frag) ? spec.slice(0, hash) : spec
438
+ }
439
+
440
+ /**
441
+ * Smart-HTTP info/refs URL for a git-hosted install target, or null when the
442
+ * transport cannot be probed with `fetch` (scp / git:// / ssh://).
443
+ * Userinfo is stripped so update checks do not resend embedded credentials.
444
+ */
445
+ export function gitUploadPackUrl(spec: string): string | null {
446
+ let remote = spec.trim().replace(/^git\+/i, '')
447
+ const scp = /^git@([^:]+):(.+)$/.exec(remote)
448
+ if (scp !== null) remote = `https://${scp[1]}/${scp[2]!.replace(/^\/*/, '')}`
449
+ const hash = remote.indexOf('#')
450
+ if (hash !== -1) remote = remote.slice(0, hash)
451
+ const query = remote.indexOf('?')
452
+ if (query !== -1) remote = remote.slice(0, query)
453
+ let url: URL
454
+ try {
455
+ url = new URL(remote)
456
+ } catch {
457
+ return null
458
+ }
459
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
460
+ url.username = ''
461
+ url.password = ''
462
+ const base = url.toString().replace(/\/+$/, '')
463
+ return `${base}/info/refs?service=git-upload-pack`
464
+ }
465
+
466
+ export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.ts'
467
+
468
+ /**
469
+ * pnpm add target for restoring a local checkout onto a catalog entry.
470
+ * When the catalog only lists the collection root but the checkout declared
471
+ * `repository.directory`, keep that subdirectory — otherwise we install the
472
+ * repo tarball and get the wrong package name (and its build scripts).
473
+ */
474
+ export function restoreTargetForLocal(
475
+ entry: { url: string; npm?: unknown },
476
+ identities: readonly string[] = [],
477
+ ): string | null {
478
+ const base = installTargetFor(entry)
479
+ if (base === null) return null
480
+ if (!base.startsWith('github:') || base.includes('#path:/')) return base
481
+ const repo = base.slice('github:'.length).toLowerCase()
482
+ for (const raw of identities) {
483
+ const id = raw.toLowerCase()
484
+ const prefix = `${repo}#path:/`
485
+ if (!id.startsWith(prefix)) continue
486
+ const subpath = id.slice(prefix.length)
487
+ if (validSubpath(subpath)) return `github:${base.slice('github:'.length)}#path:/${subpath}`
488
+ }
489
+ return base
490
+ }
491
+
492
+ /**
493
+ * Dependency names that use pnpm's `workspace:` protocol.
494
+ * Those specs only resolve inside the author's monorepo; a git `#path:`
495
+ * install into a profile cannot see the sibling packages. pnpm installs
496
+ * optional dependencies and auto-installs peers too, so all three maps are
497
+ * scanned; devDependencies are never installed and stay out.
498
+ */
499
+ export function workspaceProtocolDeps(manifest: unknown): string[] {
500
+ if (typeof manifest !== 'object' || manifest === null) return []
501
+ const seen = new Set<string>()
502
+ const names: string[] = []
503
+ for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) {
504
+ const deps = (manifest as Partial<Record<typeof field, unknown>>)[field]
505
+ if (typeof deps !== 'object' || deps === null) continue
506
+ for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
507
+ if (typeof spec === 'string' && spec.startsWith('workspace:') && !seen.has(name)) {
508
+ seen.add(name)
509
+ names.push(name)
510
+ }
511
+ }
512
+ }
513
+ return names
514
+ }
515
+
516
+ /** Git subdirectory restores cannot satisfy `workspace:` dependencies. npm can. */
517
+ export function restoreBlockedByWorkspace(target: string, workspaceDeps: readonly string[]): boolean {
518
+ return workspaceDeps.length > 0 && target.startsWith('github:')
519
+ }
520
+
521
+ /**
522
+ * The name an entry is ALREADY installed under, or null — the server-side
523
+ * duplicate guard (#27): the same plugin listed under an alias entry must
524
+ * never install twice (two loader entries with one id brick the next boot).
525
+ *
526
+ * Identity is subpath-aware so monorepo siblings stay independent: an entry
527
+ * with a /tree/ subpath identifies as repo#path:/sub (never the bare repo),
528
+ * while an installed dependency contributes its bare repo AND its #path:
529
+ * form — so a collection root still matches the pieces it was retargeted
530
+ * into, but two different subpackages of one repo never cross-match.
531
+ */
532
+ export function findInstalledAlias(
533
+ entry: { name: string; npm?: unknown; url: string },
534
+ installed: Record<string, string>,
535
+ ): string | null {
536
+ const source = parseSourceUrl(entry.url)
537
+ const entryRepoId = source === null
538
+ ? null
539
+ : source.subpath === null
540
+ ? source.repo.toLowerCase()
541
+ : `${source.repo.toLowerCase()}#path:/${source.subpath.toLowerCase()}`
542
+ const ids = new Set<string>([entry.name.toLowerCase()])
543
+ if (typeof entry.npm === 'string' && entry.npm !== '') ids.add(entry.npm.toLowerCase())
544
+ if (entryRepoId !== null) ids.add(entryRepoId)
545
+ for (const [name, spec] of Object.entries(installed)) {
546
+ const dep = new Set<string>([name.toLowerCase()])
547
+ const scoped = /^@([^/]+)\/(.+)$/.exec(name)
548
+ if (scoped !== null) dep.add(`${scoped[1]}/${scoped[2]}`.toLowerCase())
549
+ const repoId = repoOfTarget(spec)
550
+ if (repoId !== null) {
551
+ dep.add(repoId.split('#path:/')[0]!)
552
+ dep.add(repoId)
553
+ // Repo evidence on both sides is decisive (#66): the curated registry
554
+ // lists distinct plugins under one name (both dsh-usage-stats, four
555
+ // dsh-memory…), so a github-installed dependency is the entry's plugin
556
+ // only if the REPOS agree — a bare name coincidence must not count.
557
+ if (entryRepoId !== null) {
558
+ if (dep.has(entryRepoId)) return name
559
+ continue
560
+ }
561
+ }
562
+ for (const id of dep) if (ids.has(id)) return name
563
+ }
564
+ return null
565
+ }
package/src/store.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * pnpm store hygiene: reclaim staging directories orphaned by aborted runs.
3
+ *
4
+ * pnpm extracts every fetched tarball under the store's `tmp/` directory as
5
+ * `tmp/_tmp_<pid>_<hex>/`. A run that is killed, cancelled, or timed out —
6
+ * or one that hard-fails mid-fetch — never finishes that staging step, so
7
+ * the directory survives. For `github:` sources the staged payload is the
8
+ * WHOLE repository tarball, so a single aborted install can leave hundreds
9
+ * of megabytes behind (e.g. an OpenViking monorepo install killed at ~88MB).
10
+ *
11
+ * The directory name carries the owning pnpm process id, which makes
12
+ * reclamation safe by construction: when that pid is gone, no live download
13
+ * can be using the directory. Live pnpm tmp dirs are never touched.
14
+ */
15
+
16
+ import { readdirSync, rmSync, type Dirent } from 'node:fs'
17
+ import { isAbsolute, join } from 'node:path'
18
+ import type { InstallResult, PluginRunner } from './dsh-cli.ts'
19
+ import { logEvent } from './log.ts'
20
+
21
+ /** pnpm store tmp staging prefix: `_tmp_<pid>_<random-hex>/`. */
22
+ const ORPHAN_TMP_RE = /^_tmp_(\d+)_/
23
+
24
+ /** True when the process with this pid is still running (EPERM = exists). */
25
+ function pidAlive(pid: number): boolean {
26
+ try {
27
+ process.kill(pid, 0)
28
+ return true
29
+ } catch (error) {
30
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Remove every orphaned staging directory under a pnpm store's `tmp/` whose
36
+ * owning pid is no longer alive. Directories that do not match the pnpm
37
+ * staging shape, and any that are locked or in use, are left alone.
38
+ * @param storePath - the pnpm store root (as printed by `pnpm store path`).
39
+ * @returns the removed directory names.
40
+ */
41
+ export function cleanOrphanedStoreTmp(storePath: string): string[] {
42
+ const tmp = join(storePath, 'tmp')
43
+ let entries: Dirent[]
44
+ try {
45
+ entries = readdirSync(tmp, { withFileTypes: true })
46
+ } catch {
47
+ return [] // no store tmp dir — nothing to reclaim
48
+ }
49
+ const removed: string[] = []
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory()) continue
52
+ const m = ORPHAN_TMP_RE.exec(entry.name)
53
+ if (m === null) continue
54
+ const pid = Number(m[1])
55
+ if (pid > 0 && pidAlive(pid)) continue
56
+ try {
57
+ rmSync(join(tmp, entry.name), { recursive: true, force: true })
58
+ removed.push(entry.name)
59
+ } catch {
60
+ // Locked or still being written — leave it for a later run.
61
+ }
62
+ }
63
+ return removed
64
+ }
65
+
66
+ /**
67
+ * Resolve the active profile's pnpm store root through the same runner the
68
+ * market uses for installs (so both the web and Desktop pnpm paths agree)
69
+ * and reclaim its orphaned staging directories.
70
+ * @returns the removed directory names, empty when the store cannot be resolved.
71
+ */
72
+ export async function cleanOrphanedStore(run: PluginRunner, profile: string): Promise<string[]> {
73
+ let result: InstallResult
74
+ try {
75
+ result = await run(profile, ['store', 'path'])
76
+ } catch {
77
+ return []
78
+ }
79
+ if (result.exitCode !== 0 || result.cancelled) return []
80
+ const lines = result.stdout.split('\n').map(line => line.trim()).filter(line => line !== '')
81
+ const storePath = lines[lines.length - 1] ?? ''
82
+ if (storePath === '' || !isAbsolute(storePath)) return []
83
+ const removed = cleanOrphanedStoreTmp(storePath)
84
+ if (removed.length > 0) {
85
+ logEvent('info', 'install',
86
+ `removed ${removed.length} orphaned pnpm store tmp dir(s) under ${storePath}: ${removed.slice(0, 3).join(', ')}${removed.length > 3 ? ', …' : ''}`)
87
+ }
88
+ return removed
89
+ }