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