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.
@@ -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. */