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.
- 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 +297 -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/api.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reloadable logic entry: every tool body lives here, behind one cache-busted
|
|
3
|
+
* import. The host shell asks for this module with `?v=<logic mtime>` on each
|
|
4
|
+
* call, so editing any file under `logic/` takes effect on the next tool call
|
|
5
|
+
* without restarting the host.
|
|
6
|
+
*
|
|
7
|
+
* Nothing in here touches the loader or the tool registry; it receives the
|
|
8
|
+
* profile's `pluginManager` and a Cordis context and returns plain text.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-resolver/logic/api
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Local dependencies carry this module's own `?v=` query, so one cache-busted
|
|
14
|
+
// import of `api.js` reloads catalog/resolve/install/util together.
|
|
15
|
+
const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
|
|
16
|
+
const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
|
|
17
|
+
const { baseName, loadCatalog, searchCatalog } = await import(__q('catalog.js'))
|
|
18
|
+
const { installGithubSource, installRegistry, verifyInstall } = await import(__q('install.js'))
|
|
19
|
+
const { resolveTarget } = await import(__q('resolve.js'))
|
|
20
|
+
const { oneLine, stars } = await import(__q('util.js'))
|
|
21
|
+
|
|
22
|
+
/** Catalog load honoring the `refresh` flag and config overrides. */
|
|
23
|
+
async function catalogFor(options, refresh) {
|
|
24
|
+
try {
|
|
25
|
+
const combined = { ...options }
|
|
26
|
+
if (refresh) return await loadCatalog(combined, { force: true })
|
|
27
|
+
return await loadCatalog(combined)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return { entries: [], fetchedAt: 0, fromCache: false, source: 'unavailable', error: error?.message ?? String(error) }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bundle names already installed in this profile. */
|
|
34
|
+
async function installedNames(manager) {
|
|
35
|
+
const set = new Set()
|
|
36
|
+
if (!manager) return set
|
|
37
|
+
try {
|
|
38
|
+
const bundles = await manager.listBundles()
|
|
39
|
+
for (const bundle of bundles) {
|
|
40
|
+
if (bundle?.installed && typeof bundle.name === 'string') set.add(bundle.name)
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// inventory is best-effort
|
|
44
|
+
}
|
|
45
|
+
return set
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `plugin_search`: rank catalog entries and keep same-name ambiguity visible.
|
|
50
|
+
*
|
|
51
|
+
* @param {any} ctx
|
|
52
|
+
* @param {any} manager
|
|
53
|
+
* @param {Record<string, unknown>} options
|
|
54
|
+
* @param {any} args
|
|
55
|
+
* @returns {Promise<{text: string}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function pluginSearch(ctx, manager, options, args) {
|
|
58
|
+
const query = String(args?.query ?? '').trim()
|
|
59
|
+
if (query === '') return { text: 'plugin_search needs a non-empty query.' }
|
|
60
|
+
const installed = await installedNames(manager)
|
|
61
|
+
const catalog = await catalogFor(options, args?.refresh === true)
|
|
62
|
+
if (catalog.entries.length === 0) {
|
|
63
|
+
return { text: `Catalog is unavailable right now: ${catalog.error ?? 'empty catalog'}` }
|
|
64
|
+
}
|
|
65
|
+
const { matches, ambiguous } = searchCatalog(catalog.entries, query, {
|
|
66
|
+
limit: Number(args?.limit) || 8,
|
|
67
|
+
installed,
|
|
68
|
+
})
|
|
69
|
+
if (matches.length === 0) {
|
|
70
|
+
return { text: `No catalog entry matches "${query}" (${catalog.entries.length} entries, ${catalog.fromCache ? 'cached' : 'fresh'}).` }
|
|
71
|
+
}
|
|
72
|
+
const lines = [
|
|
73
|
+
`${matches.length} match(es) for "${query}" — catalog ${catalog.entries.length} entries, ${catalog.fromCache ? 'cached' : `fresh from ${catalog.source}`}:`,
|
|
74
|
+
'',
|
|
75
|
+
]
|
|
76
|
+
matches.forEach(({ entry, installed: isInstalled }, index) => {
|
|
77
|
+
lines.push(`${index + 1}. ${entry.name} ★${stars(entry.stars)} owner=${entry.owner || '?'}${isInstalled ? ' [installed]' : ''}`)
|
|
78
|
+
if (entry.npm) lines.push(` npm: ${entry.npm}${entry.version ? `@${entry.version}` : ''}`)
|
|
79
|
+
else lines.push(' npm: (not published — GitHub source)')
|
|
80
|
+
if (entry.install) lines.push(` install: ${entry.install}`)
|
|
81
|
+
if (entry.description) lines.push(` ${oneLine(entry.description, 150)}`)
|
|
82
|
+
})
|
|
83
|
+
if (ambiguous) {
|
|
84
|
+
lines.push('', 'Same name has several owners. Disambiguate with owner or a full spec, e.g. owner="wqty123".')
|
|
85
|
+
}
|
|
86
|
+
return { text: lines.join('\n') }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `plugin_resolve`: produce the install plan without mutating anything.
|
|
91
|
+
*/
|
|
92
|
+
export async function pluginResolve(ctx, manager, options, args) {
|
|
93
|
+
if (!manager) return { text: 'The pluginManager service is not mounted in this profile; resolve is unavailable.' }
|
|
94
|
+
const catalog = await catalogFor(options, args?.refresh === true)
|
|
95
|
+
const plan = await resolveTarget(ctx, manager, String(args?.target ?? ''), {
|
|
96
|
+
owner: typeof args?.owner === 'string' ? args.owner : undefined,
|
|
97
|
+
version: typeof args?.version === 'string' ? args.version : undefined,
|
|
98
|
+
registry: typeof args?.registry === 'string' && args.registry !== '' ? args.registry : options.registry,
|
|
99
|
+
catalog: catalog.entries,
|
|
100
|
+
})
|
|
101
|
+
return { text: formatPlan(plan, 'resolve') }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `plugin_install`: resolve, install through the manager, verify, and report
|
|
106
|
+
* whether a refresh or restart is needed.
|
|
107
|
+
*/
|
|
108
|
+
export async function pluginInstall(ctx, manager, options, args) {
|
|
109
|
+
if (!manager) return { text: 'The pluginManager service is not mounted in this profile; install is unavailable.' }
|
|
110
|
+
try {
|
|
111
|
+
const catalog = await catalogFor(options, args?.refresh === true)
|
|
112
|
+
const plan = await resolveTarget(ctx, manager, String(args?.target ?? ''), {
|
|
113
|
+
owner: typeof args?.owner === 'string' ? args.owner : undefined,
|
|
114
|
+
version: typeof args?.version === 'string' ? args.version : undefined,
|
|
115
|
+
registry: typeof args?.registry === 'string' && args.registry !== '' ? args.registry : options.registry,
|
|
116
|
+
catalog: catalog.entries,
|
|
117
|
+
})
|
|
118
|
+
if (plan.kind === 'ambiguous') return { text: formatPlan(plan, 'install') }
|
|
119
|
+
|
|
120
|
+
const lines = [formatPlan(plan, 'install'), '']
|
|
121
|
+
const logLines = []
|
|
122
|
+
let packageName = plan.name
|
|
123
|
+
let steps = []
|
|
124
|
+
let application = 'unknown'
|
|
125
|
+
|
|
126
|
+
if (plan.kind === 'github') {
|
|
127
|
+
const result = await installGithubSource(manager, plan, {
|
|
128
|
+
githubProxy: options.githubProxy,
|
|
129
|
+
skipBuild: args?.skipBuild === true,
|
|
130
|
+
log: (line) => {
|
|
131
|
+
logLines.push(line)
|
|
132
|
+
if (logLines.length > 12) logLines.shift()
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
packageName = result.packageName
|
|
136
|
+
steps = result.steps
|
|
137
|
+
application = result.summary.application
|
|
138
|
+
lines.push(`install: ${result.summary.application} (${result.summary.stage})`)
|
|
139
|
+
if (result.summary.error) lines.push(`error: ${result.summary.error}`)
|
|
140
|
+
if (steps.length > 0) lines.push(`steps: ${steps.join(' → ')}`)
|
|
141
|
+
} else {
|
|
142
|
+
const { summary } = await installRegistry(manager, plan)
|
|
143
|
+
application = summary.application
|
|
144
|
+
if (packageName === null && summary.bundle) packageName = summary.bundle
|
|
145
|
+
lines.push(`install: ${summary.application} (${summary.stage})`)
|
|
146
|
+
if (summary.error) lines.push(`error: ${summary.error}`)
|
|
147
|
+
if (summary.pendingBuilds.length > 0) {
|
|
148
|
+
lines.push(`pending build scripts (needs approval): ${summary.pendingBuilds.join(', ')}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (packageName) {
|
|
153
|
+
const health = await verifyInstall(ctx, manager, packageName)
|
|
154
|
+
lines.push('', 'verification:')
|
|
155
|
+
if (health.entry) {
|
|
156
|
+
const phase = health.entry.fiberPhase ?? 'unknown'
|
|
157
|
+
const flag = phase === 'active' ? 'OK' : phase === 'failed' ? 'FAILED' : phase
|
|
158
|
+
lines.push(`- loader entry ${health.entry.entryId}: enabled=${health.entry.enabled} fiberPhase=${phase} [${flag}]`)
|
|
159
|
+
} else {
|
|
160
|
+
lines.push('- loader entry: not found — the host half may need a restart to mount')
|
|
161
|
+
}
|
|
162
|
+
if (health.bundle) {
|
|
163
|
+
lines.push(`- bundle: ${health.bundle.name}@${health.bundle.version ?? '?'} enabled=${health.bundle.enabled}`)
|
|
164
|
+
}
|
|
165
|
+
lines.push(`- package: ${packageName}${health.hasClient ? ` (client half via ${health.clientSource})` : ' (host-only)'}`)
|
|
166
|
+
const next = []
|
|
167
|
+
if (application === 'restart-required') {
|
|
168
|
+
next.push('restart DSH fully: replacing an already-mounted plugin cannot be hot-swapped safely')
|
|
169
|
+
}
|
|
170
|
+
if (health.entry?.fiberPhase === 'failed') next.push('host half failed to load; inspect the harness log for the error')
|
|
171
|
+
if (health.hasClient && application !== 'restart-required') next.push('client half exists: refresh the GUI (Ctrl+R) to load it')
|
|
172
|
+
if (health.entry?.fiberPhase === 'active' && !health.hasClient && application !== 'restart-required') {
|
|
173
|
+
next.push('host-only: no GUI refresh required')
|
|
174
|
+
}
|
|
175
|
+
if (next.length > 0) lines.push('', `next: ${next.join('; ')}`)
|
|
176
|
+
}
|
|
177
|
+
if (logLines.length > 0) lines.push('', `build log (tail):\n ${logLines.join('\n ')}`)
|
|
178
|
+
return { text: lines.join('\n') }
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { text: `plugin_install failed: ${error?.message ?? String(error)}` }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `plugin_status`: third-party bundles and their loader entries.
|
|
186
|
+
*/
|
|
187
|
+
export async function pluginStatus(ctx, manager, options, args) {
|
|
188
|
+
if (!manager) return { text: 'The pluginManager service is not mounted in this profile; status is unavailable.' }
|
|
189
|
+
const filter = String(args?.query ?? '').trim().toLowerCase()
|
|
190
|
+
const lines = []
|
|
191
|
+
try {
|
|
192
|
+
const bundles = await manager.listBundles()
|
|
193
|
+
const installed = bundles.filter((bundle) => bundle.installed && !String(bundle.name).startsWith('@deepseek-ai/'))
|
|
194
|
+
lines.push(`installed third-party bundles: ${installed.length}`)
|
|
195
|
+
for (const bundle of installed) {
|
|
196
|
+
if (filter !== '' && !String(bundle.name).toLowerCase().includes(filter)) continue
|
|
197
|
+
lines.push(`- ${bundle.name}@${bundle.version ?? '?'} enabled=${bundle.enabled} optional=${bundle.optional}`)
|
|
198
|
+
const rows = Array.isArray(bundle.rows) ? bundle.rows.filter((row) => row.entryId) : []
|
|
199
|
+
if (rows.length > 0) lines.push(` rows: ${rows.map((row) => `${row.entryId}(${row.moduleName})`).join(', ')}`)
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
lines.push(`bundle inventory failed: ${error.message}`)
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const plugins = await manager.listPlugins()
|
|
206
|
+
const thirdParty = plugins.filter((plugin) => {
|
|
207
|
+
const name = String(plugin.moduleName ?? '')
|
|
208
|
+
return name !== '' && !name.startsWith('@deepseek-ai/') && !name.startsWith('file:///')
|
|
209
|
+
})
|
|
210
|
+
lines.push('', `third-party loader entries: ${thirdParty.length}`)
|
|
211
|
+
for (const plugin of thirdParty) {
|
|
212
|
+
if (filter !== '' && !plugin.moduleName.toLowerCase().includes(filter)) continue
|
|
213
|
+
lines.push(`- ${plugin.entryId}: ${plugin.moduleName} enabled=${plugin.enabled} fiberPhase=${plugin.fiberPhase ?? 'null'}`)
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
lines.push(`plugin inventory failed: ${error.message}`)
|
|
217
|
+
}
|
|
218
|
+
return { text: lines.join('\n') }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render a resolution plan as compact text.
|
|
223
|
+
*
|
|
224
|
+
* @param {any} plan
|
|
225
|
+
* @param {'resolve' | 'install'} mode
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
export function formatPlan(plan, mode) {
|
|
229
|
+
if (plan.kind === 'ambiguous') {
|
|
230
|
+
const lines = [`"${plan.name}" is ambiguous: ${plan.candidates.length} catalog entries share this name.`, '']
|
|
231
|
+
plan.candidates.forEach((candidate, index) => {
|
|
232
|
+
lines.push(
|
|
233
|
+
`${index + 1}. owner=${candidate.owner || '?'} ★${stars(candidate.stars)} ${
|
|
234
|
+
candidate.npm ? `npm: ${candidate.npm}` : 'npm: (GitHub source)'
|
|
235
|
+
}`,
|
|
236
|
+
)
|
|
237
|
+
if (candidate.install) lines.push(` install: ${candidate.install}`)
|
|
238
|
+
if (candidate.description) lines.push(` ${oneLine(candidate.description, 130)}`)
|
|
239
|
+
})
|
|
240
|
+
lines.push('', `Re-run ${mode === 'install' ? 'plugin_install' : 'plugin_resolve'} with owner="<owner>" or a full spec.`)
|
|
241
|
+
return lines.join('\n')
|
|
242
|
+
}
|
|
243
|
+
const lines = []
|
|
244
|
+
const runtime = plan.runtime ?? 'unknown'
|
|
245
|
+
lines.push(`target kind: ${plan.kind} running DSH: ${runtime}`)
|
|
246
|
+
if (plan.name) lines.push(`package: ${plan.name}${plan.version ? `@${plan.version}` : ''}`)
|
|
247
|
+
if (plan.spec) lines.push(`install spec: ${plan.spec}`)
|
|
248
|
+
if (plan.registry) lines.push(`registry: ${plan.registry}`)
|
|
249
|
+
else lines.push('registry: pnpm default (no first registry named)')
|
|
250
|
+
if (plan.latest && plan.latest !== plan.version) lines.push(`latest published: ${plan.latest} (not used: incompatible or newer)`)
|
|
251
|
+
if (plan.kind === 'github') {
|
|
252
|
+
lines.push(`source: github.com/${plan.repo}${plan.ref ? ` @ ${plan.ref}` : ''}${plan.subpath ? ` (${plan.subpath})` : ''}`)
|
|
253
|
+
}
|
|
254
|
+
if (plan.catalogEntry) {
|
|
255
|
+
lines.push(`catalog: ${plan.catalogEntry.name} by ${plan.catalogEntry.owner || '?'} ★${stars(plan.catalogEntry.stars)}`)
|
|
256
|
+
if (plan.catalogEntry.description) lines.push(`about: ${oneLine(plan.catalogEntry.description, 160)}`)
|
|
257
|
+
}
|
|
258
|
+
if (Array.isArray(plan.rejected) && plan.rejected.length > 0) {
|
|
259
|
+
lines.push('skipped versions:')
|
|
260
|
+
for (const item of plan.rejected) lines.push(`- ${item.version}: ${item.reason}`)
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(plan.notes) && plan.notes.length > 0) {
|
|
263
|
+
lines.push('notes:')
|
|
264
|
+
for (const note of plan.notes) lines.push(`- ${note}`)
|
|
265
|
+
}
|
|
266
|
+
if (mode === 'install') lines.push('', 'proceeding with install…')
|
|
267
|
+
return lines.join('\n')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { baseName }
|
package/logic/catalog.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Reloadable module: every local dependency is imported with this module's own
|
|
2
|
+
// `?v=` query, so a cache-busted entry import reloads the whole graph together.
|
|
3
|
+
const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
|
|
4
|
+
const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
|
|
5
|
+
const { cacheDir, dirExists, fetchBuffer, fetchJson, oneLine, readJson, writeJson } = await import(__q('util.js'))
|
|
6
|
+
/**
|
|
7
|
+
* The community plugin catalog: fetch, normalize, cache, and search it.
|
|
8
|
+
*
|
|
9
|
+
* The curated list is the same one the in-app market reads. Its home is a
|
|
10
|
+
* GitHub Pages URL, which is slow and sometimes unreachable from mainland
|
|
11
|
+
* China, so the primary source is the `dsh-plugin-catalog` npm package: every
|
|
12
|
+
* npm mirror carries it, and the `dist.tarball` field keeps the download on
|
|
13
|
+
* whichever registry answered. The origin URL stays as the last fallback for
|
|
14
|
+
* deployments that cannot reach a registry.
|
|
15
|
+
*
|
|
16
|
+
* Same display names repeat across owners, which is why search keeps the owner
|
|
17
|
+
* in view and reports ambiguity instead of guessing.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-plugin-resolver/lib/catalog
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { gunzipSync } from 'node:zlib'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CATALOG_URL = 'https://awesome-dsh-plugin.com/plugins.json'
|
|
26
|
+
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000
|
|
27
|
+
const CATALOG_PACKAGE = 'dsh-plugin-catalog'
|
|
28
|
+
const DEFAULT_CATALOG_REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the catalog: fresh cache first, then the npm carrier, then the origin.
|
|
32
|
+
*
|
|
33
|
+
* @param {{catalogUrl?: string, catalogTtlMs?: number, catalogRegistries?: string[], registry?: string}} options
|
|
34
|
+
* @param {{force?: boolean}} [control]
|
|
35
|
+
* @returns {Promise<{entries: CatalogEntry[], fetchedAt: number, fromCache: boolean, source: string, version?: string, error?: string}>}
|
|
36
|
+
*/
|
|
37
|
+
export async function loadCatalog(options, control = {}) {
|
|
38
|
+
const file = path.join(cacheDir(), 'catalog.json')
|
|
39
|
+
const ttl = options.catalogTtlMs ?? DEFAULT_TTL_MS
|
|
40
|
+
if (!control.force) {
|
|
41
|
+
const cached = await readJson(file)
|
|
42
|
+
if (cached && Array.isArray(cached.entries) && Number.isFinite(cached.fetchedAt)) {
|
|
43
|
+
if (Date.now() - cached.fetchedAt < ttl) {
|
|
44
|
+
return {
|
|
45
|
+
entries: cached.entries,
|
|
46
|
+
fetchedAt: cached.fetchedAt,
|
|
47
|
+
fromCache: true,
|
|
48
|
+
source: typeof cached.source === 'string' ? cached.source : 'cache',
|
|
49
|
+
version: cached.version,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const attempts = []
|
|
56
|
+
const registries = [
|
|
57
|
+
...(Array.isArray(options.catalogRegistries) ? options.catalogRegistries : []),
|
|
58
|
+
options.registry,
|
|
59
|
+
...DEFAULT_CATALOG_REGISTRIES,
|
|
60
|
+
].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
|
|
61
|
+
|
|
62
|
+
for (const registry of registries) {
|
|
63
|
+
try {
|
|
64
|
+
const carried = await catalogFromPackage(registry)
|
|
65
|
+
const entries = normalizeCatalog(carried.raw)
|
|
66
|
+
const fetchedAt = Date.now()
|
|
67
|
+
await writeJson(file, { fetchedAt, source: 'npm', version: carried.version, entries })
|
|
68
|
+
return { entries, fetchedAt, fromCache: false, source: 'npm', version: carried.version }
|
|
69
|
+
} catch (error) {
|
|
70
|
+
attempts.push(`${registry}: ${error.message}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const urls = [options.catalogUrl, DEFAULT_CATALOG_URL].filter(
|
|
75
|
+
(value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index,
|
|
76
|
+
)
|
|
77
|
+
for (const url of urls) {
|
|
78
|
+
try {
|
|
79
|
+
const raw = await fetchJson(url, { timeoutMs: 180_000 })
|
|
80
|
+
const entries = normalizeCatalog(raw)
|
|
81
|
+
const fetchedAt = Date.now()
|
|
82
|
+
await writeJson(file, { fetchedAt, source: 'url', entries })
|
|
83
|
+
return { entries, fetchedAt, fromCache: false, source: 'url' }
|
|
84
|
+
} catch (error) {
|
|
85
|
+
attempts.push(`${url}: ${error.message}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
entries: [],
|
|
91
|
+
fetchedAt: 0,
|
|
92
|
+
fromCache: false,
|
|
93
|
+
source: 'unavailable',
|
|
94
|
+
error: `catalog unavailable (${attempts.join(' | ')})`,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read `plugins.json` out of the published catalog package via one registry.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} registry
|
|
102
|
+
* @returns {Promise<{raw: any, version: string | null}>}
|
|
103
|
+
*/
|
|
104
|
+
async function catalogFromPackage(registry) {
|
|
105
|
+
const base = registry.replace(/\/+$/, '')
|
|
106
|
+
const meta = await fetchJson(`${base}/${CATALOG_PACKAGE}/latest`, { timeoutMs: 20_000 })
|
|
107
|
+
const version = typeof meta?.version === 'string' ? meta.version : null
|
|
108
|
+
const tarball = typeof meta?.dist?.tarball === 'string' ? meta.dist.tarball : null
|
|
109
|
+
if (tarball === null) throw new Error('package metadata names no tarball')
|
|
110
|
+
const bytes = await fetchBuffer(tarball, { timeoutMs: 90_000 })
|
|
111
|
+
const file = fileFromTarball(bytes, 'package/plugins.json')
|
|
112
|
+
if (file === null) throw new Error('catalog package carries no package/plugins.json')
|
|
113
|
+
return { raw: JSON.parse(file.toString('utf8')), version }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extract one regular file from a gzipped tar.
|
|
118
|
+
*
|
|
119
|
+
* @param {Buffer} gz
|
|
120
|
+
* @param {string} wanted
|
|
121
|
+
* @returns {Buffer | null}
|
|
122
|
+
*/
|
|
123
|
+
export function fileFromTarball(gz, wanted) {
|
|
124
|
+
const buffer = gunzipSync(gz)
|
|
125
|
+
const BLOCK = 512
|
|
126
|
+
let offset = 0
|
|
127
|
+
while (offset + BLOCK <= buffer.length) {
|
|
128
|
+
const name = buffer
|
|
129
|
+
.toString('utf8', offset, offset + 100)
|
|
130
|
+
.replace(/\0[\s\S]*$/, '')
|
|
131
|
+
if (name === '') break
|
|
132
|
+
const rawSize = buffer
|
|
133
|
+
.toString('ascii', offset + 124, offset + 136)
|
|
134
|
+
.replace(/\0[\s\S]*$/, '')
|
|
135
|
+
.trim()
|
|
136
|
+
const size = Number.parseInt(rawSize, 8)
|
|
137
|
+
if (!Number.isFinite(size) || size < 0) break
|
|
138
|
+
const type = String.fromCharCode(buffer[offset + 156] ?? 0)
|
|
139
|
+
offset += BLOCK
|
|
140
|
+
if ((type === '0' || type === '\0') && name === wanted) {
|
|
141
|
+
return buffer.subarray(offset, offset + size)
|
|
142
|
+
}
|
|
143
|
+
offset += Math.ceil(size / BLOCK) * BLOCK
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalize one raw catalog payload.
|
|
150
|
+
*
|
|
151
|
+
* @typedef {object} CatalogEntry
|
|
152
|
+
* @property {string} name display name, possibly `name#subpath`
|
|
153
|
+
* @property {string} owner repository owner
|
|
154
|
+
* @property {string} url repository URL
|
|
155
|
+
* @property {string | null} npm published npm package name
|
|
156
|
+
* @property {string | null} version catalogued version
|
|
157
|
+
* @property {number} stars
|
|
158
|
+
* @property {string | null} install the documented install command
|
|
159
|
+
* @property {string} description one-line, English preferred
|
|
160
|
+
* @property {string} category
|
|
161
|
+
* @property {string} added
|
|
162
|
+
*
|
|
163
|
+
* @param {any} raw
|
|
164
|
+
* @returns {CatalogEntry[]}
|
|
165
|
+
*/
|
|
166
|
+
export function normalizeCatalog(raw) {
|
|
167
|
+
const list = Array.isArray(raw) ? raw : raw?.plugins ?? raw?.entries ?? []
|
|
168
|
+
const entries = []
|
|
169
|
+
for (const item of list) {
|
|
170
|
+
if (!item || typeof item.name !== 'string' || item.name === '') continue
|
|
171
|
+
entries.push({
|
|
172
|
+
name: item.name,
|
|
173
|
+
owner: typeof item.owner === 'string' ? item.owner : '',
|
|
174
|
+
url: typeof item.url === 'string' ? item.url : '',
|
|
175
|
+
npm: typeof item.npm === 'string' && item.npm !== '' ? item.npm : null,
|
|
176
|
+
version: typeof item.version === 'string' && item.version !== '' ? item.version : null,
|
|
177
|
+
stars: Number(item.stars) || 0,
|
|
178
|
+
install: typeof item.install === 'string' ? item.install : null,
|
|
179
|
+
description: oneLine(item.description?.en ?? item.description?.zh ?? '', 160),
|
|
180
|
+
category: typeof item.category === 'string' ? item.category : '',
|
|
181
|
+
added: typeof item.added === 'string' ? item.added : '',
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return entries
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Base display name without the monorepo subpath suffix. */
|
|
188
|
+
export function baseName(entry) {
|
|
189
|
+
const at = entry.name.indexOf('#')
|
|
190
|
+
return at === -1 ? entry.name : entry.name.slice(0, at)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Rank catalog entries for one query.
|
|
195
|
+
*
|
|
196
|
+
* Scoring prefers an exact display name, then a shared prefix, then tokens in
|
|
197
|
+
* the name/owner/description; star count only breaks ties. Same-name results
|
|
198
|
+
* are all returned so the caller can present the ambiguity instead of guessing.
|
|
199
|
+
*
|
|
200
|
+
* @param {CatalogEntry[]} entries
|
|
201
|
+
* @param {string} query
|
|
202
|
+
* @param {{limit?: number, installed?: Set<string>}} [options]
|
|
203
|
+
* @returns {{matches: Array<{entry: CatalogEntry, score: number, installed: boolean}>, ambiguous: boolean}}
|
|
204
|
+
*/
|
|
205
|
+
export function searchCatalog(entries, query, options = {}) {
|
|
206
|
+
const limit = Math.max(1, Math.min(options.limit ?? 8, 25))
|
|
207
|
+
const raw = String(query ?? '').trim().toLowerCase()
|
|
208
|
+
if (raw === '') return { matches: [], ambiguous: false }
|
|
209
|
+
const tokens = raw.split(/[\s/]+/).filter(Boolean)
|
|
210
|
+
const matches = []
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
const name = entry.name.toLowerCase()
|
|
213
|
+
const base = baseName(entry).toLowerCase()
|
|
214
|
+
const owner = entry.owner.toLowerCase()
|
|
215
|
+
const npmName = (entry.npm ?? '').toLowerCase()
|
|
216
|
+
const description = entry.description.toLowerCase()
|
|
217
|
+
let score = 0
|
|
218
|
+
if (base === raw) score = 1000
|
|
219
|
+
else if (name === raw) score = 990
|
|
220
|
+
else if (base.startsWith(raw)) score = 700
|
|
221
|
+
else if (name.startsWith(raw)) score = 690
|
|
222
|
+
else if (npmName !== '' && npmName === raw) score = 800
|
|
223
|
+
else if (name.includes(raw)) score = 400
|
|
224
|
+
else if (owner === raw) score = 350
|
|
225
|
+
else if (description.includes(raw)) score = 120
|
|
226
|
+
else {
|
|
227
|
+
let tokenHits = 0
|
|
228
|
+
for (const token of tokens) {
|
|
229
|
+
if (name.includes(token)) tokenHits += 1
|
|
230
|
+
else if (owner.includes(token)) tokenHits += 0.5
|
|
231
|
+
else if (description.includes(token)) tokenHits += 0.25
|
|
232
|
+
}
|
|
233
|
+
if (tokenHits === 0) continue
|
|
234
|
+
score = 200 + tokenHits * 20
|
|
235
|
+
}
|
|
236
|
+
score += Math.min(entry.stars, 10_000) / 100_000
|
|
237
|
+
if (entry.npm && options.installed?.has(entry.npm)) score += 0.5
|
|
238
|
+
matches.push({ entry, score, installed: Boolean(entry.npm && options.installed?.has(entry.npm)) })
|
|
239
|
+
}
|
|
240
|
+
matches.sort((a, b) => b.score - a.score || b.entry.stars - a.entry.stars)
|
|
241
|
+
const limited = matches.slice(0, limit)
|
|
242
|
+
const names = new Map()
|
|
243
|
+
for (const { entry } of limited) {
|
|
244
|
+
const key = baseName(entry).toLowerCase()
|
|
245
|
+
names.set(key, (names.get(key) ?? 0) + 1)
|
|
246
|
+
}
|
|
247
|
+
const ambiguous = [...names.values()].some((count) => count > 1)
|
|
248
|
+
return { matches: limited, ambiguous }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Extract `github:owner/repo#...` from a documented install command. */
|
|
252
|
+
export function githubSpecOf(entry) {
|
|
253
|
+
if (entry.install) {
|
|
254
|
+
const match = /github:[^\s"']+/.exec(entry.install)
|
|
255
|
+
if (match) return match[0]
|
|
256
|
+
}
|
|
257
|
+
const urlMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/tree\/[^/]+\/(.+))?/.exec(entry.url)
|
|
258
|
+
if (!urlMatch) return null
|
|
259
|
+
const subpath = urlMatch[3] ? `#path:/${urlMatch[3]}` : ''
|
|
260
|
+
return `github:${urlMatch[1]}/${urlMatch[2]}${subpath}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Extract the repository slug (`owner/repo`) for a catalog entry. */
|
|
264
|
+
export function repoSlugOf(entry) {
|
|
265
|
+
const match = /github\.com\/([^/]+)\/([^/]+)/.exec(entry.url ?? '')
|
|
266
|
+
if (!match) return null
|
|
267
|
+
return `${match[1]}/${match[2].replace(/\.git$/, '')}`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Repository checkout directory for a GitHub source build. */
|
|
271
|
+
export function sourceDirOf(repoSlug) {
|
|
272
|
+
const safe = repoSlug.replace(/[^A-Za-z0-9._-]+/g, '__')
|
|
273
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : ''
|
|
274
|
+
return path.join(home, 'plugin-sources', safe)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Whether a repository checkout already exists. */
|
|
278
|
+
export function hasSourceCheckout(dir) {
|
|
279
|
+
return dir !== '' && dirExists(dir)
|
|
280
|
+
}
|
|
281
|
+
|