@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
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
|
+
}
|