dsh-miloco-bridge 0.1.0 → 0.1.2

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/lib/deploy.js CHANGED
@@ -5,15 +5,15 @@
5
5
  * reach a backend it can:
6
6
  *
7
7
  * - start an already-installed Miloco (`miloco-cli service start`), or
8
- * - clone the upstream repository into a configurable directory and run its
9
- * own installer (`scripts/install.sh --dev --skip-openclaw`).
8
+ * - download the official release installer and run it without OpenClaw
9
+ * (`install.sh --skip-openclaw`), then install this package's adapter.
10
10
  *
11
- * It then installs this package's `agent_platform` adapter, points Miloco's
12
- * webhook at DSH, and writes the runtime overlay so the plugin can talk to the
13
- * new backend immediately.
11
+ * The release installer is downloaded directly from GitHub first and then
12
+ * through the configured mirrors. It is a self-contained script and downloads
13
+ * its own release assets through the mirrors embedded in its manifest.
14
14
  *
15
- * This module never vendors upstream code: the clone and install happen at
16
- * runtime, on the owner's machine, after an explicit button press.
15
+ * This module never vendors upstream code: the release installer download and
16
+ * install happen at runtime, on the owner's machine, after a button press.
17
17
  *
18
18
  * @module dsh-miloco-bridge/deploy
19
19
  */
@@ -21,11 +21,17 @@
21
21
  import { spawn } from 'node:child_process'
22
22
  import { randomBytes } from 'node:crypto'
23
23
  import { constants, existsSync } from 'node:fs'
24
- import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
24
+ import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
25
25
  import { homedir } from 'node:os'
26
26
  import { dirname, join } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
28
  import { readMilocoConfig } from './miloco-client.js'
29
+ import {
30
+ DEFAULT_GITHUB_MIRRORS,
31
+ normalizeRepo,
32
+ releaseInstallerUrls,
33
+ viaMirror,
34
+ } from './github-release.js'
29
35
 
30
36
  /** Upstream source repository used when no local checkout exists. */
31
37
  export const MILOCO_REPO = 'https://github.com/XiaoMi/xiaomi-miloco'
@@ -48,6 +54,46 @@ function expandHome(value) {
48
54
  return text
49
55
  }
50
56
 
57
+ /**
58
+ * Mask credentials in command logs.
59
+ * @param {string[]} args - Command arguments.
60
+ * @returns {string[]} Arguments safe to log.
61
+ */
62
+ export function redactCommandArgs(args) {
63
+ const out = [...args]
64
+ const secretFlags = new Set(['agent.auth_bearer', 'auth_bearer', '--token', '--bearer'])
65
+ for (let index = 0; index < out.length - 1; index += 1) {
66
+ if (secretFlags.has(out[index])) out[index + 1] = '<redacted>'
67
+ }
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * Force release-installer arguments.
73
+ *
74
+ * The old source flow used `--dev`; a release installer rejects it. OpenClaw is
75
+ * used only to satisfy upstream platform detection, while `--skip-openclaw`
76
+ * prevents its plugin from being installed.
77
+ * @param {string[]} [raw] - Configured arguments.
78
+ * @returns {string[]} Safe release-installer arguments.
79
+ */
80
+ function normalizeInstallArgs(raw) {
81
+ const args = Array.isArray(raw) ? raw.map(String) : []
82
+ const filtered = args.filter(arg =>
83
+ arg !== '--dev'
84
+ && !arg.startsWith('--dev=')
85
+ && !arg.startsWith('--agent-prepare')
86
+ && !arg.startsWith('--agent-finish'))
87
+ if (!filtered.includes('--skip-openclaw')) filtered.push('--skip-openclaw')
88
+ if (!filtered.some(arg => arg === '--agent-platform' || arg.startsWith('--agent-platform='))) {
89
+ filtered.push('--agent-platform=openclaw')
90
+ }
91
+ if (!filtered.some(arg => arg === '--lang' || arg.startsWith('--lang='))) {
92
+ filtered.push('--lang=zh')
93
+ }
94
+ return filtered
95
+ }
96
+
51
97
  /**
52
98
  * Create a directory and prove the DSH process can write to it.
53
99
  * @param {string} dir - Directory path.
@@ -85,22 +131,45 @@ export function createDeployService(options) {
85
131
  )
86
132
  const milocoHome = expandHome(config.home || '~/.miloco')
87
133
  const repo = String(deployConfig.repo || MILOCO_REPO)
88
- const installArgs = Array.isArray(deployConfig.installArgs) && deployConfig.installArgs.length
89
- ? deployConfig.installArgs.map(String)
90
- : ['--dev', '--skip-openclaw']
134
+ const githubMirrors = Array.isArray(deployConfig.githubMirrors) && deployConfig.githubMirrors.length
135
+ ? deployConfig.githubMirrors.map(String)
136
+ : DEFAULT_GITHUB_MIRRORS
137
+ const installArgs = normalizeInstallArgs(deployConfig.installArgs)
138
+ const repoUrl = `https://github.com/${normalizeRepo(repo)}`
139
+ const downloadSite = String(deployConfig.downloadSite || '').trim()
140
+ || (githubMirrors[0] ? viaMirror(githubMirrors[0], `${repoUrl}/releases/download`) : '')
141
+ const installEnv = {
142
+ UV_CACHE_DIR: join(milocoHome, '.uv-cache'),
143
+ UV_INDEX: String(deployConfig.uvIndex || 'https://pypi.org/simple'),
144
+ ...(downloadSite ? { MILOCO_DOWNLOAD_URL: downloadSite } : {}),
145
+ }
91
146
  const logPath = expandHome(deployConfig.logPath || join(milocoHome, 'log', 'dsh-miloco-deploy.log'))
147
+ const upgradeLogPath = expandHome(
148
+ deployConfig.upgradeLogPath || join(milocoHome, 'log', 'dsh-miloco-upgrade.log'),
149
+ )
92
150
 
93
151
  /** @type {{running: boolean, step: string, startedAt: string | null, exitCode: number | null}} */
94
152
  const state = { running: false, step: 'idle', startedAt: null, exitCode: null }
153
+ /** @type {{running: boolean, step: string, startedAt: string | null, exitCode: number | null}} */
154
+ const upgradeState = { running: false, step: 'idle', startedAt: null, exitCode: null }
155
+
156
+ /**
157
+ * Write a line into a log file.
158
+ * @param {string} path - Log path.
159
+ * @param {string} text - The line.
160
+ */
161
+ async function logTo(path, text) {
162
+ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
163
+ await mkdir(dirname(path), { recursive: true })
164
+ await writeFile(path, `[${stamp}] ${text}\n`, { flag: 'a' })
165
+ }
95
166
 
96
167
  /**
97
168
  * Write a line into the deployment log.
98
169
  * @param {string} text - The line.
99
170
  */
100
171
  async function logLine(text) {
101
- const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
102
- await mkdir(dirname(logPath), { recursive: true })
103
- await writeFile(logPath, `[${stamp}] ${text}\n`, { flag: 'a' })
172
+ return logTo(logPath, text)
104
173
  }
105
174
 
106
175
  /**
@@ -112,16 +181,27 @@ export function createDeployService(options) {
112
181
  */
113
182
  function run(command, args, extra = {}) {
114
183
  return new Promise(resolve => {
115
- void logLine(`$ ${command} ${args.join(' ')}`)
184
+ const secrets = (extra.secrets ?? []).filter(Boolean)
185
+ const sanitize = value => secrets.reduce(
186
+ (text, secret) => text.split(secret).join('<redacted>'),
187
+ String(value),
188
+ )
189
+ const commandLog = extra.logPath || logPath
190
+ void logTo(commandLog, `$ ${command} ${redactCommandArgs(args).join(' ')}`)
116
191
  const child = spawn(command, args, {
117
192
  cwd: extra.cwd,
118
- env: { ...process.env, MILOCO_HOME: milocoHome, ...(extra.env ?? {}) },
193
+ env: {
194
+ ...process.env,
195
+ PATH: [join(homedir(), '.local', 'bin'), process.env.PATH].filter(Boolean).join(':'),
196
+ MILOCO_HOME: milocoHome,
197
+ ...(extra.env ?? {}),
198
+ },
119
199
  stdio: ['ignore', 'pipe', 'pipe'],
120
200
  })
121
201
  const pump = stream => {
122
202
  stream.on('data', chunk => {
123
- for (const line of String(chunk).split('\n')) {
124
- if (line.trim() !== '') void logLine(line.trim())
203
+ for (const line of sanitize(chunk).split('\n')) {
204
+ if (line.trim() !== '') void logTo(commandLog, line.trim())
125
205
  }
126
206
  })
127
207
  }
@@ -191,6 +271,37 @@ export function createDeployService(options) {
191
271
  return false
192
272
  }
193
273
 
274
+ /**
275
+ * Download the self-contained official release installer.
276
+ * @returns {Promise<string>} Path to the installer script.
277
+ */
278
+ async function downloadReleaseInstaller(logTarget = logPath) {
279
+ const target = join(milocoHome, '.dsh-release-install.sh')
280
+ const urls = releaseInstallerUrls({ repo, mirrors: githubMirrors })
281
+ const failures = []
282
+ for (const url of urls) {
283
+ await logTo(logTarget, `downloading release installer: ${url}`)
284
+ try {
285
+ const response = await fetch(url, {
286
+ headers: { 'user-agent': 'dsh-miloco-bridge' },
287
+ redirect: 'follow',
288
+ signal: AbortSignal.timeout(30_000),
289
+ })
290
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
291
+ const text = await response.text()
292
+ if (!text.startsWith('#!')) throw new Error('响应不是 shell 安装脚本')
293
+ await writeFile(target, text, { encoding: 'utf8', mode: 0o700 })
294
+ await chmod(target, 0o700)
295
+ return target
296
+ } catch (error) {
297
+ const message = error instanceof Error ? error.message : String(error)
298
+ failures.push(`${url}: ${message}`)
299
+ await logTo(logTarget, `download failed: ${message}`)
300
+ }
301
+ }
302
+ throw new Error(`无法下载 Miloco release 安装器:${failures.join('; ')}`)
303
+ }
304
+
194
305
  /**
195
306
  * Resolve the effective Miloco URL/token from `$MILOCO_HOME/config.json`.
196
307
  * @returns {Promise<{baseUrl: string, token: string}>} Connection settings.
@@ -244,10 +355,13 @@ export function createDeployService(options) {
244
355
  async function wireMiloco(opts) {
245
356
  const webhook = `${opts.origin}/miloco/webhook`
246
357
  if (opts.cli) {
247
- await run(opts.cli, ['config', 'set', 'agent.platform', 'dsh'], { cwd: sourceDir })
248
- await run(opts.cli, ['config', 'set', 'agent.webhook_url', webhook], { cwd: sourceDir })
249
- await run(opts.cli, ['config', 'set', 'agent.auth_bearer', opts.token], { cwd: sourceDir })
250
- await run(opts.cli, ['service', 'restart'], { cwd: sourceDir })
358
+ await run(opts.cli, ['config', 'set', 'agent.platform', 'dsh'], { cwd: milocoHome })
359
+ await run(opts.cli, ['config', 'set', 'agent.webhook_url', webhook], { cwd: milocoHome })
360
+ await run(opts.cli, ['config', 'set', 'agent.auth_bearer', opts.token], {
361
+ cwd: milocoHome,
362
+ secrets: [opts.token],
363
+ })
364
+ await run(opts.cli, ['service', 'restart'], { cwd: milocoHome })
251
365
  }
252
366
  if (options.overlay) {
253
367
  const current = await options.overlay.current()
@@ -279,7 +393,6 @@ export function createDeployService(options) {
279
393
  void (async () => {
280
394
  try {
281
395
  await ensureWritableDir(milocoHome, 'MILOCO_HOME')
282
- await ensureWritableDir(dirname(sourceDir), 'deploy.sourceDir 的父目录')
283
396
  await logLine('=== dsh-miloco-bridge: 一键部署 Miloco 开始 ===')
284
397
 
285
398
  if (await healthy()) {
@@ -290,28 +403,28 @@ export function createDeployService(options) {
290
403
  if (cli && existsSync(join(milocoHome, 'config.json'))) {
291
404
  state.step = 'starting-existing'
292
405
  await logLine(`检测到已安装的 Miloco,尝试启动:${cli}`)
293
- await run(cli, ['service', 'start'], { cwd: sourceDir })
406
+ await run(cli, ['service', 'start'], { cwd: milocoHome })
294
407
  await waitHealthy(DEFAULT_MILOCO_URL, 45_000)
295
408
  }
296
409
 
297
410
  if (!await healthy()) {
298
- state.step = 'preparing-source'
299
- if (!existsSync(join(sourceDir, 'scripts', 'install.sh'))) {
300
- if (!/^https:\/\//i.test(repo)) {
301
- throw new Error('deploy.repo 必须是 https:// 地址')
302
- }
303
- await mkdir(dirname(sourceDir), { recursive: true })
304
- await logLine(`cloning ${repo} -> ${sourceDir}`)
305
- const clone = await run('git', ['clone', '--depth', '1', repo, sourceDir], { cwd: homedir() })
306
- if (clone !== 0) throw new Error(`git clone 失败(exit ${clone})`)
307
- }
411
+ state.step = 'downloading-installer'
412
+ const installer = await downloadReleaseInstaller()
308
413
  state.step = 'installing'
309
- const install = await run('bash', ['scripts/install.sh', ...installArgs], { cwd: sourceDir })
310
- if (install !== 0) throw new Error(`Miloco 安装器返回 ${install}`)
414
+ const prepare = await run('bash', [installer, '--agent-prepare', ...installArgs], {
415
+ cwd: milocoHome,
416
+ env: installEnv,
417
+ })
418
+ if (prepare !== 0) throw new Error(`Miloco release 安装器 prepare 返回 ${prepare}`)
419
+ const finish = await run('bash', [installer, '--agent-finish', ...installArgs], {
420
+ cwd: milocoHome,
421
+ env: installEnv,
422
+ })
423
+ if (finish !== 0) throw new Error(`Miloco release 安装器 finish 返回 ${finish}`)
311
424
  state.step = 'starting-service'
312
425
  const cliAfter = await findMilocoCli()
313
- if (cliAfter) await run(cliAfter, ['service', 'start'], { cwd: sourceDir })
314
- if (!await waitHealthy(DEFAULT_MILOCO_URL, 90_000)) {
426
+ if (cliAfter) await run(cliAfter, ['service', 'start'], { cwd: milocoHome })
427
+ if (!await waitHealthy(DEFAULT_MILOCO_URL, 120_000)) {
315
428
  throw new Error('Miloco 安装完成但没有在 127.0.0.1:1810 上响应')
316
429
  }
317
430
  }
@@ -346,6 +459,88 @@ export function createDeployService(options) {
346
459
  return { started: true, logPath }
347
460
  }
348
461
 
462
+ /**
463
+ * Run a mirror-backed upgrade through the same release installer as deploy.
464
+ * @param {{origin?: string}} [opts] - Request origin.
465
+ * @returns {Promise<{started: boolean, reason?: string, logPath?: string}>} Outcome.
466
+ */
467
+ async function startUpgrade(opts = {}) {
468
+ if (upgradeState.running) {
469
+ return { started: false, reason: 'already-running', logPath: upgradeLogPath }
470
+ }
471
+ if (state.running) {
472
+ return { started: false, reason: 'deploy-running', logPath: upgradeLogPath }
473
+ }
474
+ upgradeState.running = true
475
+ upgradeState.step = 'starting'
476
+ upgradeState.startedAt = new Date().toISOString()
477
+ upgradeState.exitCode = null
478
+ const origin = String(opts.origin || '').replace(/\/+$/, '') || 'http://127.0.0.1:3080'
479
+
480
+ void (async () => {
481
+ try {
482
+ await ensureWritableDir(milocoHome, 'MILOCO_HOME')
483
+ await logTo(upgradeLogPath, '=== Miloco 升级开始 ===')
484
+ upgradeState.step = 'downloading-installer'
485
+ const installer = await downloadReleaseInstaller(upgradeLogPath)
486
+ upgradeState.step = 'installing'
487
+ const installOpts = { cwd: milocoHome, env: installEnv, logPath: upgradeLogPath }
488
+ const prepare = await run('bash', [installer, '--agent-prepare', ...installArgs], installOpts)
489
+ if (prepare !== 0) throw new Error(`Miloco release 安装器 prepare 返回 ${prepare}`)
490
+ const finish = await run('bash', [installer, '--agent-finish', ...installArgs], installOpts)
491
+ if (finish !== 0) throw new Error(`Miloco release 安装器 finish 返回 ${finish}`)
492
+ upgradeState.step = 'starting-service'
493
+ const cli = await findMilocoCli()
494
+ if (cli) {
495
+ await run(cli, ['service', 'start'], { cwd: milocoHome, logPath: upgradeLogPath })
496
+ }
497
+ if (!await waitHealthy(DEFAULT_MILOCO_URL, 120_000)) {
498
+ throw new Error('Miloco 升级后没有在 127.0.0.1:1810 上响应')
499
+ }
500
+ upgradeState.step = 'wiring'
501
+ const connection = await localConnection()
502
+ const fileConfig = await readMilocoConfig(milocoHome)
503
+ const existingToken = String(fileConfig?.agent?.auth_bearer ?? '').trim()
504
+ const pushToken = existingToken || randomBytes(32).toString('base64url')
505
+ await installAdapter({ origin, token: pushToken })
506
+ await wireMiloco({ cli, origin, token: pushToken, connection })
507
+ upgradeState.step = 'done'
508
+ upgradeState.exitCode = 0
509
+ await logTo(upgradeLogPath, '=== 升级完成 ===')
510
+ } catch (error) {
511
+ upgradeState.step = 'failed'
512
+ upgradeState.exitCode = 1
513
+ await logTo(upgradeLogPath, `升级异常:${error instanceof Error ? error.message : String(error)}`)
514
+ } finally {
515
+ upgradeState.running = false
516
+ }
517
+ })()
518
+
519
+ return { started: true, logPath: upgradeLogPath }
520
+ }
521
+
522
+ /**
523
+ * @returns {any} Upgrade state for the settings API.
524
+ */
525
+ function upgradeStateView() {
526
+ return { ...upgradeState, logPath: upgradeLogPath, milocoHome }
527
+ }
528
+
529
+ /**
530
+ * Read the tail of the upgrade log.
531
+ * @param {number} [lines] - Number of trailing lines.
532
+ * @returns {Promise<string>} The log tail.
533
+ */
534
+ async function upgradeTail(lines = 60) {
535
+ try {
536
+ const text = await readFile(upgradeLogPath, 'utf8')
537
+ const all = text.split('\n').filter(line => line.trim() !== '')
538
+ return all.slice(-lines).join('\n')
539
+ } catch {
540
+ return ''
541
+ }
542
+ }
543
+
349
544
  /**
350
545
  * Read the tail of the deployment log.
351
546
  * @param {number} [lines] - Number of trailing lines.
@@ -371,5 +566,8 @@ export function createDeployService(options) {
371
566
  }),
372
567
  tail,
373
568
  start,
569
+ upgradeState: upgradeStateView,
570
+ upgradeTail,
571
+ startUpgrade,
374
572
  }
375
573
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * GitHub release lookup with mirror fallback.
3
+ *
4
+ * Miloco only publishes on GitHub. Direct GitHub access is preferred; when it
5
+ * is unavailable, the same API/installer URLs are requested through public
6
+ * pull-through mirrors.
7
+ *
8
+ * @module dsh-miloco-bridge/github-release
9
+ */
10
+
11
+ export const DEFAULT_GITHUB_REPO = 'XiaoMi/xiaomi-miloco'
12
+ export const DEFAULT_GITHUB_API = 'https://api.github.com'
13
+ export const DEFAULT_GITHUB_MIRRORS = [
14
+ 'https://gh-proxy.com/',
15
+ 'https://gh-proxy.org/',
16
+ ]
17
+
18
+ /**
19
+ * Normalise an owner/repo or GitHub URL to `owner/repo`.
20
+ * @param {string} [repo] - Repository URL or slug.
21
+ * @returns {string} Repository slug.
22
+ */
23
+ export function normalizeRepo(repo) {
24
+ const text = String(repo ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '')
25
+ const match = /^(?:https?:\/\/github\.com\/)?([^/\s]+\/[^/\s]+)$/i.exec(text)
26
+ return match ? match[1] : DEFAULT_GITHUB_REPO
27
+ }
28
+
29
+ /**
30
+ * Prefix a URL with a mirror, or expand a `{url}` template.
31
+ * @param {string} mirror - Mirror base or template.
32
+ * @param {string} url - Original URL.
33
+ * @returns {string} Mirrored URL.
34
+ */
35
+ export function viaMirror(mirror, url) {
36
+ const base = String(mirror ?? '').trim()
37
+ if (!base) return url
38
+ if (base.includes('{url}')) return base.replaceAll('{url}', url)
39
+ return `${base.replace(/\/+$/, '')}/${url}`
40
+ }
41
+
42
+ /**
43
+ * Build direct + mirrored URLs for the latest-release API.
44
+ * @param {{repo?: string, apiBase?: string, mirrors?: string[]}} [opts] - Options.
45
+ * @returns {string[]} Candidate URLs, direct first.
46
+ */
47
+ export function releaseApiUrls(opts = {}) {
48
+ const repo = normalizeRepo(opts.repo)
49
+ const apiBase = String(opts.apiBase || DEFAULT_GITHUB_API).replace(/\/+$/, '')
50
+ const direct = `${apiBase}/repos/${repo}/releases/latest`
51
+ const mirrors = (opts.mirrors ?? DEFAULT_GITHUB_MIRRORS).map(mirror => viaMirror(mirror, direct))
52
+ return [...new Set([direct, ...mirrors])]
53
+ }
54
+
55
+ /**
56
+ * Build direct + mirrored URLs for the release installer script.
57
+ * @param {{repo?: string, mirrors?: string[]}} [opts] - Options.
58
+ * @returns {string[]} Candidate URLs, direct first.
59
+ */
60
+ export function releaseInstallerUrls(opts = {}) {
61
+ const repo = normalizeRepo(opts.repo)
62
+ const direct = `https://github.com/${repo}/releases/latest/download/install.sh`
63
+ const mirrors = (opts.mirrors ?? DEFAULT_GITHUB_MIRRORS).map(mirror => viaMirror(mirror, direct))
64
+ return [...new Set([direct, ...mirrors])]
65
+ }
66
+
67
+ /**
68
+ * Fetch the latest release, trying direct GitHub then mirrors.
69
+ * @param {{repo?: string, apiBase?: string, mirrors?: string[], timeoutMs?: number, fetchImpl?: typeof fetch}} [opts] - Options.
70
+ * @returns {Promise<{tag: string, html_url: string, source: 'github'|'mirror', mirror: string|null, release_url: string, request_url: string}>} Latest release.
71
+ */
72
+ export async function fetchLatestRelease(opts = {}) {
73
+ const doFetch = opts.fetchImpl ?? fetch
74
+ const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : 6_000
75
+ const urls = releaseApiUrls(opts)
76
+ const errors = []
77
+ for (const [index, url] of urls.entries()) {
78
+ try {
79
+ const response = await doFetch(url, {
80
+ headers: {
81
+ accept: 'application/vnd.github+json',
82
+ 'user-agent': 'dsh-miloco-bridge',
83
+ },
84
+ redirect: 'follow',
85
+ signal: AbortSignal.timeout(timeoutMs),
86
+ })
87
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
88
+ const body = await response.json()
89
+ const tag = String(body?.tag_name ?? '').trim()
90
+ if (!tag) throw new Error('响应缺少 tag_name')
91
+ const htmlUrl = String(body?.html_url ?? '').trim()
92
+ const mirrored = index > 0
93
+ const mirror = mirrored ? (opts.mirrors ?? DEFAULT_GITHUB_MIRRORS)[index - 1] : null
94
+ return {
95
+ tag,
96
+ html_url: htmlUrl,
97
+ source: mirrored ? 'mirror' : 'github',
98
+ mirror,
99
+ release_url: mirrored && htmlUrl ? viaMirror(mirror, htmlUrl) : htmlUrl,
100
+ request_url: url,
101
+ }
102
+ } catch (error) {
103
+ errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
104
+ }
105
+ }
106
+ throw new Error(`最新版本查询失败:${errors.join('; ')}`)
107
+ }
108
+
109
+ /**
110
+ * Parse a CalVer/PEP440-style release string.
111
+ * @param {string} value - Version string.
112
+ * @returns {{parts: number[], post: number, dev: number|null}|null} Parsed version.
113
+ */
114
+ export function parseReleaseVersion(value) {
115
+ const text = String(value ?? '').trim().replace(/^v/i, '').split('+')[0]
116
+ const match = /^(\d+(?:\.\d+)*)(?:\.post(\d+))?(?:\.dev(\d+))?$/.exec(text)
117
+ if (!match) return null
118
+ return {
119
+ parts: match[1].split('.').map(Number),
120
+ post: Number(match[2] ?? 0),
121
+ dev: match[3] === undefined ? null : Number(match[3]),
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Normalise a release tag for display.
127
+ * @param {string} value - Tag or version.
128
+ * @returns {string} Version without a leading `v`.
129
+ */
130
+ export function normalizeReleaseVersion(value) {
131
+ return String(value ?? '').trim().replace(/^v/i, '')
132
+ }
133
+
134
+ /**
135
+ * Whether `latest` is strictly newer than `current`.
136
+ * Dev builds and unparseable versions are treated conservatively as "no update".
137
+ * @param {string} current - Current version.
138
+ * @param {string} latest - Latest release tag.
139
+ * @returns {boolean} Whether an update is available.
140
+ */
141
+ export function isNewerRelease(current, latest) {
142
+ const left = parseReleaseVersion(current)
143
+ const right = parseReleaseVersion(latest)
144
+ if (!left || !right) return false
145
+ if (left.dev !== null) return false
146
+ const length = Math.max(left.parts.length, right.parts.length)
147
+ for (let index = 0; index < length; index += 1) {
148
+ const a = left.parts[index] ?? 0
149
+ const b = right.parts[index] ?? 0
150
+ if (b !== a) return b > a
151
+ }
152
+ return right.post > left.post
153
+ }
154
+
155
+ /**
156
+ * Infer release/dev deployment kind from a package version.
157
+ * @param {string} version - Package version.
158
+ * @returns {'release'|'dev'} Deployment kind.
159
+ */
160
+ export function inferDeployKind(version) {
161
+ return /\.dev\d*|\+/.test(String(version ?? '')) ? 'dev' : 'release'
162
+ }
package/lib/index.js CHANGED
@@ -96,7 +96,17 @@ export function normalizeConfig(raw) {
96
96
  : join(resolveDshHome(), 'plugin-data', 'dsh-miloco-bridge', 'xiaomi-miloco'),
97
97
  installArgs: Array.isArray(deployRaw.installArgs)
98
98
  ? deployRaw.installArgs.map(String)
99
- : ['--dev', '--skip-openclaw'],
99
+ : ['--skip-openclaw', '--agent-platform=openclaw', '--lang=zh'],
100
+ githubMirrors: Array.isArray(deployRaw.githubMirrors)
101
+ ? deployRaw.githubMirrors.map(String).filter(Boolean)
102
+ : ['https://gh-proxy.com/', 'https://gh-proxy.org/'],
103
+ githubApi: typeof deployRaw.githubApi === 'string' && deployRaw.githubApi
104
+ ? deployRaw.githubApi
105
+ : 'https://api.github.com',
106
+ downloadSite: typeof deployRaw.downloadSite === 'string' ? deployRaw.downloadSite : '',
107
+ uvIndex: typeof deployRaw.uvIndex === 'string' && deployRaw.uvIndex
108
+ ? deployRaw.uvIndex
109
+ : 'https://pypi.org/simple',
100
110
  logPath: typeof deployRaw.logPath === 'string' ? deployRaw.logPath : '',
101
111
  },
102
112
  }