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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The slice of semver this plugin needs: parse versions, compare them, and
3
+ * decide whether a version satisfies a range.
4
+ *
5
+ * Semantics intentionally match the Host's own compatibility preflight, which
6
+ * compares with `includePrerelease: true`: prerelease versions are ordinary
7
+ * values, so `0.2.0-rc.2` satisfies `^0.2.0-rc.2` but not `^0.2.1-rc.1`.
8
+ *
9
+ * @module dsh-plugin-resolver/lib/semver
10
+ */
11
+
12
+ const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
13
+
14
+ /**
15
+ * Parse a full semver version.
16
+ *
17
+ * @param {unknown} value
18
+ * @returns {{major: number, minor: number, patch: number, pre: string[]} | null}
19
+ */
20
+ export function parseVersion(value) {
21
+ const match = VERSION_RE.exec(String(value ?? '').trim())
22
+ if (!match) return null
23
+ return {
24
+ major: Number(match[1]),
25
+ minor: Number(match[2]),
26
+ patch: Number(match[3]),
27
+ pre: match[4] ? match[4].split('.') : [],
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Compare two parsed versions.
33
+ *
34
+ * @param {{major: number, minor: number, patch: number, pre: string[]}} left
35
+ * @param {{major: number, minor: number, patch: number, pre: string[]}} right
36
+ * @returns {number} -1, 0, or 1
37
+ */
38
+ export function compareParsed(left, right) {
39
+ for (const key of ['major', 'minor', 'patch']) {
40
+ if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1
41
+ }
42
+ if (left.pre.length === 0 && right.pre.length === 0) return 0
43
+ if (left.pre.length === 0) return 1
44
+ if (right.pre.length === 0) return -1
45
+ const length = Math.max(left.pre.length, right.pre.length)
46
+ for (let index = 0; index < length; index += 1) {
47
+ const a = left.pre[index]
48
+ const b = right.pre[index]
49
+ if (a === undefined) return -1
50
+ if (b === undefined) return 1
51
+ const aNumeric = /^\d+$/.test(a)
52
+ const bNumeric = /^\d+$/.test(b)
53
+ if (aNumeric && bNumeric) {
54
+ const delta = Number(a) - Number(b)
55
+ if (delta !== 0) return delta < 0 ? -1 : 1
56
+ } else if (aNumeric) return -1
57
+ else if (bNumeric) return 1
58
+ else if (a !== b) return a < b ? -1 : 1
59
+ }
60
+ return 0
61
+ }
62
+
63
+ /**
64
+ * Compare two version strings.
65
+ *
66
+ * @param {string} left
67
+ * @param {string} right
68
+ * @returns {number}
69
+ */
70
+ export function compareVersions(left, right) {
71
+ const a = parseVersion(left)
72
+ const b = parseVersion(right)
73
+ if (!a || !b) return 0
74
+ return compareParsed(a, b)
75
+ }
76
+
77
+ /**
78
+ * Parse a possibly partial version such as `1`, `1.2`, `^1.2.3-rc.1`.
79
+ *
80
+ * @param {string} raw
81
+ * @returns {{major: number, minor: number, patch: number, pre: string[], minorGiven: boolean, patchGiven: boolean} | null}
82
+ */
83
+ function parsePartial(raw) {
84
+ const text = raw.trim().replace(/^v/, '')
85
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text)
86
+ if (!match) return null
87
+ return {
88
+ major: Number(match[1]),
89
+ minor: match[2] === undefined ? 0 : Number(match[2]),
90
+ patch: match[3] === undefined ? 0 : Number(match[3]),
91
+ pre: match[4] ? match[4].split('.') : [],
92
+ minorGiven: match[2] !== undefined,
93
+ patchGiven: match[3] !== undefined,
94
+ }
95
+ }
96
+
97
+ const LOWER = { major: 0, minor: 0, patch: 0, pre: [] }
98
+
99
+ /** Full parse of a version-like token, or null when it is a wildcard. */
100
+ function asVersion(partial) {
101
+ if (!partial) return null
102
+ return { major: partial.major, minor: partial.minor, patch: partial.patch, pre: partial.pre }
103
+ }
104
+
105
+ /**
106
+ * Upper bound of a caret range. The `-0` prerelease mirrors node-semver's
107
+ * `includePrerelease: true` desugaring: it excludes prereleases of the next
108
+ * minor/major, which is exactly the trap that silently disables a plugin whose
109
+ * caret range was written against the previous support line.
110
+ */
111
+ function caretUpper(partial) {
112
+ if (partial.major > 0 || !partial.minorGiven) {
113
+ return { major: partial.major + 1, minor: 0, patch: 0, pre: ['0'] }
114
+ }
115
+ if (partial.minor > 0 || !partial.patchGiven) {
116
+ return { major: 0, minor: partial.minor + 1, patch: 0, pre: ['0'] }
117
+ }
118
+ return { major: 0, minor: 0, patch: partial.patch + 1, pre: ['0'] }
119
+ }
120
+
121
+ /** Upper bound of a tilde range, with the same prerelease ceiling. */
122
+ function tildeUpper(partial) {
123
+ return partial.minorGiven
124
+ ? { major: partial.major, minor: partial.minor + 1, patch: 0, pre: ['0'] }
125
+ : { major: partial.major + 1, minor: 0, patch: 0, pre: ['0'] }
126
+ }
127
+
128
+ /** Upper bound of a bare partial range: `1.2` means `>=1.2.0 <1.3.0`. */
129
+ function partialUpper(partial) {
130
+ if (partial.minorGiven && partial.patchGiven) return null
131
+ return partial.minorGiven
132
+ ? { major: partial.major, minor: partial.minor + 1, patch: 0, pre: ['0'] }
133
+ : { major: partial.major + 1, minor: 0, patch: 0, pre: ['0'] }
134
+ }
135
+
136
+ /**
137
+ * Whether one whitespace-separated comparator set matches.
138
+ *
139
+ * @param {{major: number, minor: number, patch: number, pre: string[]}} version
140
+ * @param {string} set
141
+ * @returns {boolean}
142
+ */
143
+ function matchesSet(version, set) {
144
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(set.trim())
145
+ if (hyphen) {
146
+ const lower = asVersion(parsePartial(hyphen[1]))
147
+ const upperPartial = parsePartial(hyphen[2])
148
+ const upper = asVersion(upperPartial)
149
+ if (!lower || !upper) return false
150
+ return compareParsed(version, lower) >= 0 && compareParsed(version, upper) <= 0
151
+ }
152
+ const tokens = set.trim().split(/\s+/).filter(Boolean)
153
+ if (tokens.length === 0) return true
154
+ for (const token of tokens) {
155
+ if (token === '*' || token === 'x' || token === 'X') continue
156
+ const operatorMatch = /^(\^|~|>=|<=|>|<|=)?\s*(.*)$/.exec(token)
157
+ if (!operatorMatch) return false
158
+ const operator = operatorMatch[1] ?? ''
159
+ const raw = operatorMatch[2]
160
+ if (raw === '' || raw === '*' || raw === 'x' || raw === 'X') continue
161
+ const partial = parsePartial(raw)
162
+ if (!partial) return false
163
+ const point = asVersion(partial)
164
+ if (operator === '^') {
165
+ if (compareParsed(version, point) < 0) return false
166
+ if (compareParsed(version, caretUpper(partial)) >= 0) return false
167
+ } else if (operator === '~') {
168
+ if (compareParsed(version, point) < 0) return false
169
+ if (compareParsed(version, tildeUpper(partial)) >= 0) return false
170
+ } else if (operator === '>=') {
171
+ if (compareParsed(version, point) < 0) return false
172
+ } else if (operator === '>') {
173
+ if (compareParsed(version, point) <= 0) return false
174
+ } else if (operator === '<=') {
175
+ if (compareParsed(version, point) > 0) return false
176
+ } else if (operator === '<') {
177
+ if (compareParsed(version, point) >= 0) return false
178
+ } else if (operator === '=') {
179
+ if (compareParsed(version, point) !== 0) return false
180
+ } else {
181
+ // Bare token: an exact version, or a partial range such as `1.2`.
182
+ const upper = partialUpper(partial)
183
+ if (upper === null) {
184
+ if (compareParsed(version, point) !== 0) return false
185
+ } else {
186
+ if (compareParsed(version, point) < 0) return false
187
+ if (compareParsed(version, upper) >= 0) return false
188
+ }
189
+ }
190
+ }
191
+ return true
192
+ }
193
+
194
+ /**
195
+ * Whether `version` satisfies `range`.
196
+ *
197
+ * @param {string} version
198
+ * @param {string} range
199
+ * @returns {boolean}
200
+ */
201
+ export function satisfies(version, range) {
202
+ const parsed = parseVersion(version)
203
+ if (!parsed) return false
204
+ const text = String(range ?? '').trim()
205
+ if (text === '' || text === '*' || text === 'x') return true
206
+ for (const alternative of text.split('||')) {
207
+ if (matchesSet(parsed, alternative.trim())) return true
208
+ }
209
+ return false
210
+ }
211
+
212
+ /**
213
+ * Sort version strings newest first.
214
+ *
215
+ * @param {string[]} versions
216
+ * @returns {string[]}
217
+ */
218
+ export function sortVersionsDesc(versions) {
219
+ return [...versions].sort((a, b) => compareVersions(b, a))
220
+ }
221
+
222
+ /** Lowest version above another, used only for error messages. */
223
+ export function isValidVersion(value) {
224
+ return parseVersion(value) !== null
225
+ }
226
+
227
+ export { LOWER }
package/logic/util.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Small host-side utilities: DSH home discovery, cached JSON, HTTP JSON, and a
3
+ * Windows-aware child-process runner. No host package imports on purpose.
4
+ *
5
+ * @module dsh-plugin-resolver/lib/util
6
+ */
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import { existsSync } from 'node:fs'
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
11
+ import { homedir } from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ /** Absolute DSH home for the running host. */
15
+ export function dshHome() {
16
+ return process.env.DSH_HOME && process.env.DSH_HOME !== ''
17
+ ? process.env.DSH_HOME
18
+ : path.join(homedir(), '.dsh')
19
+ }
20
+
21
+ /** Cache directory owned by this plugin. */
22
+ export function cacheDir() {
23
+ return path.join(dshHome(), 'cache', 'plugin-resolver')
24
+ }
25
+
26
+ /**
27
+ * Read a JSON file, returning undefined when absent or unparsable.
28
+ *
29
+ * @param {string} file
30
+ * @returns {Promise<any>}
31
+ */
32
+ export async function readJson(file) {
33
+ try {
34
+ return JSON.parse(await readFile(file, 'utf8'))
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Write a JSON file, creating parent directories.
42
+ *
43
+ * @param {string} file
44
+ * @param {unknown} value
45
+ * @returns {Promise<void>}
46
+ */
47
+ export async function writeJson(file, value) {
48
+ await mkdir(path.dirname(file), { recursive: true })
49
+ await writeFile(file, JSON.stringify(value), 'utf8')
50
+ }
51
+
52
+ /**
53
+ * Fetch and parse JSON with a hard timeout.
54
+ *
55
+ * @param {string} url
56
+ * @param {{timeoutMs?: number, headers?: Record<string, string>}} [options]
57
+ * @returns {Promise<any>}
58
+ */
59
+ export async function fetchJson(url, options = {}) {
60
+ const timeoutMs = options.timeoutMs ?? 30_000
61
+ const controller = new AbortController()
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
63
+ try {
64
+ const response = await fetch(url, {
65
+ signal: controller.signal,
66
+ headers: { accept: 'application/json', 'user-agent': 'dsh-plugin-resolver', ...(options.headers ?? {}) },
67
+ })
68
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
69
+ return await response.json()
70
+ } finally {
71
+ clearTimeout(timer)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Fetch raw bytes with a hard timeout.
77
+ *
78
+ * @param {string} url
79
+ * @param {{timeoutMs?: number}} [options]
80
+ * @returns {Promise<Buffer>}
81
+ */
82
+ export async function fetchBuffer(url, options = {}) {
83
+ const timeoutMs = options.timeoutMs ?? 60_000
84
+ const controller = new AbortController()
85
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
86
+ try {
87
+ const response = await fetch(url, {
88
+ signal: controller.signal,
89
+ redirect: 'follow',
90
+ headers: { 'user-agent': 'dsh-plugin-resolver' },
91
+ })
92
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
93
+ return Buffer.from(await response.arrayBuffer())
94
+ } finally {
95
+ clearTimeout(timer)
96
+ }
97
+ }
98
+
99
+ /** Trim a one-line description for compact tool output. */
100
+ export function oneLine(text, limit = 120) {
101
+ if (typeof text !== 'string') return ''
102
+ const flat = text.replace(/\s+/g, ' ').trim()
103
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`
104
+ }
105
+
106
+ /** Format a star count compactly. */
107
+ export function stars(value) {
108
+ const n = Number(value)
109
+ if (!Number.isFinite(n) || n <= 0) return 'new'
110
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
111
+ }
112
+
113
+ /**
114
+ * Run a child process and collect its output.
115
+ *
116
+ * `.cmd`/`.bat` shims on Windows go through `cmd.exe`, because Node cannot
117
+ * spawn them directly; every other executable is spawned without a shell so
118
+ * arguments keep their exact boundaries.
119
+ *
120
+ * @param {string} command
121
+ * @param {string[]} args
122
+ * @param {{cwd?: string, env?: Record<string, string>, timeoutMs?: number, onLine?: (line: string) => void}} [options]
123
+ * @returns {Promise<{code: number, output: string, timedOut: boolean}>}
124
+ */
125
+ export function runProcess(command, args, options = {}) {
126
+ const isShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)
127
+ const file = isShim ? process.env.COMSPEC || 'cmd.exe' : command
128
+ const argv = isShim
129
+ ? ['/d', '/s', '/c', quoteCommandLine([command, ...args])]
130
+ : args
131
+ return new Promise((resolve) => {
132
+ let output = ''
133
+ let settled = false
134
+ let timedOut = false
135
+ const child = spawn(file, argv, {
136
+ cwd: options.cwd,
137
+ env: { ...process.env, ...(options.env ?? {}) },
138
+ windowsHide: true,
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ })
141
+ const timer = options.timeoutMs
142
+ ? setTimeout(() => {
143
+ timedOut = true
144
+ child.kill()
145
+ }, options.timeoutMs)
146
+ : undefined
147
+ const append = (chunk) => {
148
+ const text = String(chunk)
149
+ output += text
150
+ if (options.onLine) {
151
+ for (const line of text.split(/\r?\n/)) {
152
+ if (line.trim() !== '') options.onLine(line.trimEnd())
153
+ }
154
+ }
155
+ if (output.length > 400_000) output = output.slice(-200_000)
156
+ }
157
+ child.stdout?.on('data', append)
158
+ child.stderr?.on('data', append)
159
+ child.on('error', (error) => {
160
+ if (settled) return
161
+ settled = true
162
+ if (timer) clearTimeout(timer)
163
+ resolve({ code: 127, output: `${output}\n${error.message}`.trim(), timedOut })
164
+ })
165
+ child.on('close', (code) => {
166
+ if (settled) return
167
+ settled = true
168
+ if (timer) clearTimeout(timer)
169
+ resolve({ code: code ?? 1, output, timedOut })
170
+ })
171
+ })
172
+ }
173
+
174
+ /** Quote an argv for the Windows `cmd.exe /s /c` fallback. */
175
+ function quoteCommandLine(argv) {
176
+ return argv
177
+ .map((part) => {
178
+ const value = String(part)
179
+ if (value === '') return '""'
180
+ if (!/[\s"&|<>^]/.test(value)) return value
181
+ return `"${value.replace(/"/g, '""')}"`
182
+ })
183
+ .join(' ')
184
+ }
185
+
186
+ /** True when a directory exists. */
187
+ export function dirExists(target) {
188
+ try {
189
+ return existsSync(target)
190
+ } catch {
191
+ return false
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "dsh-plugin-resolver",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Agent-facing DSH plugin resolver: search the catalog, pick a version the running DSH accepts, install (registry or GitHub-source build), and verify - no trial and error.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "host",
14
+ "logic",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "BENCHMARK.md",
18
+ "ABLATION.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node test/selftest.mjs && node test/harness.mjs",
22
+ "bench": "node test/benchmark.mjs",
23
+ "ablate": "node test/ablation.mjs"
24
+ },
25
+ "engines": {
26
+ "node": ">=20",
27
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
28
+ },
29
+ "keywords": [
30
+ "deepseek",
31
+ "harness",
32
+ "dsh",
33
+ "dsh-plugin",
34
+ "plugin-manager",
35
+ "plugin-installer",
36
+ "resolver",
37
+ "catalog"
38
+ ],
39
+ "author": "sakikoTGW",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/sakikoTGW/dsh-plugin-resolver.git"
44
+ },
45
+ "homepage": "https://github.com/sakikoTGW/dsh-plugin-resolver#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/sakikoTGW/dsh-plugin-resolver/issues"
48
+ },
49
+ "publishConfig": {
50
+ "registry": "https://registry.npmjs.org/"
51
+ },
52
+ "dsh": {
53
+ "bundle": {
54
+ "patch": "./cordis.patch.yml"
55
+ }
56
+ }
57
+ }