dsh-plugin-resolver 0.0.0-stage → 0.1.0
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/ABLATION.md +78 -0
- package/BENCHMARK.md +76 -0
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/cordis.patch.yml +5 -0
- package/host/register.js +166 -0
- package/index.js +62 -0
- package/logic/api.js +270 -0
- package/logic/catalog.js +281 -0
- package/logic/install.js +303 -0
- package/logic/resolve.js +396 -0
- package/logic/semver.js +227 -0
- package/logic/util.js +193 -0
- package/package.json +55 -4
package/logic/install.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Reloadable module: local dependencies carry this module's `?v=` query.
|
|
2
|
+
const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
|
|
3
|
+
const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
|
|
4
|
+
const { dshHome, dirExists, runProcess } = await import(__q('util.js'))
|
|
5
|
+
const { registryBase } = await import(__q('resolve.js'))
|
|
6
|
+
/**
|
|
7
|
+
* Installation: drive the profile's plugin manager, build GitHub-only sources,
|
|
8
|
+
* and verify what actually landed.
|
|
9
|
+
*
|
|
10
|
+
* The manager owns `package.json`, the lockfile and the activation lifecycle;
|
|
11
|
+
* this module never edits profile files itself. Registry packages therefore go
|
|
12
|
+
* straight through `installBundle`, and a source checkout is only ever handed
|
|
13
|
+
* to it as a `link:` spec after the build produced the package's main entry.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-resolver/lib/install
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** Longest a source install may run before the tool reports a timeout. */
|
|
23
|
+
const BUILD_TIMEOUT_MS = 20 * 60 * 1000
|
|
24
|
+
const TARBALL_TIMEOUT_MS = 5 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `pluginManager` service, or a clear failure.
|
|
28
|
+
*
|
|
29
|
+
* @param {any} ctx
|
|
30
|
+
* @returns {any}
|
|
31
|
+
*/
|
|
32
|
+
export function managerOf(ctx) {
|
|
33
|
+
const manager = ctx.get('pluginManager')
|
|
34
|
+
if (!manager || typeof manager.installBundle !== 'function') {
|
|
35
|
+
throw new Error('the pluginManager service is not mounted in this profile, so installs are unavailable')
|
|
36
|
+
}
|
|
37
|
+
return manager
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Condense one manager ChangeResult for tool output.
|
|
42
|
+
*
|
|
43
|
+
* @param {any} change
|
|
44
|
+
* @returns {{applied: boolean, application: string, stage: string, error?: string, pendingBuilds: string[], logPath?: string, exitCode?: number}}
|
|
45
|
+
*/
|
|
46
|
+
export function summarizeChange(change) {
|
|
47
|
+
const summary = {
|
|
48
|
+
applied: change?.application === 'applied',
|
|
49
|
+
application: String(change?.application ?? 'failed'),
|
|
50
|
+
stage: String(change?.stage ?? 'install'),
|
|
51
|
+
pendingBuilds: Array.isArray(change?.pendingBuilds) ? change.pendingBuilds : [],
|
|
52
|
+
bundle: typeof change?.bundle === 'string' ? change.bundle : null,
|
|
53
|
+
}
|
|
54
|
+
if (change?.error) {
|
|
55
|
+
const diagnostic = typeof change.error.diagnostic === 'string' ? change.error.diagnostic : ''
|
|
56
|
+
summary.error = `${change.error.code ?? 'error'}${diagnostic ? `: ${tail(diagnostic, 700)}` : ''}`
|
|
57
|
+
}
|
|
58
|
+
if (change?.packageResult) {
|
|
59
|
+
summary.logPath = change.packageResult.logPath
|
|
60
|
+
summary.exitCode = change.packageResult.exitCode
|
|
61
|
+
if (!summary.error && change.packageResult.exitCode !== 0) {
|
|
62
|
+
summary.error = `pnpm exited ${change.packageResult.exitCode}: ${tail(change.packageResult.output, 700)}`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return summary
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Last `limit` characters, flattened. */
|
|
69
|
+
function tail(text, limit) {
|
|
70
|
+
const flat = String(text ?? '').trim()
|
|
71
|
+
return flat.length <= limit ? flat : `鈥?{flat.slice(-limit)}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Install one registry-resolved package spec.
|
|
76
|
+
*
|
|
77
|
+
* @param {any} manager
|
|
78
|
+
* @param {{spec: string, registry?: string | null}} plan
|
|
79
|
+
* @returns {Promise<{change: any, summary: ReturnType<typeof summarizeChange>}>}
|
|
80
|
+
*/
|
|
81
|
+
export async function installRegistry(manager, plan) {
|
|
82
|
+
const options = { enabled: true }
|
|
83
|
+
if (typeof plan.registry === 'string' && plan.registry !== '') options.registry = plan.registry
|
|
84
|
+
const change = await manager.installBundle(plan.spec, options)
|
|
85
|
+
return { change, summary: summarizeChange(change) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the installed package manifest through the profile's hoisted
|
|
90
|
+
* `node_modules`, which also tells us whether the package declares a client
|
|
91
|
+
* half. The profile directory comes from the environment when the host exposes
|
|
92
|
+
* it; otherwise the standard `$DSH_HOME/profiles/<name>` layout is tried.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} packageName
|
|
95
|
+
* @returns {Promise<any | undefined>}
|
|
96
|
+
*/
|
|
97
|
+
export async function installedManifest(packageName) {
|
|
98
|
+
const candidates = []
|
|
99
|
+
if (process.env.DSH_PROFILE_DIR) candidates.push(process.env.DSH_PROFILE_DIR)
|
|
100
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : null
|
|
101
|
+
if (home) {
|
|
102
|
+
const profile = process.env.DSH_PROFILE && process.env.DSH_PROFILE !== '' ? process.env.DSH_PROFILE : 'web'
|
|
103
|
+
candidates.push(path.join(home, 'profiles', profile))
|
|
104
|
+
}
|
|
105
|
+
for (const profileDir of candidates) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(await readFile(path.join(profileDir, 'node_modules', packageName, 'package.json'), 'utf8'))
|
|
108
|
+
} catch {
|
|
109
|
+
// try the next candidate
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Verify a package after installation: loader entry, bundle selection, and
|
|
117
|
+
* whether a client half exists (asked of the live client module table first,
|
|
118
|
+
* because that is what actually decides whether a refresh will show it).
|
|
119
|
+
*
|
|
120
|
+
* @param {any} ctx
|
|
121
|
+
* @param {any} manager
|
|
122
|
+
* @param {string} packageName
|
|
123
|
+
* @returns {Promise<{entry?: any, bundle?: any, manifest?: any, hasClient: boolean, clientSource: string}>}
|
|
124
|
+
*/
|
|
125
|
+
export async function verifyInstall(ctx, manager, packageName) {
|
|
126
|
+
let entry
|
|
127
|
+
let bundle
|
|
128
|
+
try {
|
|
129
|
+
const plugins = await manager.listPlugins()
|
|
130
|
+
entry = plugins.find(
|
|
131
|
+
(plugin) =>
|
|
132
|
+
plugin.moduleName === packageName ||
|
|
133
|
+
(typeof plugin.moduleName === 'string' && plugin.moduleName.endsWith(`/${packageName}`)),
|
|
134
|
+
)
|
|
135
|
+
} catch {
|
|
136
|
+
entry = undefined
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const bundles = await manager.listBundles()
|
|
140
|
+
bundle = bundles.find((item) => item.name === packageName)
|
|
141
|
+
} catch {
|
|
142
|
+
bundle = undefined
|
|
143
|
+
}
|
|
144
|
+
let hasClient = false
|
|
145
|
+
let clientSource = 'none'
|
|
146
|
+
const clientModules = ctx.get('clientModules')
|
|
147
|
+
if (clientModules && typeof clientModules.clientPath === 'function') {
|
|
148
|
+
try {
|
|
149
|
+
hasClient = Boolean(clientModules.clientPath(packageName))
|
|
150
|
+
if (hasClient) clientSource = 'client table'
|
|
151
|
+
} catch {
|
|
152
|
+
// fall through to the manifest
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const manifest = await installedManifest(packageName)
|
|
156
|
+
if (!hasClient && manifest?.dsh?.client) {
|
|
157
|
+
hasClient = true
|
|
158
|
+
clientSource = 'manifest'
|
|
159
|
+
}
|
|
160
|
+
return { entry, bundle, manifest, hasClient, clientSource }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Locate a pnpm executable usable for a source build. */
|
|
164
|
+
export function resolvePnpm() {
|
|
165
|
+
const home = dshHome()
|
|
166
|
+
const candidates =
|
|
167
|
+
process.platform === 'win32'
|
|
168
|
+
? [path.join(home, '.desktop-bin', 'pnpm.cmd'), path.join(home, '..', '..', '.desktop-bin', 'pnpm.cmd')]
|
|
169
|
+
: [path.join(home, '.desktop-bin', 'pnpm')]
|
|
170
|
+
for (const candidate of candidates) {
|
|
171
|
+
if (existsSync(candidate)) return candidate
|
|
172
|
+
}
|
|
173
|
+
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Ensure the GitHub checkout exists, downloading the repository tarball once.
|
|
178
|
+
*
|
|
179
|
+
* @param {{repo: string, ref: string | null}} plan
|
|
180
|
+
* @param {{githubProxy?: string}} options
|
|
181
|
+
* @returns {Promise<{dir: string, from: 'checkout' | 'download'}>}
|
|
182
|
+
*/
|
|
183
|
+
export async function ensureCheckout(plan, options = {}) {
|
|
184
|
+
const slug = plan.repo
|
|
185
|
+
const safe = slug.replace(/[^A-Za-z0-9._-]+/g, '__')
|
|
186
|
+
const root = path.join(dshHome(), 'plugin-sources', safe)
|
|
187
|
+
if (dirExists(path.join(root, 'package.json')) || dirExists(path.join(root, '.git'))) {
|
|
188
|
+
return { dir: root, from: 'checkout' }
|
|
189
|
+
}
|
|
190
|
+
const refs = plan.ref ? [plan.ref] : ['main', 'master']
|
|
191
|
+
const attempts = []
|
|
192
|
+
for (const ref of refs) {
|
|
193
|
+
const urls = [`https://codeload.github.com/${slug}/tar.gz/refs/heads/${ref}`]
|
|
194
|
+
if (options.githubProxy) urls.push(`${options.githubProxy.replace(/\/+$/, '')}/${urls[0]}`)
|
|
195
|
+
for (const url of urls) {
|
|
196
|
+
try {
|
|
197
|
+
const bytes = await download(url)
|
|
198
|
+
const temp = path.join(dshHome(), 'cache', 'plugin-resolver', `src-${safe}-${Date.now()}`)
|
|
199
|
+
await rm(temp, { recursive: true, force: true })
|
|
200
|
+
await mkdir(temp, { recursive: true })
|
|
201
|
+
const tarball = path.join(temp, 'repo.tar.gz')
|
|
202
|
+
await writeFile(tarball, bytes)
|
|
203
|
+
const extractDir = path.join(temp, 'extract')
|
|
204
|
+
await mkdir(extractDir, { recursive: true })
|
|
205
|
+
const tar = process.platform === 'win32' ? 'tar.exe' : 'tar'
|
|
206
|
+
const extracted = await runProcess(tar, ['-xzf', tarball, '-C', extractDir], { timeoutMs: 120_000 })
|
|
207
|
+
if (extracted.code !== 0) throw new Error(`tar exited ${extracted.code}`)
|
|
208
|
+
const children = (await readdir(extractDir, { withFileTypes: true })).filter((item) => item.isDirectory())
|
|
209
|
+
if (children.length !== 1) throw new Error('unexpected tarball layout')
|
|
210
|
+
await rm(root, { recursive: true, force: true })
|
|
211
|
+
await mkdir(path.dirname(root), { recursive: true })
|
|
212
|
+
await rename(path.join(extractDir, children[0].name), root)
|
|
213
|
+
await rm(temp, { recursive: true, force: true })
|
|
214
|
+
return { dir: root, from: 'download' }
|
|
215
|
+
} catch (error) {
|
|
216
|
+
attempts.push(`${url} -> ${error.message}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`could not download ${slug}: ${attempts.join(' | ')}`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Download a URL into memory with a bounded timeout. */
|
|
224
|
+
async function download(url) {
|
|
225
|
+
const controller = new AbortController()
|
|
226
|
+
const timer = setTimeout(() => controller.abort(), TARBALL_TIMEOUT_MS)
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(url, { signal: controller.signal, redirect: 'follow' })
|
|
229
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
230
|
+
return Buffer.from(await response.arrayBuffer())
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(timer)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Build a GitHub-only plugin from its checkout when the package ships no
|
|
238
|
+
* built output, then install it as a `link:` package.
|
|
239
|
+
*
|
|
240
|
+
* @param {any} manager
|
|
241
|
+
* @param {any} plan a resolution of kind `github`
|
|
242
|
+
* @param {{githubProxy?: string, skipBuild?: boolean, log?: (line: string) => void}} options
|
|
243
|
+
* @returns {Promise<{packageName: string, packageDir: string, built: boolean, steps: string[], change: any, summary: ReturnType<typeof summarizeChange>}>}
|
|
244
|
+
*/
|
|
245
|
+
export async function installGithubSource(manager, plan, options = {}) {
|
|
246
|
+
const log = options.log ?? (() => {})
|
|
247
|
+
const steps = []
|
|
248
|
+
const checkout = await ensureCheckout(plan, options)
|
|
249
|
+
steps.push(`checkout: ${checkout.dir} (${checkout.from})`)
|
|
250
|
+
log(`${checkout.from === 'download' ? 'download' : 'reuse'} ${plan.repo}`)
|
|
251
|
+
|
|
252
|
+
const packageDir = plan.subpath ? path.join(checkout.dir, plan.subpath) : checkout.dir
|
|
253
|
+
if (!dirExists(packageDir)) throw new Error(`subpath "${plan.subpath}" does not exist in ${plan.repo}`)
|
|
254
|
+
const manifestPath = path.join(packageDir, 'package.json')
|
|
255
|
+
if (!existsSync(manifestPath)) throw new Error(`${packageDir} has no package.json`)
|
|
256
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
257
|
+
const packageName = typeof manifest.name === 'string' ? manifest.name : null
|
|
258
|
+
if (!packageName) throw new Error(`${manifestPath} has no package name`)
|
|
259
|
+
|
|
260
|
+
const mainEntry = typeof manifest.main === 'string' && manifest.main !== '' ? manifest.main : 'index.js'
|
|
261
|
+
const builtPath = path.join(packageDir, mainEntry)
|
|
262
|
+
let built = existsSync(builtPath)
|
|
263
|
+
if (!built && options.skipBuild === true) {
|
|
264
|
+
throw new Error(`${packageName} ships no build output (${mainEntry} missing) and building was disabled`)
|
|
265
|
+
}
|
|
266
|
+
if (!built) {
|
|
267
|
+
if (!manifest.scripts || typeof manifest.scripts.build !== 'string') {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`${packageName} ships no build output and declares no "build" script; nothing this plugin can build automatically`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const pnpm = resolvePnpm()
|
|
273
|
+
const registry = registryBase(plan.registry)
|
|
274
|
+
const env = { npm_config_registry: registry, CI: '1' }
|
|
275
|
+
const install = await runProcess(pnpm, ['install', '--no-frozen-lockfile'], {
|
|
276
|
+
cwd: checkout.dir,
|
|
277
|
+
env,
|
|
278
|
+
timeoutMs: BUILD_TIMEOUT_MS,
|
|
279
|
+
onLine: log,
|
|
280
|
+
})
|
|
281
|
+
if (install.code !== 0) {
|
|
282
|
+
throw new Error(`dependency install failed (exit ${install.code}): ${tail(install.output, 900)}`)
|
|
283
|
+
}
|
|
284
|
+
steps.push(`deps: pnpm install in ${checkout.dir}`)
|
|
285
|
+
const build = await runProcess(pnpm, ['run', 'build'], {
|
|
286
|
+
cwd: packageDir,
|
|
287
|
+
env,
|
|
288
|
+
timeoutMs: BUILD_TIMEOUT_MS,
|
|
289
|
+
onLine: log,
|
|
290
|
+
})
|
|
291
|
+
if (build.code !== 0) {
|
|
292
|
+
throw new Error(`build failed (exit ${build.code}): ${tail(build.output, 900)}`)
|
|
293
|
+
}
|
|
294
|
+
steps.push(`build: pnpm run build in ${packageDir}`)
|
|
295
|
+
built = existsSync(builtPath)
|
|
296
|
+
if (!built) throw new Error(`build finished but ${mainEntry} still does not exist`)
|
|
297
|
+
}
|
|
298
|
+
log(`install link:${packageDir}`)
|
|
299
|
+
const change = await manager.installBundle(`link:${packageDir}`, { enabled: true })
|
|
300
|
+
steps.push(`install: link:${packageDir}`)
|
|
301
|
+
return { packageName, packageDir, built, steps, change, summary: summarizeChange(change) }
|
|
302
|
+
}
|
|
303
|
+
|
package/logic/resolve.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Reloadable module: local dependencies carry this module's `?v=` query.
|
|
2
|
+
const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
|
|
3
|
+
const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
|
|
4
|
+
const { baseName, githubSpecOf, normalizeCatalog, repoSlugOf, searchCatalog } = await import(__q('catalog.js'))
|
|
5
|
+
const { compareVersions, satisfies, sortVersionsDesc, parseVersion } = await import(__q('semver.js'))
|
|
6
|
+
const { fetchJson } = await import(__q('util.js'))
|
|
7
|
+
/**
|
|
8
|
+
* Resolution: what a target names, which concrete version is DSH-compatible,
|
|
9
|
+
* which registry to ask, and which install path applies.
|
|
10
|
+
*
|
|
11
|
+
* This is the decision layer the tools call before any mutation. It never
|
|
12
|
+
* touches the profile beyond reading the manager's own inventory.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-resolver/lib/resolve
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/** A package name, optionally with an explicit version. */
|
|
19
|
+
const NPM_TARGET_RE = /^(@[^/@\s]+\/[^@\s]+|[^@/\s#]+)(?:@([^\s]+))?$/
|
|
20
|
+
|
|
21
|
+
/** Clear hints that the target is already a concrete install spec. */
|
|
22
|
+
const EXPLICIT_SPEC_RE = /^(github:|git\+|file:|link:|https?:\/\/|\.{0,2}[\\/])/i
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read the running DSH core version from the manager's own bundle inventory.
|
|
26
|
+
*
|
|
27
|
+
* @param {any} manager the `pluginManager` service
|
|
28
|
+
* @returns {Promise<string | null>}
|
|
29
|
+
*/
|
|
30
|
+
export async function runtimeVersion(manager) {
|
|
31
|
+
try {
|
|
32
|
+
const bundles = await manager.listBundles()
|
|
33
|
+
const preferred = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh']
|
|
34
|
+
for (const name of preferred) {
|
|
35
|
+
const hit = bundles.find((bundle) => bundle.name === name && typeof bundle.version === 'string')
|
|
36
|
+
if (hit) return hit.version
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pick the registry asked first: the Host's own probe when mounted, else the
|
|
46
|
+
* manager's configured registry, else null (pnpm's own configuration).
|
|
47
|
+
*
|
|
48
|
+
* @param {any} ctx cordis context
|
|
49
|
+
* @param {any} manager
|
|
50
|
+
* @returns {Promise<string | null>}
|
|
51
|
+
*/
|
|
52
|
+
export async function chooseRegistry(ctx, manager) {
|
|
53
|
+
const probe = ctx.get('pluginRegistryProbe')
|
|
54
|
+
if (probe && typeof probe.fastest === 'function') {
|
|
55
|
+
try {
|
|
56
|
+
const fastest = await probe.fastest()
|
|
57
|
+
if (typeof fastest === 'string' && fastest !== '') return fastest
|
|
58
|
+
} catch {
|
|
59
|
+
// fall through to the configured registry
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const registries = await manager.registries()
|
|
64
|
+
if (registries?.resolved) return registries.resolved
|
|
65
|
+
if (typeof registries?.registry === 'string') return registries.registry
|
|
66
|
+
} catch {
|
|
67
|
+
// fall through
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A concrete base URL for registry HTTP reads. */
|
|
73
|
+
export function registryBase(registry) {
|
|
74
|
+
if (typeof registry === 'string' && /^https?:\/\//i.test(registry)) return registry.replace(/\/+$/, '')
|
|
75
|
+
return 'https://registry.npmjs.org'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Encode a scoped package name for a registry path. */
|
|
79
|
+
export function encodePackageName(name) {
|
|
80
|
+
return name.startsWith('@') ? name.replace('/', '%2f') : name
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fetch a package document from a registry.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} name
|
|
87
|
+
* @param {string} registry
|
|
88
|
+
* @returns {Promise<{name: string, versions: Record<string, any>, tags: Record<string, string>}>}
|
|
89
|
+
*/
|
|
90
|
+
export async function npmPackage(name, registry) {
|
|
91
|
+
const base = registryBase(registry)
|
|
92
|
+
const doc = await fetchJson(`${base}/${encodePackageName(name)}`, { timeoutMs: 30_000 })
|
|
93
|
+
if (!doc || typeof doc !== 'object' || typeof doc.versions !== 'object') {
|
|
94
|
+
throw new Error(`registry returned no version document for ${name}`)
|
|
95
|
+
}
|
|
96
|
+
return { name, versions: doc.versions ?? {}, tags: doc['dist-tags'] ?? {} }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Choose the newest published version that accepts the running DSH version.
|
|
101
|
+
*
|
|
102
|
+
* Only non-optional `@deepseek-ai/*` peers and an `engines.dsh` range are
|
|
103
|
+
* treated as runtime constraints: a plugin declares its DSH support line there
|
|
104
|
+
* independently of what a caret over a `0.x` prerelease happens to permit.
|
|
105
|
+
*
|
|
106
|
+
* @param {{versions: Record<string, any>, tags: Record<string, string>}} doc
|
|
107
|
+
* @param {string | null} runtime
|
|
108
|
+
* @returns {{version: string | null, reason: string, rejected: Array<{version: string, reason: string}>}}
|
|
109
|
+
*/
|
|
110
|
+
export function chooseCompatibleVersion(doc, runtime) {
|
|
111
|
+
const all = Object.keys(doc.versions).filter((version) => parseVersion(version) !== null)
|
|
112
|
+
const rejected = []
|
|
113
|
+
if (all.length === 0) return { version: null, reason: 'no published version', rejected }
|
|
114
|
+
const ordered = sortVersionsDesc(all)
|
|
115
|
+
if (runtime === null) {
|
|
116
|
+
const latest = doc.tags.latest ?? ordered[0]
|
|
117
|
+
return { version: latest, reason: 'DSH runtime version unknown; trusting the registry latest tag', rejected }
|
|
118
|
+
}
|
|
119
|
+
for (const version of ordered) {
|
|
120
|
+
const manifest = doc.versions[version] ?? {}
|
|
121
|
+
if (manifest.deprecated) {
|
|
122
|
+
if (rejected.length < 4) rejected.push({ version, reason: 'deprecated' })
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const constraint = peerConstraint(manifest, runtime)
|
|
126
|
+
if (constraint.ok) {
|
|
127
|
+
return {
|
|
128
|
+
version,
|
|
129
|
+
reason: `newest of ${all.length} published versions whose DSH peer range accepts ${runtime}`,
|
|
130
|
+
rejected,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (rejected.length < 4) rejected.push({ version, reason: constraint.reason })
|
|
134
|
+
}
|
|
135
|
+
return { version: null, reason: `no published version accepts DSH ${runtime}`, rejected }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check one published version's runtime constraints against the running DSH.
|
|
140
|
+
*
|
|
141
|
+
* Only the Host's own `@deepseek-ai/dsh*` packages share the runtime version
|
|
142
|
+
* line. Sibling libraries such as `@deepseek-ai/cordis` or
|
|
143
|
+
* `@deepseek-ai/schemastery` have their own version numbers and are therefore
|
|
144
|
+
* not runtime constraints.
|
|
145
|
+
*
|
|
146
|
+
* @param {any} manifest
|
|
147
|
+
* @param {string} runtime
|
|
148
|
+
* @returns {{ok: true} | {ok: false, reason: string}}
|
|
149
|
+
*/
|
|
150
|
+
function peerConstraint(manifest, runtime) {
|
|
151
|
+
const peers = manifest.peerDependencies ?? {}
|
|
152
|
+
const optional = manifest.peerDependenciesMeta ?? {}
|
|
153
|
+
const checked = []
|
|
154
|
+
for (const [name, range] of Object.entries(peers)) {
|
|
155
|
+
if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue
|
|
156
|
+
if (optional[name]?.optional === true) continue
|
|
157
|
+
if (typeof range !== 'string' || range.trim() === '') continue
|
|
158
|
+
checked.push(`${name}@${range}`)
|
|
159
|
+
if (!satisfies(runtime, range)) {
|
|
160
|
+
return { ok: false, reason: `${name} needs ${range}, host is ${runtime}` }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const engine = manifest.engines?.dsh
|
|
164
|
+
if (typeof engine === 'string' && engine !== '') {
|
|
165
|
+
checked.push(`engines.dsh ${engine}`)
|
|
166
|
+
if (!satisfies(runtime, engine)) return { ok: false, reason: `engines.dsh wants ${engine}, host is ${runtime}` }
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, reason: checked.length > 0 ? checked.join('; ') : 'no DSH constraint' }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Split `name@version`, leaving a scoped package intact.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} target
|
|
175
|
+
* @returns {{name: string, version: string | null} | null}
|
|
176
|
+
*/
|
|
177
|
+
export function parseNpmTarget(target) {
|
|
178
|
+
const match = NPM_TARGET_RE.exec(target.trim())
|
|
179
|
+
if (!match) return null
|
|
180
|
+
return { name: match[1], version: match[2] ?? null }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse a GitHub shorthand into its repository, revision and subpath.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} spec
|
|
187
|
+
* @returns {{repo: string, owner: string, name: string, ref: string | null, subpath: string | null} | null}
|
|
188
|
+
*/
|
|
189
|
+
export function parseGithubSpec(spec) {
|
|
190
|
+
const match = /^github:([^/\s#]+)\/([^/\s#]+)(?:#([^\s]*))?$/i.exec(spec.trim())
|
|
191
|
+
if (!match) return null
|
|
192
|
+
let ref = null
|
|
193
|
+
let subpath = null
|
|
194
|
+
for (const selector of (match[3] ?? '').split('&')) {
|
|
195
|
+
if (selector === '') continue
|
|
196
|
+
const pathMatch = /^path:\/?(.+)$/.exec(selector)
|
|
197
|
+
if (pathMatch) {
|
|
198
|
+
subpath = pathMatch[1].replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (/^[0-9a-f]{40}$/i.test(selector)) continue
|
|
202
|
+
if (selector.startsWith('semver:')) {
|
|
203
|
+
ref = null
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (selector.trim() !== '') ref = selector.trim()
|
|
207
|
+
}
|
|
208
|
+
return { repo: `${match[1]}/${match[2]}`, owner: match[1], name: match[2], ref, subpath }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve one target into an actionable plan without mutating anything.
|
|
213
|
+
*
|
|
214
|
+
* @param {any} ctx
|
|
215
|
+
* @param {any} manager
|
|
216
|
+
* @param {string} target user input: catalog name, npm name, name@version, or a spec
|
|
217
|
+
* @param {{owner?: string, version?: string, registry?: string, catalog?: CatalogEntry[]}} [options]
|
|
218
|
+
* @returns {Promise<Resolution>}
|
|
219
|
+
*/
|
|
220
|
+
export async function resolveTarget(ctx, manager, target, options = {}) {
|
|
221
|
+
const trimmed = String(target ?? '').trim()
|
|
222
|
+
if (trimmed === '') throw new Error('empty target')
|
|
223
|
+
const registry = options.registry ?? (await chooseRegistry(ctx, manager))
|
|
224
|
+
const runtime = await runtimeVersion(manager)
|
|
225
|
+
const notes = []
|
|
226
|
+
|
|
227
|
+
if (EXPLICIT_SPEC_RE.test(trimmed)) {
|
|
228
|
+
const gh = parseGithubSpec(trimmed)
|
|
229
|
+
if (gh) return await resolveGithub(manager, gh, { registry, runtime, notes, target: trimmed })
|
|
230
|
+
return {
|
|
231
|
+
kind: 'spec',
|
|
232
|
+
spec: trimmed,
|
|
233
|
+
name: null,
|
|
234
|
+
version: null,
|
|
235
|
+
registry,
|
|
236
|
+
runtime,
|
|
237
|
+
notes: ['installed verbatim through the profile plugin manager'],
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parsed = parseNpmTarget(trimmed)
|
|
242
|
+
if (!parsed) throw new Error(`cannot parse "${trimmed}" as a package name or spec`)
|
|
243
|
+
const explicitVersion = options.version ?? parsed.version
|
|
244
|
+
let name = parsed.name
|
|
245
|
+
let catalogEntry = null
|
|
246
|
+
|
|
247
|
+
const catalog = options.catalog ?? []
|
|
248
|
+
if (catalog.length > 0) {
|
|
249
|
+
const { matches } = searchCatalog(catalog, name, { limit: 12 })
|
|
250
|
+
const exact = matches.filter(({ entry }) => baseName(entry).toLowerCase() === name.toLowerCase())
|
|
251
|
+
const pool = exact.length > 0 ? exact.map(({ entry }) => entry) : []
|
|
252
|
+
if (pool.length > 0) {
|
|
253
|
+
let chosen = pool
|
|
254
|
+
if (options.owner) {
|
|
255
|
+
const wanted = options.owner.toLowerCase()
|
|
256
|
+
chosen = pool.filter((entry) => entry.owner.toLowerCase() === wanted)
|
|
257
|
+
if (chosen.length === 0) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`catalog has no "${name}" by owner "${options.owner}". Owners: ${pool
|
|
260
|
+
.map((entry) => entry.owner || 'unknown')
|
|
261
|
+
.join(', ')}`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (chosen.length > 1) {
|
|
266
|
+
return {
|
|
267
|
+
kind: 'ambiguous',
|
|
268
|
+
name,
|
|
269
|
+
registry,
|
|
270
|
+
runtime,
|
|
271
|
+
candidates: chosen.map((entry) => ({
|
|
272
|
+
name: baseName(entry),
|
|
273
|
+
owner: entry.owner,
|
|
274
|
+
stars: entry.stars,
|
|
275
|
+
npm: entry.npm,
|
|
276
|
+
install: entry.install,
|
|
277
|
+
url: entry.url,
|
|
278
|
+
description: entry.description,
|
|
279
|
+
})),
|
|
280
|
+
notes: ['several catalog entries share this name; re-run with owner or a full spec'],
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catalogEntry = chosen[0]
|
|
284
|
+
if (catalogEntry.npm) name = catalogEntry.npm
|
|
285
|
+
else {
|
|
286
|
+
const spec = githubSpecOf(catalogEntry)
|
|
287
|
+
if (spec) {
|
|
288
|
+
const gh = parseGithubSpec(spec)
|
|
289
|
+
if (gh) {
|
|
290
|
+
notes.push(`catalog entry ${catalogEntry.name} is GitHub-only (no npm package)`)
|
|
291
|
+
return await resolveGithub(manager, gh, { registry, runtime, notes, target: spec, catalogEntry })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
notes.push(`catalog entry ${catalogEntry.name} has no usable source; falling back to the npm name`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (explicitVersion) {
|
|
300
|
+
return {
|
|
301
|
+
kind: 'npm',
|
|
302
|
+
name,
|
|
303
|
+
spec: `${name}@${explicitVersion}`,
|
|
304
|
+
version: explicitVersion,
|
|
305
|
+
registry,
|
|
306
|
+
runtime,
|
|
307
|
+
catalogEntry,
|
|
308
|
+
notes: [...notes, 'explicit version requested; compatibility not checked'],
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let doc = null
|
|
313
|
+
try {
|
|
314
|
+
doc = await npmPackage(name, registry)
|
|
315
|
+
} catch (error) {
|
|
316
|
+
notes.push(`registry lookup failed (${error.message}); install will resolve the version itself`)
|
|
317
|
+
return { kind: 'npm', name, spec: name, version: null, registry, runtime, catalogEntry, notes }
|
|
318
|
+
}
|
|
319
|
+
const chosen = chooseCompatibleVersion(doc, runtime)
|
|
320
|
+
if (chosen.version === null) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`${name} has no version compatible with DSH ${runtime ?? 'unknown'}. ` +
|
|
323
|
+
`Nearest rejects: ${chosen.rejected.map((item) => `${item.version} (${item.reason})`).join('; ') || 'none'}`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (chosen.rejected.length > 0) {
|
|
327
|
+
notes.push(
|
|
328
|
+
`skipped newer incompatible versions: ${chosen.rejected.map((item) => `${item.version} (${item.reason})`).join('; ')}`,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
kind: 'npm',
|
|
333
|
+
name,
|
|
334
|
+
spec: `${name}@${chosen.version}`,
|
|
335
|
+
version: chosen.version,
|
|
336
|
+
latest: doc.tags?.latest ?? null,
|
|
337
|
+
registry,
|
|
338
|
+
runtime,
|
|
339
|
+
catalogEntry,
|
|
340
|
+
notes: [...notes, chosen.reason],
|
|
341
|
+
rejected: chosen.rejected,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Plan a GitHub-source install.
|
|
347
|
+
*
|
|
348
|
+
* @param {any} manager
|
|
349
|
+
* @param {{repo: string, owner: string, name: string, ref: string | null, subpath: string | null}} gh
|
|
350
|
+
* @param {{registry: string | null, runtime: string | null, notes: string[], target: string, catalogEntry?: any}} context
|
|
351
|
+
* @returns {Promise<Resolution>}
|
|
352
|
+
*/
|
|
353
|
+
async function resolveGithub(manager, gh, context) {
|
|
354
|
+
const slug = gh.repo
|
|
355
|
+
const label = gh.subpath ? `${slug}/${gh.subpath}` : slug
|
|
356
|
+
return {
|
|
357
|
+
kind: 'github',
|
|
358
|
+
repo: slug,
|
|
359
|
+
owner: gh.owner,
|
|
360
|
+
name: gh.name,
|
|
361
|
+
ref: gh.ref,
|
|
362
|
+
subpath: gh.subpath,
|
|
363
|
+
spec: context.target,
|
|
364
|
+
version: null,
|
|
365
|
+
registry: context.registry,
|
|
366
|
+
runtime: context.runtime,
|
|
367
|
+
catalogEntry: context.catalogEntry ?? null,
|
|
368
|
+
notes: [
|
|
369
|
+
...context.notes,
|
|
370
|
+
`GitHub source build: ${label}${gh.ref ? ` @ ${gh.ref}` : ''}`,
|
|
371
|
+
'source checkout is built locally (pnpm install + build) and then linked into the profile',
|
|
372
|
+
],
|
|
373
|
+
label,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @typedef {object} Resolution
|
|
379
|
+
* @property {'npm' | 'github' | 'spec' | 'ambiguous'} kind
|
|
380
|
+
* @property {string} spec
|
|
381
|
+
* @property {string | null} name
|
|
382
|
+
* @property {string | null} version
|
|
383
|
+
* @property {string | null} registry
|
|
384
|
+
* @property {string | null} runtime
|
|
385
|
+
* @property {string[]} notes
|
|
386
|
+
* @property {Array<{name: string, owner: string, stars: number, npm: string | null, install: string | null, url: string, description: string}>} [candidates]
|
|
387
|
+
* @property {Array<{version: string, reason: string}>} [rejected]
|
|
388
|
+
* @property {any} [catalogEntry]
|
|
389
|
+
* @property {string} [repo]
|
|
390
|
+
* @property {string | null} [ref]
|
|
391
|
+
* @property {string | null} [subpath]
|
|
392
|
+
* @property {string} [latest]
|
|
393
|
+
*/
|
|
394
|
+
|
|
395
|
+
export { normalizeCatalog, compareVersions, repoSlugOf, githubSpecOf }
|
|
396
|
+
|