dsh-plugin-resolver 0.0.0-stage → 0.1.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.
@@ -0,0 +1,297 @@
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 explicit = process.env.DSH_PNPM_PATH
166
+ if (typeof explicit === 'string' && explicit !== '' && existsSync(explicit)) return explicit
167
+ return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
168
+ }
169
+
170
+ /**
171
+ * Ensure the GitHub checkout exists, downloading the repository tarball once.
172
+ *
173
+ * @param {{repo: string, ref: string | null}} plan
174
+ * @param {{githubProxy?: string}} options
175
+ * @returns {Promise<{dir: string, from: 'checkout' | 'download'}>}
176
+ */
177
+ export async function ensureCheckout(plan, options = {}) {
178
+ const slug = plan.repo
179
+ const safe = slug.replace(/[^A-Za-z0-9._-]+/g, '__')
180
+ const root = path.join(dshHome(), 'plugin-sources', safe)
181
+ if (dirExists(path.join(root, 'package.json')) || dirExists(path.join(root, '.git'))) {
182
+ return { dir: root, from: 'checkout' }
183
+ }
184
+ const refs = plan.ref ? [plan.ref] : ['main', 'master']
185
+ const attempts = []
186
+ for (const ref of refs) {
187
+ const urls = [`https://codeload.github.com/${slug}/tar.gz/refs/heads/${ref}`]
188
+ if (options.githubProxy) urls.push(`${options.githubProxy.replace(/\/+$/, '')}/${urls[0]}`)
189
+ for (const url of urls) {
190
+ try {
191
+ const bytes = await download(url)
192
+ const temp = path.join(dshHome(), 'cache', 'plugin-resolver', `src-${safe}-${Date.now()}`)
193
+ await rm(temp, { recursive: true, force: true })
194
+ await mkdir(temp, { recursive: true })
195
+ const tarball = path.join(temp, 'repo.tar.gz')
196
+ await writeFile(tarball, bytes)
197
+ const extractDir = path.join(temp, 'extract')
198
+ await mkdir(extractDir, { recursive: true })
199
+ const tar = process.platform === 'win32' ? 'tar.exe' : 'tar'
200
+ const extracted = await runProcess(tar, ['-xzf', tarball, '-C', extractDir], { timeoutMs: 120_000 })
201
+ if (extracted.code !== 0) throw new Error(`tar exited ${extracted.code}`)
202
+ const children = (await readdir(extractDir, { withFileTypes: true })).filter((item) => item.isDirectory())
203
+ if (children.length !== 1) throw new Error('unexpected tarball layout')
204
+ await rm(root, { recursive: true, force: true })
205
+ await mkdir(path.dirname(root), { recursive: true })
206
+ await rename(path.join(extractDir, children[0].name), root)
207
+ await rm(temp, { recursive: true, force: true })
208
+ return { dir: root, from: 'download' }
209
+ } catch (error) {
210
+ attempts.push(`${url} -> ${error.message}`)
211
+ }
212
+ }
213
+ }
214
+ throw new Error(`could not download ${slug}: ${attempts.join(' | ')}`)
215
+ }
216
+
217
+ /** Download a URL into memory with a bounded timeout. */
218
+ async function download(url) {
219
+ const controller = new AbortController()
220
+ const timer = setTimeout(() => controller.abort(), TARBALL_TIMEOUT_MS)
221
+ try {
222
+ const response = await fetch(url, { signal: controller.signal, redirect: 'follow' })
223
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
224
+ return Buffer.from(await response.arrayBuffer())
225
+ } finally {
226
+ clearTimeout(timer)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Build a GitHub-only plugin from its checkout when the package ships no
232
+ * built output, then install it as a `link:` package.
233
+ *
234
+ * @param {any} manager
235
+ * @param {any} plan a resolution of kind `github`
236
+ * @param {{githubProxy?: string, skipBuild?: boolean, log?: (line: string) => void}} options
237
+ * @returns {Promise<{packageName: string, packageDir: string, built: boolean, steps: string[], change: any, summary: ReturnType<typeof summarizeChange>}>}
238
+ */
239
+ export async function installGithubSource(manager, plan, options = {}) {
240
+ const log = options.log ?? (() => {})
241
+ const steps = []
242
+ const checkout = await ensureCheckout(plan, options)
243
+ steps.push(`checkout: ${checkout.dir} (${checkout.from})`)
244
+ log(`${checkout.from === 'download' ? 'download' : 'reuse'} ${plan.repo}`)
245
+
246
+ const packageDir = plan.subpath ? path.join(checkout.dir, plan.subpath) : checkout.dir
247
+ if (!dirExists(packageDir)) throw new Error(`subpath "${plan.subpath}" does not exist in ${plan.repo}`)
248
+ const manifestPath = path.join(packageDir, 'package.json')
249
+ if (!existsSync(manifestPath)) throw new Error(`${packageDir} has no package.json`)
250
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
251
+ const packageName = typeof manifest.name === 'string' ? manifest.name : null
252
+ if (!packageName) throw new Error(`${manifestPath} has no package name`)
253
+
254
+ const mainEntry = typeof manifest.main === 'string' && manifest.main !== '' ? manifest.main : 'index.js'
255
+ const builtPath = path.join(packageDir, mainEntry)
256
+ let built = existsSync(builtPath)
257
+ if (!built && options.skipBuild === true) {
258
+ throw new Error(`${packageName} ships no build output (${mainEntry} missing) and building was disabled`)
259
+ }
260
+ if (!built) {
261
+ if (!manifest.scripts || typeof manifest.scripts.build !== 'string') {
262
+ throw new Error(
263
+ `${packageName} ships no build output and declares no "build" script; nothing this plugin can build automatically`,
264
+ )
265
+ }
266
+ const pnpm = resolvePnpm()
267
+ const registry = registryBase(plan.registry)
268
+ const env = { npm_config_registry: registry, CI: '1' }
269
+ const install = await runProcess(pnpm, ['install', '--no-frozen-lockfile'], {
270
+ cwd: checkout.dir,
271
+ env,
272
+ timeoutMs: BUILD_TIMEOUT_MS,
273
+ onLine: log,
274
+ })
275
+ if (install.code !== 0) {
276
+ throw new Error(`dependency install failed (exit ${install.code}): ${tail(install.output, 900)}`)
277
+ }
278
+ steps.push(`deps: pnpm install in ${checkout.dir}`)
279
+ const build = await runProcess(pnpm, ['run', 'build'], {
280
+ cwd: packageDir,
281
+ env,
282
+ timeoutMs: BUILD_TIMEOUT_MS,
283
+ onLine: log,
284
+ })
285
+ if (build.code !== 0) {
286
+ throw new Error(`build failed (exit ${build.code}): ${tail(build.output, 900)}`)
287
+ }
288
+ steps.push(`build: pnpm run build in ${packageDir}`)
289
+ built = existsSync(builtPath)
290
+ if (!built) throw new Error(`build finished but ${mainEntry} still does not exist`)
291
+ }
292
+ log(`install link:${packageDir}`)
293
+ const change = await manager.installBundle(`link:${packageDir}`, { enabled: true })
294
+ steps.push(`install: link:${packageDir}`)
295
+ return { packageName, packageDir, built, steps, change, summary: summarizeChange(change) }
296
+ }
297
+
@@ -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
+