dsh-plugin-update-checker 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +118 -2
- package/README.zh-CN.md +113 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +429 -0
- package/lib/host/dsh.js +158 -0
- package/lib/host/profile.js +261 -0
- package/lib/host/registry.js +195 -0
- package/lib/host/runtime.js +143 -0
- package/lib/host/scan.js +123 -0
- package/lib/host/update-checker.js +90 -0
- package/lib/index.js +23 -0
- package/package.json +62 -4
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-update-checker — host/profile.js
|
|
3
|
+
*
|
|
4
|
+
* 由 lib/host/update-checker.js 按下述职责拆出(函数体逐字保留):
|
|
5
|
+
* profileDirFromArgv, activeProfileDir, readManifest, readManifestSync, findBundleDir, KNOWN_REPOS, resolveSourceLink, gitWebUrl, describeBundle, listBundles
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
8
|
+
import { readFileSync, realpathSync, existsSync } from 'node:fs'
|
|
9
|
+
import { execFileSync } from 'node:child_process'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { basename, dirname, join } from 'node:path'
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url)
|
|
15
|
+
|
|
16
|
+
function profileDirFromArgv() {
|
|
17
|
+
for (const arg of process.argv.slice(2)) {
|
|
18
|
+
if (!arg.startsWith('/') || arg.startsWith('-')) continue
|
|
19
|
+
if (!existsSync(join(arg, 'package.json'))) continue
|
|
20
|
+
// The parent directory must be named `profiles`: that is the only shape
|
|
21
|
+
// DSH itself creates (~/.dsh/profiles/<name> or $DSH_HOME/profiles/<name>).
|
|
22
|
+
if (basename(dirname(arg)) !== 'profiles') continue
|
|
23
|
+
return arg
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolve the active profile directory: argv first, then env, then home+name. */
|
|
29
|
+
export function activeProfileDir() {
|
|
30
|
+
const fromArgv = profileDirFromArgv()
|
|
31
|
+
if (fromArgv) return fromArgv
|
|
32
|
+
|
|
33
|
+
// DSH_PROFILE_DIR is an absolute path already; some hosts export it instead
|
|
34
|
+
// of (or alongside) DSH_PROFILE. Trust it when it really is a profile.
|
|
35
|
+
const fromEnvDir = process.env.DSH_PROFILE_DIR?.trim()
|
|
36
|
+
if (fromEnvDir && existsSync(join(fromEnvDir, 'package.json'))) return fromEnvDir
|
|
37
|
+
|
|
38
|
+
const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '') ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
39
|
+
// A DSH_PROFILE that names a real directory always wins; only when it is
|
|
40
|
+
// absent (or names something that does not exist) do we fall back. Note we
|
|
41
|
+
// no longer default to 'web' silently: if DSH_PROFILE is set but wrong we
|
|
42
|
+
// still prefer it, so an explicit operator choice is never overridden.
|
|
43
|
+
if (process.env.DSH_PROFILE) return join(home, 'profiles', process.env.DSH_PROFILE)
|
|
44
|
+
return join(home, 'profiles', 'web')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read + parse a package.json; returns undefined on any read/parse failure. */
|
|
48
|
+
export async function readManifest(manifestPath) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Locate a bundle's installed directory: profile node_modules, then shared tree. */
|
|
57
|
+
function readManifestSync(manifestPath) {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a single bundle descriptor for a name known to be a bundle.
|
|
67
|
+
*
|
|
68
|
+
* Resolves the installed directory via findBundleDir (which follows symlinks,
|
|
69
|
+
* so link:-installed plugins resolve correctly), and pulls version + registry
|
|
70
|
+
* from the manifest. This never enumerates dependency directories (semver, zod,
|
|
71
|
+
* ...) because the names come exclusively from the profile's dsh.profile.bundles
|
|
72
|
+
* (the launcher's authoritative bundle list).
|
|
73
|
+
*
|
|
74
|
+
* The resolved directory and parsed manifest travel with the descriptor so
|
|
75
|
+
* scanBundle does not resolve and re-read the same package.json a second time
|
|
76
|
+
* (it used to: one findBundleDir + one readManifestSync here, then another of
|
|
77
|
+
* each there, for every bundle).
|
|
78
|
+
*/
|
|
79
|
+
export function findBundleDir(name, profileDir) {
|
|
80
|
+
const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '') ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
81
|
+
// Two trees hold a bundle: the profile's own node_modules, then the shared
|
|
82
|
+
// profiles/node_modules. For an unscoped name the package directory IS the
|
|
83
|
+
// entry, so the old "scoped ? scopeDir : leaf" second candidate was a
|
|
84
|
+
// duplicate of the first (two wasted stat calls per bundle); for a scoped
|
|
85
|
+
// name it pointed at the bare scope directory (@local), which is not a
|
|
86
|
+
// package and could shadow the real one if it ever held a package.json.
|
|
87
|
+
const candidates = [
|
|
88
|
+
join(profileDir, 'node_modules', name),
|
|
89
|
+
join(home, 'profiles', 'node_modules', name),
|
|
90
|
+
]
|
|
91
|
+
for (const dir of candidates) {
|
|
92
|
+
if (existsSync(join(dir, 'package.json'))) return dir
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return dirname(require.resolve(`${name}/package.json`))
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Known source repos for link:-installed bundles, keyed by package name.
|
|
103
|
+
*
|
|
104
|
+
* A link:-installed bundle publishes nothing to a registry and may not carry a
|
|
105
|
+
* `repository` field or live in a git checkout, so the scanner cannot always
|
|
106
|
+
* discover its source URL. Declare them here (or via the plugin config's
|
|
107
|
+
* `repositories` map) as bare URLs like "https://github.com/<org>/<repo>"; the
|
|
108
|
+
* scanner appends "/tree/<ref>/<subpath>" when the local path is inside the repo.
|
|
109
|
+
*/
|
|
110
|
+
export const KNOWN_REPOS = {
|
|
111
|
+
"dsh-minimal-ui-panels": "https://github.com/windrover/dsh-minimal-UI-panels",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a link:-installed bundle's real source directory and its git origin.
|
|
116
|
+
*
|
|
117
|
+
* A link:-installed bundle is a symlink into the developer's source tree, so it
|
|
118
|
+
* publishes nothing to a registry and installBundle must never replace it. We
|
|
119
|
+
* therefore skip the registry and instead point the user at the source: the real
|
|
120
|
+
* path (git worktree) and the origin remote (for a one-click GitHub link). When
|
|
121
|
+
* the local checkout has no git origin and no declared repo, `repoUrl` stays null
|
|
122
|
+
* and the UI falls back to showing just the local path.
|
|
123
|
+
*
|
|
124
|
+
* @returns {{ dir: string, repoUrl: string|null, ref: string, subpath: string|null }}
|
|
125
|
+
* repoUrl is the origin's web URL (GitHub/GitLab/Codeberg) when resolvable,
|
|
126
|
+
* subpath is the path inside the repo the bundle lives at (for monorepos),
|
|
127
|
+
* ref is the current branch/HEAD (used as the tree ref in the link).
|
|
128
|
+
*/
|
|
129
|
+
export function resolveSourceLink(dir, declaredRepo) {
|
|
130
|
+
let real = dir
|
|
131
|
+
try { real = realpathSync(dir) } catch { /* keep dir */ }
|
|
132
|
+
let repo = real
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (existsSync(join(repo, '.git'))) break
|
|
135
|
+
const parent = dirname(repo)
|
|
136
|
+
if (parent === repo) { repo = null; break }
|
|
137
|
+
repo = parent
|
|
138
|
+
}
|
|
139
|
+
let repoUrl = declaredRepo ? gitWebUrl(declaredRepo) : null
|
|
140
|
+
let ref = 'main'
|
|
141
|
+
if (repo) {
|
|
142
|
+
try {
|
|
143
|
+
const out = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repo, encoding: 'utf8' }).trim()
|
|
144
|
+
if (out) repoUrl = repoUrl ?? gitWebUrl(out)
|
|
145
|
+
} catch { /* no origin */ }
|
|
146
|
+
try {
|
|
147
|
+
const head = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim()
|
|
148
|
+
if (head && head !== 'HEAD') ref = head
|
|
149
|
+
} catch { /* detached */ }
|
|
150
|
+
}
|
|
151
|
+
let subpath = null
|
|
152
|
+
if (repo && real.startsWith(repo + '/')) subpath = real.slice(repo.length + 1)
|
|
153
|
+
return { dir: real, repoUrl, ref, subpath }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Turn a git remote URL (ssh/https) into a web URL (best-effort for common hosts). */
|
|
157
|
+
function gitWebUrl(remote) {
|
|
158
|
+
let url = (remote ?? '').trim()
|
|
159
|
+
if (!url) return null
|
|
160
|
+
if (url.startsWith('git@')) url = 'https://' + url.slice(4).replace(/:/, '/')
|
|
161
|
+
if (url.endsWith('.git')) url = url.slice(0, -4)
|
|
162
|
+
if (/^https?:\/\//.test(url)) return url
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Registries to probe, best first.
|
|
168
|
+
*
|
|
169
|
+
* The host half declares only `webServer`, and this cordis version throws on any
|
|
170
|
+
* service access outside `inject` ("cannot get property ... without inject"), so
|
|
171
|
+
* the browser-side plugin manager is not reachable from here. Read whatever
|
|
172
|
+
* configuration the host can see for itself.
|
|
173
|
+
*
|
|
174
|
+
* Precedence is first-found-wins, and what counts as safe to race depends on
|
|
175
|
+
* what was found:
|
|
176
|
+
*
|
|
177
|
+
* - Nothing configured → race the mirror the launcher also falls back to
|
|
178
|
+
* against the public registry. Both are public, so trying both costs only a
|
|
179
|
+
* request, and on a mirror-served host it saves the public registry's latency
|
|
180
|
+
* (measured here: ~0.2 s against the mirror versus ~11 s against npmjs).
|
|
181
|
+
* - The *public default* configured (npm writes this into ~/.npmrc itself) →
|
|
182
|
+
* keep it first and add the mirror behind it. Racing two public registries
|
|
183
|
+
* leaks nothing, and it is what the launcher already documents.
|
|
184
|
+
* - Anything else configured → it alone. A corporate or internal registry was
|
|
185
|
+
* named deliberately; racing it would send internal package names to third
|
|
186
|
+
* parties, and the public registries cannot answer for its packages anyway.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} profileDir - the active profile directory.
|
|
189
|
+
* @returns {string[]} registry bases, deduplicated, best first.
|
|
190
|
+
*/
|
|
191
|
+
function describeBundle(name, profileDir) {
|
|
192
|
+
const dir = findBundleDir(name, profileDir)
|
|
193
|
+
const manifest = dir ? readManifestSync(join(dir, 'package.json')) : undefined
|
|
194
|
+
return {
|
|
195
|
+
name,
|
|
196
|
+
version: manifest?.version ?? null,
|
|
197
|
+
// A name listed in dsh.profile.bundles is not proof that it is installed:
|
|
198
|
+
// the list is hand-maintained and can keep entries whose package was never
|
|
199
|
+
// installed (or was removed). findBundleDir returns undefined then, and
|
|
200
|
+
// reporting installed:true made the panel show a phantom row with a null
|
|
201
|
+
// version. `installed` now reflects whether the directory actually
|
|
202
|
+
// resolved, which is what the browser half renders.
|
|
203
|
+
installed: Boolean(dir),
|
|
204
|
+
optional: false,
|
|
205
|
+
registry: manifest?.publishConfig?.registry ?? '',
|
|
206
|
+
dir,
|
|
207
|
+
manifest,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Enumerate the bundles to scan.
|
|
213
|
+
*
|
|
214
|
+
* Primary source: the profile's dsh.profile.bundles list (authoritative; it
|
|
215
|
+
* excludes bare dependencies like semver/zod and includes every link:-installed
|
|
216
|
+
* plugin). Each name is turned into a descriptor by describeBundle.
|
|
217
|
+
*
|
|
218
|
+
* Fallback (no bundle list in the profile): a best-effort directory walk over
|
|
219
|
+
* profile/node_modules that treats symlinks as bundles too. This last-resort
|
|
220
|
+
* path can include dependency directories, so it is only used when the
|
|
221
|
+
* canonical list is unavailable.
|
|
222
|
+
*/
|
|
223
|
+
export async function listBundles(profileDir) {
|
|
224
|
+
let names = []
|
|
225
|
+
try {
|
|
226
|
+
const pj = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
227
|
+
const b = pj?.dsh?.profile?.bundles
|
|
228
|
+
if (Array.isArray(b)) names = b
|
|
229
|
+
} catch { /* ignore */ }
|
|
230
|
+
if (names.length > 0) return names.map((name) => describeBundle(name, profileDir))
|
|
231
|
+
|
|
232
|
+
const nm = join(profileDir, 'node_modules')
|
|
233
|
+
const out = []
|
|
234
|
+
const readEntries = async (base) =>
|
|
235
|
+
(await readdir(base, { withFileTypes: true })).catch(() => [])
|
|
236
|
+
const collect = async (dir) => {
|
|
237
|
+
const manifest = await readManifest(join(dir, 'package.json'))
|
|
238
|
+
if (manifest?.name) out.push({ name: manifest.name, version: manifest.version, installed: true, optional: false })
|
|
239
|
+
}
|
|
240
|
+
for (const entry of await readEntries(nm)) {
|
|
241
|
+
const dir = join(nm, entry.name)
|
|
242
|
+
if (entry.isDirectory()) {
|
|
243
|
+
if (entry.name.startsWith('@')) {
|
|
244
|
+
for (const inner of await readEntries(dir)) await collect(join(dir, inner.name))
|
|
245
|
+
} else {
|
|
246
|
+
await collect(dir)
|
|
247
|
+
}
|
|
248
|
+
} else if (entry.isSymbolicLink()) {
|
|
249
|
+
await collect(dir)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Plugin entry point: register the scan route.
|
|
257
|
+
* @param {object} ctx - host context (needs webServer).
|
|
258
|
+
* @param {object} _config - plugin config; supports `repositories` keyed by
|
|
259
|
+
* package name -> source repo URL (e.g. "https://github.com/<org>/<repo>"),
|
|
260
|
+
* used to render a clickable link for link:-installed bundles.
|
|
261
|
+
*/
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-update-checker — host/registry.js
|
|
3
|
+
*
|
|
4
|
+
* 由 lib/host/update-checker.js 按下述职责拆出(函数体逐字保留):
|
|
5
|
+
* resolveRegistries, firstConfiguredRegistry, normalizeRegistry, isPublicDefault, readRegistryFromNpmrc, probeAnyRegistry, probeRegistry, classifyUpdate
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { default as semver } from 'semver'
|
|
11
|
+
|
|
12
|
+
export function resolveRegistries(profileDir) {
|
|
13
|
+
const MIRROR = 'https://registry.npmmirror.com'
|
|
14
|
+
const PUBLIC = 'https://registry.npmjs.org'
|
|
15
|
+
|
|
16
|
+
const configured = firstConfiguredRegistry(profileDir)
|
|
17
|
+
if (configured === undefined) return [MIRROR, PUBLIC]
|
|
18
|
+
if (isPublicDefault(configured)) return [configured, MIRROR]
|
|
19
|
+
return [configured]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The first registry named by the environment or an .npmrc, most specific first. */
|
|
23
|
+
function firstConfiguredRegistry(profileDir) {
|
|
24
|
+
const sources = [
|
|
25
|
+
process.env.npm_config_registry,
|
|
26
|
+
process.env.NPM_CONFIG_REGISTRY,
|
|
27
|
+
readRegistryFromNpmrc(join(profileDir, '.npmrc')),
|
|
28
|
+
readRegistryFromNpmrc(join(homedir(), '.npmrc')),
|
|
29
|
+
]
|
|
30
|
+
for (const value of sources) {
|
|
31
|
+
const base = normalizeRegistry(value)
|
|
32
|
+
if (base !== undefined) return base
|
|
33
|
+
}
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Trim a registry into a bare base URL, or undefined when it names nothing. */
|
|
38
|
+
export function normalizeRegistry(value) {
|
|
39
|
+
if (typeof value !== 'string') return undefined
|
|
40
|
+
const trimmed = value.trim().replace(/\/+$/, '')
|
|
41
|
+
if (trimmed === '') return undefined
|
|
42
|
+
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether a registry is npm's own public default. */
|
|
46
|
+
function isPublicDefault(base) {
|
|
47
|
+
try {
|
|
48
|
+
return new URL(base).hostname.toLowerCase() === 'registry.npmjs.org'
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the default `registry=` out of an .npmrc.
|
|
56
|
+
*
|
|
57
|
+
* Only the unscoped default is taken: a scoped `@scope:registry=` sends one
|
|
58
|
+
* scope elsewhere, which is a per-package decision belonging with that package's
|
|
59
|
+
* manifest rather than with the default this resolves.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} file - path to an .npmrc.
|
|
62
|
+
* @returns {string|undefined} the configured registry, if any.
|
|
63
|
+
*/
|
|
64
|
+
function readRegistryFromNpmrc(file) {
|
|
65
|
+
let text
|
|
66
|
+
try {
|
|
67
|
+
text = readFileSync(file, 'utf8')
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
72
|
+
const line = raw.trim()
|
|
73
|
+
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue
|
|
74
|
+
// A scoped entry (`@scope:registry=`) is per-scope, not the default.
|
|
75
|
+
if (line.startsWith('@')) continue
|
|
76
|
+
const match = /^registry\s*=\s*(.+)$/.exec(line)
|
|
77
|
+
if (match) {
|
|
78
|
+
const value = match[1].trim().replace(/^["']|["']$/g, '')
|
|
79
|
+
if (value !== '') return value
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Probe the registries concurrently and take the first *successful* answer.
|
|
87
|
+
*
|
|
88
|
+
* A per-registry failure (or a slow one) never blocks the others: the mirror
|
|
89
|
+
* wins even when the public registry was asked first, which is the whole point
|
|
90
|
+
* on a mirror-served host. The winner's base is returned so a caller that needs
|
|
91
|
+
* to install from the same place can pass it along.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} name - package name.
|
|
94
|
+
* @param {string[]} registries - candidate bases, best first.
|
|
95
|
+
* @param {AbortSignal} signal - request deadline.
|
|
96
|
+
* @returns {Promise<{ result: object, registry: string }>} the winning answer.
|
|
97
|
+
*/
|
|
98
|
+
export async function probeAnyRegistry(name, registries, signal) {
|
|
99
|
+
// Start every candidate at once and settle on the first *successful* answer,
|
|
100
|
+
// in completion order rather than list order. Awaiting in list order would let
|
|
101
|
+
// a slow first registry delay an already-finished mirror — exactly the case
|
|
102
|
+
// this exists to fix.
|
|
103
|
+
if (registries.length === 0) return { result: { error: 'no registry configured' }, registry: undefined }
|
|
104
|
+
return await new Promise((resolve) => {
|
|
105
|
+
let failures = 0
|
|
106
|
+
let lastFailure = null
|
|
107
|
+
let settled = false
|
|
108
|
+
for (const registry of registries) {
|
|
109
|
+
probeRegistry(name, registry, signal).then((result) => {
|
|
110
|
+
if (settled) return
|
|
111
|
+
if (result.error === undefined) {
|
|
112
|
+
settled = true
|
|
113
|
+
resolve({ result, registry })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
lastFailure = { result, registry }
|
|
117
|
+
failures += 1
|
|
118
|
+
if (failures === registries.length) {
|
|
119
|
+
settled = true
|
|
120
|
+
resolve(lastFailure)
|
|
121
|
+
}
|
|
122
|
+
}, (err) => {
|
|
123
|
+
if (settled) return
|
|
124
|
+
lastFailure = { result: { error: `registry unreachable: ${String(err?.message ?? err)}` }, registry }
|
|
125
|
+
failures += 1
|
|
126
|
+
if (failures === registries.length) {
|
|
127
|
+
settled = true
|
|
128
|
+
resolve(lastFailure)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Probe one npm registry for a package's published versions. */
|
|
136
|
+
async function probeRegistry(name, registry, signal) {
|
|
137
|
+
const base = registry && registry.trim() !== '' ? registry.replace(/\/+$/, '') : 'https://registry.npmjs.org'
|
|
138
|
+
const url = `${base}/${encodeURIComponent(name).replace('%40', '@')}`
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(url, { method: 'GET', headers: { accept: 'application/json' }, signal })
|
|
141
|
+
if (!res.ok) return { error: `registry responded ${res.status}` }
|
|
142
|
+
const data = await res.json()
|
|
143
|
+
const distTags = data?.['dist-tags']
|
|
144
|
+
const versions = Array.isArray(data?.versions) ? data.versions : Object.keys(data?.versions ?? {})
|
|
145
|
+
if (distTags?.latest === undefined && versions.length === 0) return { error: 'package not found on registry' }
|
|
146
|
+
return { latest: distTags?.latest, versions }
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err?.name === 'AbortError') return { error: 'request aborted' }
|
|
149
|
+
return { error: `registry unreachable: ${String(err?.message ?? err)}` }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Compare installed vs latest into an update verdict. */
|
|
154
|
+
export function classifyUpdate(installedVersion, remote) {
|
|
155
|
+
if (remote.error !== undefined) return { status: 'unknown', error: remote.error }
|
|
156
|
+
const latest = remote.latest
|
|
157
|
+
if (latest === undefined) return { status: 'unknown', error: 'no published version' }
|
|
158
|
+
let newer = false
|
|
159
|
+
try {
|
|
160
|
+
newer = semver.gt(latest, installedVersion ?? '0.0.0')
|
|
161
|
+
} catch {
|
|
162
|
+
newer = String(latest) !== String(installedVersion)
|
|
163
|
+
}
|
|
164
|
+
return newer ? { status: 'update-available', latest } : { status: 'up-to-date', latest }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check the running DSH *runtime* itself for a newer release.
|
|
169
|
+
*
|
|
170
|
+
* The runtime is not a plugin: it is the @deepseek-ai/dsh* tree the launcher
|
|
171
|
+
* boots, carrying one version. We report it as a single, clearly labelled row
|
|
172
|
+
* ("dsh-runtime") so the panel can answer "is my DSH itself up to date?" — the
|
|
173
|
+
* question the old panel silently left unanswered. We never call it a
|
|
174
|
+
* source-link (the @deepseek-ai/dsh* packages are symlinked from the runtime
|
|
175
|
+
* install, which would otherwise make the scanner mislabel the *entire runtime*
|
|
176
|
+
* as a user-edited source tree and say "update from the repository").
|
|
177
|
+
*
|
|
178
|
+
* Source precedence for the running-runtime comparison:
|
|
179
|
+
* 1. The desktop app's own electron updater feed (app-update.yml's `url` +
|
|
180
|
+
* `channel`). This is the authoritative channel and the ONLY correct one
|
|
181
|
+
* for prerelease / nightly users: npm's `latest` dist-tag lags the running
|
|
182
|
+
* nightly build, so comparing against it would falsely report "up to date".
|
|
183
|
+
* 2. npm's @deepseek-ai/dsh-app-boot `latest` dist-tag — correct for stable
|
|
184
|
+
* channel users, and used when no updater feed is reachable (e.g. the web
|
|
185
|
+
* profile, which has no app-update.yml).
|
|
186
|
+
*
|
|
187
|
+
* A probe failure (offline, private registry, ...) is surfaced honestly as
|
|
188
|
+
* status 'unknown' rather than hiding the row.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} dshVersion - the running runtime version.
|
|
191
|
+
* @param {string[]} registries - candidate registry bases, best first.
|
|
192
|
+
* @param {AbortSignal} signal - request deadline.
|
|
193
|
+
* @returns {Promise<object>} a row shaped like scanBundle's output, with
|
|
194
|
+
* name 'dsh-runtime', so the browser half can render it without special-casing.
|
|
195
|
+
*/
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-update-checker — host/runtime.js
|
|
3
|
+
*
|
|
4
|
+
* 由 lib/host/update-checker.js 按下述职责拆出(函数体逐字保留):
|
|
5
|
+
* APP_UPDATE_YML, readAppUpdateConfig, runtimeFeedFileNames, runtimePlatformKey, runtimeArchKey, fetchRuntimeFeedVersion, checkDshRuntime
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { default as semver } from 'semver'
|
|
10
|
+
import { normalizeRegistry, probeAnyRegistry, classifyUpdate } from './registry.js'
|
|
11
|
+
|
|
12
|
+
const APP_UPDATE_YML = 'app-update.yml'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Locate and parse the desktop app's app-update.yml.
|
|
16
|
+
*
|
|
17
|
+
* The Electron host's argv carries the app path (e.g.
|
|
18
|
+
* DeepSeek Harness --expose-internals <host.js> <app.asar/dsh>
|
|
19
|
+
* /Users/me/.dsh/profiles/desktop <runtime> ...),
|
|
20
|
+
* so we walk up from that path to find app-update.yml (it sits in
|
|
21
|
+
* .../Contents/Resources/, the parent of app.asar). On the web profile there is
|
|
22
|
+
* no app bundle, so this returns undefined and the runtime check falls back to
|
|
23
|
+
* npm. Returns { url, channel } or undefined.
|
|
24
|
+
*/
|
|
25
|
+
function readAppUpdateConfig() {
|
|
26
|
+
for (const arg of process.argv.slice(2)) {
|
|
27
|
+
if (!arg.startsWith('/') || arg.startsWith('-')) continue
|
|
28
|
+
let dir = dirname(arg)
|
|
29
|
+
for (let i = 0; i < 6; i++) {
|
|
30
|
+
const candidate = join(dir, APP_UPDATE_YML)
|
|
31
|
+
if (existsSync(candidate)) {
|
|
32
|
+
try {
|
|
33
|
+
const text = readFileSync(candidate, 'utf8')
|
|
34
|
+
const get = (k) => {
|
|
35
|
+
const m = new RegExp(`^\\s*${k}\\s*:\\s*(.+)$`, 'm').exec(text)
|
|
36
|
+
return m ? m[1].trim() : undefined
|
|
37
|
+
}
|
|
38
|
+
const url = get('url')
|
|
39
|
+
if (!url) return undefined
|
|
40
|
+
return { url: normalizeRegistry(url).replace(/\/+$/, ''), channel: get('channel') || 'latest' }
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const parent = dirname(dir)
|
|
46
|
+
if (parent === dir) break
|
|
47
|
+
dir = parent
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** electron-builder's channel file name for the current platform/arch. */
|
|
54
|
+
function runtimeFeedFileNames(channel, platform, arch) {
|
|
55
|
+
const base = (channel === 'latest' ? 'latest' : channel) + '-' + platform
|
|
56
|
+
// electron-builder may publish a per-arch file (latest-mac-arm64.yml) in
|
|
57
|
+
// addition to the platform-only file; try the arch-suffixed name first.
|
|
58
|
+
return arch ? [base + '-' + arch + '.yml', base + '.yml'] : [base + '.yml']
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Map node's platform/arch onto electron-builder's feed naming. */
|
|
62
|
+
function runtimePlatformKey() {
|
|
63
|
+
const p = process.platform
|
|
64
|
+
if (p === 'darwin') return 'mac'
|
|
65
|
+
if (p === 'win32') return 'win'
|
|
66
|
+
if (p === 'linux') return 'linux'
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
function runtimeArchKey() {
|
|
70
|
+
if (process.arch === 'arm64') return 'arm64'
|
|
71
|
+
if (process.arch === 'x64') return 'x64'
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fetch the published runtime version from the desktop app's electron updater
|
|
77
|
+
* feed. Returns { version } on success or { error } when every candidate file
|
|
78
|
+
* is missing/unreachable, so the caller can fall back to npm.
|
|
79
|
+
*/
|
|
80
|
+
async function fetchRuntimeFeedVersion(feedBase, channel, signal) {
|
|
81
|
+
const platform = runtimePlatformKey()
|
|
82
|
+
if (!platform) return { error: 'unsupported platform for feed lookup' }
|
|
83
|
+
const names = runtimeFeedFileNames(channel, platform, runtimeArchKey())
|
|
84
|
+
let lastErr
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
const url = feedBase + '/' + name
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(url, { method: 'GET', signal })
|
|
89
|
+
if (!res.ok) { lastErr = `feed responded ${res.status}`; continue }
|
|
90
|
+
const text = await res.text()
|
|
91
|
+
const m = /^version\s*:\s*(\S+)/m.exec(text)
|
|
92
|
+
if (!m) { lastErr = 'feed missing version'; continue }
|
|
93
|
+
return { version: m[1].replace(/['"]/g, '') }
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err?.name === 'AbortError') return { error: 'request aborted' }
|
|
96
|
+
lastErr = `feed unreachable: ${String(err?.message ?? err)}`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { error: lastErr ?? 'feed not found' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Scan one bundle for compatibility + update facts. */
|
|
103
|
+
export async function checkDshRuntime(dshVersion, registries, signal) {
|
|
104
|
+
const row = {
|
|
105
|
+
name: 'dsh-runtime',
|
|
106
|
+
installedVersion: dshVersion,
|
|
107
|
+
installed: true,
|
|
108
|
+
linked: false,
|
|
109
|
+
description: 'DSH 运行时(@deepseek-ai/dsh*)',
|
|
110
|
+
compatibility: { status: 'compatible' },
|
|
111
|
+
update: { status: 'unknown', error: 'not scanned' },
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
// 1) Authoritative: the desktop app's own electron updater feed.
|
|
115
|
+
const cfg = readAppUpdateConfig()
|
|
116
|
+
if (cfg) {
|
|
117
|
+
const feed = await fetchRuntimeFeedVersion(cfg.url, cfg.channel, signal)
|
|
118
|
+
if (feed.version !== undefined) {
|
|
119
|
+
let newer = false
|
|
120
|
+
try { newer = semver.gt(feed.version, dshVersion ?? '0.0.0') } catch { newer = String(feed.version) !== String(dshVersion) }
|
|
121
|
+
if (newer) {
|
|
122
|
+
row.update = { status: 'update-available', latest: feed.version, source: 'desktop-updater' }
|
|
123
|
+
} else {
|
|
124
|
+
row.update = { status: 'up-to-date', latest: feed.version }
|
|
125
|
+
}
|
|
126
|
+
return row
|
|
127
|
+
}
|
|
128
|
+
// Feed unreachable -> fall through to npm; its error is non-fatal.
|
|
129
|
+
}
|
|
130
|
+
// 2) Fallback: npm's @deepseek-ai/dsh-app-boot `latest` dist-tag.
|
|
131
|
+
const { result: remote, registry: usedRegistry } = await probeAnyRegistry('@deepseek-ai/dsh-app-boot', registries, signal)
|
|
132
|
+
row.update = classifyUpdate(dshVersion, remote)
|
|
133
|
+
if (row.update.status !== 'unknown' && usedRegistry) row.update.registry = usedRegistry
|
|
134
|
+
if (row.update.status === 'unknown' && remote.error && /not found|404/.test(remote.error)) {
|
|
135
|
+
row.update = { status: 'unsupported', error: 'runtime not published to the configured registry' }
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
row.update = { status: 'unknown', error: String(err?.message ?? err) }
|
|
139
|
+
}
|
|
140
|
+
return row
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The electron updater's channel file lives next to the app at app-update.yml. */
|