dsh-plugin-syncup 0.0.0-stage → 4.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,447 @@
1
+ import { isRecord, parseVersion, compareVersions } from './spec.js'
2
+
3
+ const DEFAULT_NPM = 'https://registry.npmjs.org'
4
+ const GITHUB_API = 'https://api.github.com'
5
+ const MAX_CHECK = 40
6
+
7
+ function timeoutSignal(ms, upstream) {
8
+ const controller = new AbortController()
9
+ const timer = setTimeout(() => controller.abort(new Error('timeout')), ms)
10
+ const abort = () => controller.abort()
11
+ if (upstream !== undefined && upstream !== null) {
12
+ if (upstream.aborted) abort()
13
+ else upstream.addEventListener('abort', abort, { once: true })
14
+ }
15
+ return {
16
+ signal: controller.signal,
17
+ dispose: () => {
18
+ clearTimeout(timer)
19
+ if (upstream !== undefined && upstream !== null) upstream.removeEventListener?.('abort', abort)
20
+ },
21
+ }
22
+ }
23
+
24
+ /** A registry-backed spec: `name` or `name@range`, never a git/tarball/local source. */
25
+ export function registryNameOf(spec) {
26
+ if (typeof spec !== 'string') return null
27
+ const text = spec.trim()
28
+ if (text === '') return null
29
+ if (/^(?:github:|git\+|git@|https?:|file:|link:|npm:|workspace:|\.{1,2}[\\/])/i.test(text)) return null
30
+ const at = text.indexOf('@', text.startsWith('@') ? 1 : 0)
31
+ const name = at === -1 ? text : text.slice(0, at)
32
+ const range = at === -1 ? '' : text.slice(at + 1)
33
+ if (name === '' || /\s/.test(name)) return null
34
+ if (range !== '' && (/\s/.test(range) || range.includes(':'))) return null
35
+ return { name, range }
36
+ }
37
+
38
+ /** `github:owner/repo#ref` → `{ owner, repo, ref }`. */
39
+ export function gitHubTargetOf(spec) {
40
+ if (typeof spec !== 'string') return null
41
+ const text = spec.trim()
42
+ const match = /^github:([^/#\s]+)\/([^/#\s]+)(?:#([^\s]+))?$/i.exec(text)
43
+ ?? /^git\+(?:https?|ssh):\/\/(?:[^@/]+@)?github\.com\/([^/#\s]+)\/([^/#\s]+?)(?:\.git)?(?:#([^\s]+))?$/i.exec(text)
44
+ if (match === null) return null
45
+ return {
46
+ owner: match[1],
47
+ repo: match[2].replace(/\.git$/i, ''),
48
+ ref: match[3] ?? '',
49
+ }
50
+ }
51
+
52
+ function cleanVersion(value) {
53
+ if (typeof value !== 'string') return ''
54
+ const text = value.trim().replace(/^v/i, '')
55
+ return parseVersion(text) === null ? '' : text
56
+ }
57
+
58
+ /** A semver prerelease such as `5.18.0-beta.1` is not a stable release. */
59
+ export function isPrereleaseVersion(value) {
60
+ const parsed = parseVersion(typeof value === 'string' ? value.trim().replace(/^v/i, '') : value)
61
+ return parsed !== null && Array.isArray(parsed.prerelease) && parsed.prerelease.length > 0
62
+ }
63
+
64
+ /**
65
+ * A version carrying build metadata such as `2.0.0+win` or `1.0.0+linux-x64`.
66
+ *
67
+ * Semver treats `+…` as build metadata, so `2.0.0+win` compares equal to
68
+ * `2.0.0`. Repositories use it for per-platform builds, which are not the
69
+ * project's newest release and can be older than the current line — one real
70
+ * repo shipped `v2.0.0+win` a month before `v0.4.2`. They never win the
71
+ * newest-release pick.
72
+ */
73
+ export function isPlatformVersion(value) {
74
+ const text = typeof value === 'string' ? value.trim().replace(/^v/i, '') : ''
75
+ return text.includes('+')
76
+ }
77
+
78
+ /** The moment a release went public (or a tag was created), as epoch ms. */
79
+ function releaseTime(entry) {
80
+ for (const key of ['published_at', 'created_at', 'updated_at']) {
81
+ const value = entry?.[key]
82
+ if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return Date.parse(value)
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** Pick the newest version out of a list of tag names. */
88
+ export function newestTag(tags) {
89
+ let best = ''
90
+ for (const tag of Array.isArray(tags) ? tags : []) {
91
+ const version = cleanVersion(typeof tag === 'string' ? tag : tag?.name)
92
+ if (version === '' || !/^\d/.test(version)) continue
93
+ if (best === '' || compareVersions(version, best) > 0) best = version
94
+ }
95
+ return best
96
+ }
97
+
98
+ /** `git+https://github.com/owner/repo.git` (or a bare owner/repo) → GitHub target. */
99
+ export function gitHubTargetOfRepository(repository) {
100
+ const raw = typeof repository === 'string'
101
+ ? repository
102
+ : typeof repository === 'object' && repository !== null && typeof repository.url === 'string'
103
+ ? repository.url
104
+ : ''
105
+ const text = raw.trim()
106
+ if (text === '') return null
107
+ const shorthand = /^([\w.-]+)\/([\w.-]+)$/.exec(text)
108
+ if (shorthand !== null) return { owner: shorthand[1], repo: shorthand[2].replace(/\.git$/i, ''), ref: '' }
109
+ const match = /^(?:git\+)?(?:https?|ssh|git):\/\/(?:[^@/]+@)?github\.com\/([^/#\s]+)\/([^/#\s]+?)(?:\.git)?(?:#([^\s]+))?$/i.exec(text)
110
+ if (match === null) return null
111
+ return { owner: match[1], repo: match[2].replace(/\.git$/i, ''), ref: match[3] ?? '' }
112
+ }
113
+
114
+ export function createVersionChecker({
115
+ fetchImpl = globalThis.fetch,
116
+ npmBaseUrl = DEFAULT_NPM,
117
+ githubBaseUrl = GITHUB_API,
118
+ token = '',
119
+ timeoutMs = 15_000,
120
+ onReleaseFallback = null,
121
+ } = {}) {
122
+ async function getJson(url, headers, upstream) {
123
+ const { signal, dispose } = timeoutSignal(timeoutMs, upstream)
124
+ try {
125
+ const response = await fetchImpl(url, {
126
+ headers: { accept: 'application/json', 'user-agent': 'dsh-plugin-syncup/2.8.0', ...headers },
127
+ signal,
128
+ })
129
+ if (!response.ok) {
130
+ const hint = response.status === 404 ? 'not-found' : response.status === 401 || response.status === 403 ? 'auth' : 'http'
131
+ return { ok: false, code: hint, status: response.status }
132
+ }
133
+ return { ok: true, body: await response.json() }
134
+ } catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error)
136
+ return { ok: false, code: /abort/i.test(message) ? 'timeout' : 'network', message }
137
+ } finally {
138
+ dispose()
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Read the newest published version and, when the registry exposes it, the
144
+ * source repository.
145
+ *
146
+ * The compact `install-v1` document omits `repository`, so a package that ships
147
+ * newer git tags than npm releases cannot be judged from it. Full metadata is
148
+ * asked for instead; the version fields are identical in both documents.
149
+ */
150
+ async function npmLatest(name, upstream) {
151
+ const url = `${npmBaseUrl.replace(/\/+$/, '')}/${name.replace('/', '%2F')}`
152
+ const result = await getJson(url, { accept: 'application/json' }, upstream)
153
+ if (!result.ok) return { source: 'npm', name, latest: null, ...result }
154
+ const latest = cleanVersion(result.body?.['dist-tags']?.latest)
155
+ const repository = gitHubTargetOfRepository(
156
+ result.body?.repository ?? result.body?.versions?.[result.body?.['dist-tags']?.latest]?.repository,
157
+ )
158
+ if (latest === '') {
159
+ const versions = Object.keys(result.body?.versions ?? {})
160
+ const best = newestTag(versions)
161
+ return best === ''
162
+ ? { source: 'npm', name, latest: null, ok: false, code: 'no-version', ...(repository === null ? {} : { repository }) }
163
+ : { source: 'npm', name, latest: best, ok: true, ...(repository === null ? {} : { repository }) }
164
+ }
165
+ return {
166
+ source: 'npm',
167
+ name,
168
+ latest,
169
+ ok: true,
170
+ distTags: result.body?.['dist-tags'] ?? {},
171
+ ...(repository === null ? {} : { repository }),
172
+ }
173
+ }
174
+
175
+ function githubHeaders() {
176
+ const headers = { 'x-github-api-version': '2022-11-28' }
177
+ if (token !== '') headers.authorization = `Bearer ${token}`
178
+ return headers
179
+ }
180
+
181
+ /**
182
+ * Pick the newest stable version out of GitHub release rows.
183
+ *
184
+ * Two independent ideas of "newest" exist here and the caller chooses:
185
+ *
186
+ * - by version (`prefer: 'version'`, the default): highest semver.
187
+ * - by release date (`prefer: 'date'`): the release published last. A repo can
188
+ * publish a high-numbered build early and then continue a lower line, so the
189
+ * highest number is not the newest code — `v2.0.0+win` (2026-09) versus
190
+ * `v0.4.2` (2026-10) is exactly that case.
191
+ *
192
+ * Drafts are never installable. A version is treated as a prerelease when GitHub
193
+ * flags it or when the version string itself carries a prerelease suffix, so a
194
+ * mistimed flag cannot hide a real stable release and a `-beta` tag cannot slip
195
+ * through. Platform builds (`+win`) are always skipped.
196
+ */
197
+ function newestRelease(rows, { includePrerelease = false, prefer = 'version', includePlatform = false } = {}) {
198
+ let best = null
199
+ for (const entry of Array.isArray(rows) ? rows : []) {
200
+ if (entry?.draft === true) continue
201
+ const raw = typeof entry?.tag_name === 'string' ? entry.tag_name : ''
202
+ const version = cleanVersion(raw)
203
+ if (version === '' || !/^\d/.test(version)) continue
204
+ if (includePlatform === false && isPlatformVersion(raw)) continue
205
+ const flagged = entry?.prerelease === true
206
+ if (includePrerelease === false && (flagged || isPrereleaseVersion(version))) continue
207
+
208
+ const at = releaseTime(entry)
209
+ if (best === null) {
210
+ best = { latest: version, latestTag: raw === '' ? `v${version}` : raw, prerelease: flagged, publishedAt: at }
211
+ continue
212
+ }
213
+ // Date ordering needs timestamps on both sides. When either is missing
214
+ // (a tag list carries none), fall back to the version so it still resolves.
215
+ const newer = prefer === 'date' && at !== null && best.publishedAt !== null
216
+ ? at > best.publishedAt
217
+ : compareVersions(version, best.latest) > 0
218
+ if (newer) {
219
+ best = { latest: version, latestTag: raw === '' ? `v${version}` : raw, prerelease: flagged, publishedAt: at }
220
+ }
221
+ }
222
+ return best
223
+ }
224
+
225
+ /**
226
+ * Read a repository's newest installable version.
227
+ *
228
+ * A git tag is not a release, and the two lists routinely disagree: a repo can
229
+ * carry `v5.18.0` with no matching release, or platform tags like `v2.0.0+win`
230
+ * that are far ahead of the newest published release. Releases are therefore
231
+ * authoritative.
232
+ *
233
+ * The tag fallback runs only when the release list was fetched successfully and
234
+ * is genuinely empty. A failed request (rate limit, network, auth) must NOT
235
+ * look like "no releases" — that would silently downgrade the answer to a tag,
236
+ * which is exactly the bug this guards against. Such a failure is reported as an
237
+ * error and the caller keeps whatever the other source said.
238
+ */
239
+ async function gitHubLatestRef(target, upstream, { includePrerelease = false, prefer = 'date' } = {}) {
240
+ const repoUrl = `${githubBaseUrl}/repos/${target.owner}/${target.repo}`
241
+
242
+ const releases = await getJson(`${repoUrl}/releases?per_page=100`, githubHeaders(), upstream)
243
+ if (releases.ok && Array.isArray(releases.body)) {
244
+ const best = newestRelease(releases.body, { includePrerelease, prefer })
245
+ if (best !== null) {
246
+ const match = releases.body.find((item) => cleanVersion(item?.tag_name) === best.latest)
247
+ return {
248
+ source: 'release',
249
+ latest: best.latest,
250
+ latestTag: best.latestTag,
251
+ sha: typeof match?.target_commitish === 'string' && /^[0-9a-f]{40}$/i.test(match.target_commitish)
252
+ ? match.target_commitish
253
+ : null,
254
+ ok: true,
255
+ prerelease: best.prerelease,
256
+ ...(best.publishedAt === null
257
+ ? {}
258
+ : { publishedAt: new Date(best.publishedAt).toISOString() }),
259
+ }
260
+ }
261
+ // The release list was read and holds no stable release. Only now may a
262
+ // tag stand in for it.
263
+ if (releases.body.length > 0 && typeof onReleaseFallback === 'function') {
264
+ onReleaseFallback(target, { reason: 'no-stable-release' })
265
+ }
266
+ } else if (releases.ok === false) {
267
+ // Do not silently fall back to tags on a failed read.
268
+ return {
269
+ source: 'github',
270
+ latest: null,
271
+ ok: false,
272
+ code: releases.code ?? 'release-read-failed',
273
+ ...(releases.status === undefined ? {} : { status: releases.status }),
274
+ }
275
+ }
276
+
277
+ // No stable release exists: a tagged commit is still better than nothing.
278
+ const tags = await getJson(`${repoUrl}/tags?per_page=100`, githubHeaders(), upstream)
279
+ if (tags.ok && Array.isArray(tags.body)) {
280
+ const rows = tags.body.map((item) => ({ tag_name: item?.name, prerelease: false }))
281
+ const best = newestRelease(rows, { includePrerelease, prefer })
282
+ if (best !== null) {
283
+ const match = tags.body.find((item) => cleanVersion(item?.name) === best.latest)
284
+ return {
285
+ source: 'tag',
286
+ latest: best.latest,
287
+ latestTag: best.latestTag,
288
+ sha: typeof match?.commit?.sha === 'string' ? match.commit.sha : null,
289
+ ok: true,
290
+ }
291
+ }
292
+ }
293
+
294
+ // Nothing tag-shaped: report the default-branch head rather than guessing.
295
+ const repo = await getJson(repoUrl, githubHeaders(), upstream)
296
+ if (!repo.ok) return { source: 'github', latest: null, ...repo }
297
+ const branch = typeof repo.body?.default_branch === 'string' ? repo.body.default_branch : 'main'
298
+ const head = await getJson(`${repoUrl}/commits/${encodeURIComponent(branch)}`, githubHeaders(), upstream)
299
+ if (!head.ok) return { source: 'github', latest: null, ...head }
300
+ const sha = typeof head.body?.sha === 'string' ? head.body.sha : null
301
+ return sha === null
302
+ ? { source: 'github', latest: null, ok: false, code: 'no-version' }
303
+ : { source: 'github', latest: sha.slice(0, 12), sha, branch, ok: true }
304
+ }
305
+
306
+ /**
307
+ * Ask both package channels what their newest installable version is.
308
+ *
309
+ * The automatic answer favors the newer of npm and the repository's newest
310
+ * **release** (a tie stays on npm, which installs more reliably). That guess is
311
+ * not always what the user wants — one channel can lead the other, and a
312
+ * platform build can be newer than either — so each channel's own answer is
313
+ * reported as `channels` for the page to offer as an explicit choice.
314
+ */
315
+ async function latestFor(spec, { upstream } = {}) {
316
+ const npm = registryNameOf(spec)
317
+ if (npm !== null) {
318
+ const published = await npmLatest(npm.name, upstream)
319
+ const repo = published.repository ?? null
320
+ if (repo === null) {
321
+ return {
322
+ ...published,
323
+ spec: spec.trim(),
324
+ kind: 'npm',
325
+ channels: { npm: published.latest ?? null, github: null },
326
+ }
327
+ }
328
+
329
+ const fromGitHub = await gitHubLatestRef(repo, upstream)
330
+ const npmVersion = cleanVersion(published.latest)
331
+ const gitVersion = cleanVersion(fromGitHub.latest)
332
+ const channels = { npm: published.latest ?? null, github: fromGitHub.latest ?? null }
333
+ const githubInfo = {
334
+ githubLatest: fromGitHub.latest,
335
+ githubSource: fromGitHub.source,
336
+ ...(fromGitHub.latestTag === undefined ? {} : { latestTag: fromGitHub.latestTag }),
337
+ ...(fromGitHub.publishedAt === undefined ? {} : { githubPublishedAt: fromGitHub.publishedAt }),
338
+ }
339
+
340
+ // Both sides comparable: only a strictly newer GitHub version wins, so a
341
+ // tie stays on npm.
342
+ if (npmVersion !== '' && gitVersion !== '') {
343
+ const gitNewer = compareVersions(gitVersion, npmVersion) > 0
344
+ return {
345
+ ...published,
346
+ spec: spec.trim(),
347
+ kind: 'npm',
348
+ source: gitNewer ? fromGitHub.source : 'npm',
349
+ latest: gitNewer ? fromGitHub.latest : published.latest,
350
+ npmLatest: published.latest,
351
+ channels,
352
+ ...githubInfo,
353
+ ...(gitNewer && fromGitHub.sha ? { sha: fromGitHub.sha } : {}),
354
+ ...(gitNewer && fromGitHub.publishedAt !== undefined ? { publishedAt: fromGitHub.publishedAt } : {}),
355
+ ok: true,
356
+ }
357
+ }
358
+ // The repository answer is only a fallback when npm gave nothing usable.
359
+ if (npmVersion === '' && gitVersion !== '') {
360
+ return {
361
+ ...published,
362
+ spec: spec.trim(),
363
+ kind: 'npm',
364
+ source: fromGitHub.source,
365
+ latest: fromGitHub.latest,
366
+ npmLatest: published.latest,
367
+ channels,
368
+ ...githubInfo,
369
+ ...(fromGitHub.sha ? { sha: fromGitHub.sha } : {}),
370
+ ...(fromGitHub.publishedAt === undefined ? {} : { publishedAt: fromGitHub.publishedAt }),
371
+ ok: true,
372
+ }
373
+ }
374
+ return {
375
+ ...published,
376
+ spec: spec.trim(),
377
+ kind: 'npm',
378
+ channels,
379
+ ...(gitVersion === '' ? {} : githubInfo),
380
+ }
381
+ }
382
+ const git = gitHubTargetOf(spec)
383
+ if (git !== null) {
384
+ const found = await gitHubLatestRef(git, upstream)
385
+ return {
386
+ ...found,
387
+ spec: spec.trim(),
388
+ kind: 'github',
389
+ ref: git.ref,
390
+ channels: { npm: null, github: found.latest ?? null },
391
+ }
392
+ }
393
+ return {
394
+ source: null,
395
+ spec: typeof spec === 'string' ? spec.trim() : '',
396
+ kind: 'other',
397
+ latest: null,
398
+ channels: { npm: null, github: null },
399
+ ok: false,
400
+ code: 'unsupported',
401
+ }
402
+ }
403
+
404
+ async function checkAll(entries, { upstream } = {}) {
405
+ const items = Array.isArray(entries) ? entries.slice(0, MAX_CHECK) : []
406
+ const results = []
407
+ for (const item of items) {
408
+ if (upstream?.aborted === true) break
409
+ const found = await latestFor(item.spec, { upstream })
410
+ const current = cleanVersion(item.version)
411
+ const latestVersion = cleanVersion(found.latest)
412
+ const comparable = current !== '' && latestVersion !== ''
413
+ results.push({
414
+ name: item.name,
415
+ spec: found.spec,
416
+ kind: found.kind,
417
+ current,
418
+ latest: found.latest,
419
+ // Per-source versions and the winning source must survive this mapping:
420
+ // the page needs them to offer npm and GitHub as separate choices, and
421
+ // without them every channel option was disabled.
422
+ ...(found.source === undefined ? {} : { source: found.source }),
423
+ ...(found.channels === undefined ? {} : { channels: found.channels }),
424
+ ...(found.repository === undefined ? {} : { repository: found.repository }),
425
+ ...(found.publishedAt === undefined ? {} : { publishedAt: found.publishedAt }),
426
+ ...(found.npmLatest === undefined ? {} : { npmLatest: found.npmLatest }),
427
+ ...(found.githubLatest === undefined ? {} : { githubLatest: found.githubLatest }),
428
+ ...(found.latestTag === undefined ? {} : { latestTag: found.latestTag }),
429
+ ...(found.sha === undefined ? {} : { sha: found.sha }),
430
+ ...(found.ok === false ? { error: found.code ?? 'lookup-failed' } : {}),
431
+ ...(comparable
432
+ ? { outdated: compareVersions(latestVersion, current) > 0 }
433
+ : found.sha !== undefined && typeof item.version === 'string' && found.sha !== null
434
+ ? { outdated: !found.sha.startsWith(item.version) && item.version.length < 40 }
435
+ : {}),
436
+ ...(found.distTags === undefined ? {} : { distTags: found.distTags }),
437
+ })
438
+ }
439
+ return results
440
+ }
441
+
442
+ return { latestFor, checkAll }
443
+ }
444
+
445
+ export function isRegistrySpec(spec) {
446
+ return isRecord({ spec }) && registryNameOf(spec) !== null
447
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Plugin Sync",
4
+ "description": "Configure a secret GitHub Gist (default name dsh-plugin-sync) in the plugin page, pull it manually, review conflicts, then install only what you select."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "插件同步",
4
+ "description": "在插件页配置私有 GitHub Gist(默认名称 dsh-plugin-sync),手动拉取、检查冲突,并只安装你确认的插件。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "dsh-plugin-syncup",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "4.1.0",
4
+ "description": "Sync plugin inventories through a secret GitHub Gist and update every plugin to its newest release, with conflict-aware preview and an install log.",
5
+ "type": "module",
6
+ "icon": "icon.svg",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "client.js",
17
+ "lib/*.js",
18
+ "cordis.patch.yml",
19
+ "locale/*.json",
20
+ "icon.svg",
21
+ "README.md",
22
+ "README.en.md",
23
+ "LICENSE"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web"
31
+ }
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "keywords": [
37
+ "deepseek-harness",
38
+ "dsh",
39
+ "plugin",
40
+ "sync",
41
+ "github"
42
+ ],
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/jijijitian/dsh-plugin-syncup.git"
47
+ },
48
+ "homepage": "https://github.com/jijijitian/dsh-plugin-syncup",
49
+ "bugs": {
50
+ "url": "https://github.com/jijijitian/dsh-plugin-syncup/issues"
51
+ },
52
+ "scripts": {
53
+ "test": "node --test"
54
+ }
6
55
  }