dsh-miloco-bridge 0.1.1 → 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/README.md CHANGED
@@ -48,7 +48,7 @@ dsh plugin --profile web add github:exa160/dsh-miloco-bridge
48
48
 
49
49
  ```bash
50
50
  # 发布包
51
- dsh plugin --profile web add file:dist/dsh-miloco-bridge-0.1.1.tgz
51
+ dsh plugin --profile web add file:dist/dsh-miloco-bridge-0.1.2.tgz
52
52
 
53
53
  # 本地开发(link)
54
54
  ./install.sh --profile web
@@ -102,7 +102,7 @@ systemctl --user restart dsh-web.service
102
102
  ## 开发
103
103
 
104
104
  ```bash
105
- npm test # 65 项测试
105
+ npm test # 76 项测试
106
106
  npm run selftest # 离线自检
107
107
  npm run verify:readonly # 真实 Miloco 只读接口
108
108
  npm run pack:bundle # 生成 dist/*.tgz
package/cordis.patch.yml CHANGED
@@ -53,10 +53,22 @@
53
53
  minLevel: "all"
54
54
  deploy:
55
55
  repo: "https://github.com/XiaoMi/xiaomi-miloco"
56
+ # 一键部署下载官方 release install.sh,不再 clone 源码。
56
57
  # 留空 = $DSH_HOME/plugin-data/dsh-miloco-bridge/xiaomi-miloco
57
- # 必须是 DSH 进程用户可读写的目录,不要指向只读工作区或插件 node_modules。
58
+ # (release 部署不创建该目录;仅保留给旧源码部署/卸载清理)
58
59
  sourceDir: ""
59
- installArgs: ["--dev", "--skip-openclaw"]
60
+ # --skip-openclaw 阻止上游安装 OpenClaw 插件;--agent-platform=openclaw
61
+ # 只用于通过上游非交互式平台检测,实际由 DSH adapter 接管。
62
+ installArgs: ["--skip-openclaw", "--agent-platform=openclaw", "--lang=zh"]
63
+ # 直连 GitHub 失败时按顺序尝试这些镜像(直接拼接原 URL)。
64
+ githubMirrors:
65
+ - "https://gh-proxy.com/"
66
+ - "https://gh-proxy.org/"
67
+ # release 包默认从第一个镜像下载;留空 = 自动拼接 releases/download。
68
+ downloadSite: ""
69
+ # 额外的 uv 索引,不覆盖用户默认索引;PyPI 带 upload-time 元数据,
70
+ # 部分国内镜像缺少该元数据会触发安装器 exclude-newer 解析失败。
71
+ uvIndex: "https://pypi.org/simple"
60
72
  # 设置页里不展示任何「DSH 默认模型」配置项。模型下拉只有
61
73
  # 「跟随该对话」+ 实际模型;不选模型就完全不动会话的模型选择。
62
74
  # ------------------------------------------------------------------
@@ -83,3 +95,68 @@
83
95
  overlayFile: ""
84
96
  stateFile: ""
85
97
  cwd: ""
98
+ # 保留标准 DSH Agent 的工具面;子插件只在 miloco 预设内生效。
99
+ - id: tool-bash
100
+ name: '@deepseek-ai/dsh-tool-bash'
101
+ disabled: !!js process.platform === 'win32'
102
+ - id: tool-pwsh
103
+ name: '@deepseek-ai/dsh-tool-pwsh'
104
+ disabled: !!js process.platform !== 'win32'
105
+ - id: tool-fs
106
+ name: '@deepseek-ai/dsh-tool-fs'
107
+ - id: tool-fs-search
108
+ name: '@deepseek-ai/dsh-tool-fs-search'
109
+ config:
110
+ sampleOverCapGlobResults: false
111
+ - id: tool-jobs
112
+ name: '@deepseek-ai/dsh-tool-jobs'
113
+ - id: tool-schedule
114
+ name: '@deepseek-ai/dsh-tool-schedule'
115
+ - id: skill-filesystem
116
+ name: '@deepseek-ai/dsh-skill-filesystem'
117
+ - id: tool-skill
118
+ name: '@deepseek-ai/dsh-tool-skill'
119
+ - id: command-goal
120
+ name: '@deepseek-ai/dsh-command-goal'
121
+ - id: tool-goal
122
+ name: '@deepseek-ai/dsh-tool-goal'
123
+ - id: tool-ask-user
124
+ name: '@deepseek-ai/dsh-tool-ask-user'
125
+ - id: tool-todo
126
+ name: '@deepseek-ai/dsh-tool-todo'
127
+ config:
128
+ allowParallelInProgress: true
129
+ - id: tool-web
130
+ name: '@deepseek-ai/dsh-tool-web'
131
+ config:
132
+ fetch: true
133
+ searchTimeoutMs: 60000
134
+ - id: present
135
+ name: '@deepseek-ai/dsh-tool-present'
136
+ - id: delegation
137
+ name: cordis:group
138
+ group: true
139
+ isolate:
140
+ workflowEngine: true
141
+ config:
142
+ - id: tool-subagent-control
143
+ name: '@deepseek-ai/dsh-tool-subagent-control'
144
+ - id: tool-subagent-list-agents
145
+ name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
146
+ - id: tool-subagent
147
+ name: '@deepseek-ai/dsh-tool-subagent'
148
+ config:
149
+ provider: spawn
150
+ toolName: subagent
151
+ modelSelectionSettings: true
152
+ - id: tool-subagent-fork
153
+ name: '@deepseek-ai/dsh-tool-subagent'
154
+ config:
155
+ provider: fork
156
+ toolName: subagent_fork
157
+ - id: workflow-ptc
158
+ name: '@deepseek-ai/dsh-workflow-ptc'
159
+ config:
160
+ provider: spawn
161
+ - id: tool-workflow
162
+ name: '@deepseek-ai/dsh-tool-workflow'
@@ -36,7 +36,7 @@ lib/
36
36
  ├── miloco-client.js Miloco HTTP 客户端(连接优先级、envelope 解包)
37
37
  ├── push.js 入站推送:鉴权、目标解析、会话恢复、等待 turn
38
38
  ├── settings-api.js 设置 API 路由
39
- ├── deploy.js 一键部署(启动已有 / clone + 安装 / 写 adapter)
39
+ ├── deploy.js 一键部署(启动已有 / 下载 release 安装器 / 写 adapter)
40
40
  ├── uninstall.js 卸载 Miloco(停服务 / 移除 adapter / 可选删数据或源码)
41
41
  ├── perception.js 感知事件优先级解析与阈值过滤
42
42
  ├── client.js 浏览器设置页(无构建步骤)
@@ -194,13 +194,20 @@ push.perception.minLevel = all | low | medium | high
194
194
 
195
195
  1. 健康检查 `127.0.0.1:1810`;
196
196
  2. 已装 Miloco → `miloco-cli service start`;
197
- 3. 未装 → `git clone` 官方仓库到 `$DSH_HOME/plugin-data/dsh-miloco-bridge/xiaomi-miloco`;
198
- 4. 运行 `scripts/install.sh --dev --skip-openclaw`;
197
+ 3. 未装 → 下载官方 release `install.sh`(直连失败时走 GitHub 镜像);
198
+ 4. 以 headless agent 模式运行 `install.sh --agent-prepare` + `--agent-finish`
199
+ (`--skip-openclaw --agent-platform=openclaw --lang=zh`);
199
200
  5. 启动服务并等待健康;
200
201
  6. 安装 adapter,写令牌;
201
202
  7. 写 overlay 的连接与推送令牌。
202
203
 
203
- 所有子进程用 `spawn(command, args)`,不做 shell 字符串拼接;仓库地址必须是 `https://`。
204
+ 所有子进程用 `spawn(command, args)`,不做 shell 字符串拼接。
205
+ `--agent-platform=openclaw` 只用于通过上游平台的非交互检测,
206
+ `--skip-openclaw` 保证不安装 OpenClaw 插件,实际由 DSH adapter 接管。
207
+
208
+ 镜像模式的一键升级复用同一 release 安装器:下载 → `--agent-prepare` →
209
+ `--agent-finish` → 启动服务 → 写 adapter/webhook。升级状态与日志独立于部署,
210
+ 日志在 `$MILOCO_HOME/log/dsh-miloco-upgrade.log`;命令参数和子进程输出都会脱敏。
204
211
 
205
212
  ## 9. 安全边界
206
213
 
package/docs/INSTALL.md CHANGED
@@ -33,14 +33,14 @@ npm 新包可能处于审核期;若 `dsh plugin add dsh-miloco-bridge` 暂时
33
33
 
34
34
  ```bash
35
35
  npm run pack:bundle
36
- # dist/dsh-miloco-bridge-0.1.1.tgz
37
- dsh plugin --profile web add "file:/path/to/dist/dsh-miloco-bridge-0.1.1.tgz"
36
+ # dist/dsh-miloco-bridge-0.1.2.tgz
37
+ dsh plugin --profile web add "file:/path/to/dist/dsh-miloco-bridge-0.1.2.tgz"
38
38
  ```
39
39
 
40
40
  或:
41
41
 
42
42
  ```bash
43
- ./install.sh --profile web --bundle dist/dsh-miloco-bridge-0.1.1.tgz
43
+ ./install.sh --profile web --bundle dist/dsh-miloco-bridge-0.1.2.tgz
44
44
  ```
45
45
 
46
46
  ### 1.3 手动配置
@@ -94,16 +94,17 @@ install.sh
94
94
  3. 点「一键部署 Miloco」;
95
95
  4. 面板会实时显示部署日志。流程:
96
96
  - 若检测到已安装的 `miloco-cli` 与 `$MILOCO_HOME/config.json`,先尝试 `service start`;
97
- - 否则 clone `https://github.com/XiaoMi/xiaomi-miloco` 到
98
- `$DSH_HOME/plugin-data/dsh-miloco-bridge/xiaomi-miloco`;
99
- - 运行上游 `scripts/install.sh --dev --skip-openclaw`;
97
+ - 否则下载官方 release `install.sh`(GitHub 直连失败时走 `gh-proxy.com` / `gh-proxy.org`);
98
+ - 以 headless agent 模式运行 `install.sh --agent-prepare --skip-openclaw --agent-platform=openclaw --lang=zh`
99
+ 和 `install.sh --agent-finish ...`(`openclaw` 只用于通过上游平台检测,不会安装 OpenClaw 插件);
100
+ - 安装器使用 `UV_CACHE_DIR=$MILOCO_HOME/.uv-cache`、额外 `UV_INDEX`(默认 PyPI)
101
+ 和镜像下载 release 包(`MILOCO_DOWNLOAD_URL`,默认第一个 GitHub 镜像);
100
102
  - 启动后端并等待 `127.0.0.1:1810` 健康;
101
103
  - 安装本插件的 adapter,生成/复用推送令牌,写入 overlay。
102
104
  5. 部署完成后按面板提示绑定米家账号、开启摄像头感知。
103
105
 
104
- > **部署目录必须可写**:默认是 `$DSH_HOME/plugin-data/dsh-miloco-bridge/xiaomi-miloco`
105
- > (`$DSH_HOME` 默认 `~/.dsh`,跟随环境变量)。不要指向只读工作区、`node_modules`
106
- > 或其它 DSH 进程用户不可写的路径;部署前会显式检查并给出中文错误。
106
+ > **MILOCO_HOME 必须可写**:默认 `~/.miloco`,部署前会显式检查并给出中文错误。
107
+ > release 部署不再创建源码目录;`deploy.sourceDir` 仅用于旧源码部署或卸载清理。
107
108
 
108
109
  > 一键部署会运行上游安装器,可能安装 Python 依赖并占用较多磁盘。
109
110
  > 请先确认你接受 Xiaomi Miloco 的许可证与资源开销。
@@ -272,10 +273,20 @@ systemctl --user restart dsh-web.service
272
273
 
273
274
  ```bash
274
275
  npm run pack:bundle
275
- dsh plugin --profile web add "file:dist/dsh-miloco-bridge-0.1.1.tgz"
276
+ dsh plugin --profile web add "file:dist/dsh-miloco-bridge-0.1.2.tgz"
276
277
  systemctl --user restart dsh-web.service
277
278
  ```
278
279
 
280
+ ### 8.3 Miloco 版本更新
281
+
282
+ 「运行状态 → 检查更新」按顺序检查:Miloco 后端 → 直连 GitHub → GitHub 镜像。
283
+
284
+ - 后端能访问 GitHub:走 Miloco 原有一键升级;
285
+ - 后端不可达但镜像可用:由插件下载 release 安装器并执行 headless 升级
286
+ (`--agent-prepare` + `--agent-finish`,继续使用 `MILOCO_HOME` 和镜像下载);
287
+ - 源码/dev 部署不提供一键升级;
288
+ - 升级过程写入 `$MILOCO_HOME/log/dsh-miloco-upgrade.log`。
289
+
279
290
  卸载插件:
280
291
 
281
292
  ```bash
package/docs/TOOLS.md CHANGED
@@ -12,7 +12,7 @@
12
12
  |---|---|---|---|
13
13
  | `miloco_status` | — | `/api/admin/status` + `/api/perception/engine/status` | 运行状态、账号、引擎、数据库 |
14
14
  | `miloco_capabilities` | — | — | 当前模式与已注册工具 |
15
- | `miloco_device_list` | `room?` | `/api/miot/device_list` | 设备清单;`room` 在本地按 `room_name` 过滤 |
15
+ | `miloco_device_list` | `room?`, `name?`, `online_only?`, `limit?`, `offset?`, `include?[]` | `/api/miot/device_list` | 默认 `did/name/online/room_name/model/home_id`,默认最多 500 台;`include` 可取 `icon/local_ip/home_name/order_time/lan_online/sub_devices` |
16
16
  | `miloco_device_spec` | `did` | `/api/miot/devices/{did}/spec` | SPEC:iid、access、枚举、范围 |
17
17
  | `miloco_device_status` | `did`, `iid?` | `/api/miot/devices/{did}/status` | 当前属性值 |
18
18
  | `miloco_device_history` | — | `/api/miot/device_history` | 最近访问的 iid |
@@ -220,7 +220,7 @@ dsh-miloco-bridge: push delivery failed ... # 仅在失败时
220
220
 
221
221
  ```bash
222
222
  npm run pack:bundle
223
- tar -tzf dist/dsh-miloco-bridge-0.1.1.tgz | head -30
223
+ tar -tzf dist/dsh-miloco-bridge-0.1.2.tgz | head -30
224
224
  ```
225
225
 
226
226
  预期包含:`package/lib/*`、`package/skills/*/SKILL.md`、`package/adapter/dsh_adapter.py`、
@@ -230,7 +230,7 @@ tar -tzf dist/dsh-miloco-bridge-0.1.1.tgz | head -30
230
230
 
231
231
  ```bash
232
232
  DSH_HOME=/tmp/dsh-miloco-bundle-verify \
233
- dsh plugin --profile web add "file:$PWD/dist/dsh-miloco-bridge-0.1.1.tgz"
233
+ dsh plugin --profile web add "file:$PWD/dist/dsh-miloco-bridge-0.1.2.tgz"
234
234
  ```
235
235
 
236
236
  ---
package/lib/client.js CHANGED
@@ -755,6 +755,8 @@ window.__ModuleLoader__.load({
755
755
  h('div', { className: 'muted' },
756
756
  backend.error || `连不上 ${connection.baseUrl || '(未配置地址)'}。`
757
757
  + '插件本身可以独立安装;部署或连接之后才会出现设备与感知能力。'),
758
+ h('div', { className: 'muted' },
759
+ '一键部署会下载官方 release 安装器(支持 GitHub 镜像),不会在本机编译源码。'),
758
760
  h('div', { className: 'row' },
759
761
  h('button', { type: 'button', className: 'primary', disabled: busy, onClick: deploy },
760
762
  busy ? '部署中…' : '一键部署 Miloco'),
@@ -926,14 +928,19 @@ window.__ModuleLoader__.load({
926
928
  const upgradeInfo = versionInfo?.upgrade ?? null
927
929
  const currentVersion = upgradeInfo?.current || versionData?.version || ''
928
930
  const latestVersion = upgradeInfo?.latest || ''
929
- const deployKind = upgradeInfo?.deploy_kind || ''
931
+ const deployKind = upgradeInfo?.deploy_kind
932
+ || (currentVersion && /\.dev\d*|\+/.test(currentVersion) ? 'dev' : '')
930
933
  const hasUpdate = Boolean(upgradeInfo?.has_update)
931
934
  const isDevDeployment = deployKind === 'dev'
935
+ const upgradeSource = upgradeInfo?.source || 'backend'
936
+ const mirrorCheck = upgradeSource === 'mirror'
937
+ const upgradeUnavailable = !upgradeInfo || upgradeInfo.reachable === false
932
938
  const releaseUrl = upgradeInfo?.release_url || ''
933
939
  const sourceRoot = versionInfo?.sourceRoot || deployState.sourceDir || ''
934
940
  const gitDirty = Boolean(versionData?.git?.dirty)
935
941
  const commitShort = versionData?.git?.commit_short || ''
936
942
  const upgradePhase = upgradeStatus?.phase || ''
943
+ const upgradeLogPath = upgradeStatus?.logPath || ''
937
944
  const upgradePhaseLabels = {
938
945
  idle: '空闲',
939
946
  starting: '准备中',
@@ -952,9 +959,11 @@ window.__ModuleLoader__.load({
952
959
  ? ['', versionBusy ? '读取中' : '未知']
953
960
  : isDevDeployment
954
961
  ? ['', '源码部署']
955
- : hasUpdate
956
- ? ['warn', '有新版本']
957
- : ['ok', '已是最新']
962
+ : upgradeUnavailable
963
+ ? ['warn', '检查失败']
964
+ : hasUpdate
965
+ ? ['warn', '有新版本']
966
+ : ['ok', '已是最新']
958
967
 
959
968
  cards.push(h('div', { className: 'card', key: 'runtime' },
960
969
  h('div', { className: 'cardHead' },
@@ -1027,12 +1036,18 @@ window.__ModuleLoader__.load({
1027
1036
  latestVersion
1028
1037
  ? h('span', null, `最新正式版 ${latestVersion}`)
1029
1038
  : null,
1039
+ mirrorCheck
1040
+ ? h('span', {
1041
+ className: 'muted',
1042
+ title: 'GitHub 直连不可用,已通过镜像检查',
1043
+ }, '镜像')
1044
+ : null,
1030
1045
  releaseUrl
1031
1046
  ? h('a', {
1032
1047
  href: releaseUrl,
1033
1048
  target: '_blank',
1034
1049
  rel: 'noopener noreferrer',
1035
- title: '在 GitHub 查看发布说明',
1050
+ title: '查看发布说明',
1036
1051
  }, '发布页')
1037
1052
  : null,
1038
1053
  isDevDeployment && devUpdateCommand
@@ -1050,6 +1065,12 @@ window.__ModuleLoader__.load({
1050
1065
  disabled: versionBusy,
1051
1066
  onClick: () => loadVersion(true),
1052
1067
  }, versionBusy ? '检查中…' : '检查更新'),
1068
+ upgradeUnavailable
1069
+ ? h('span', {
1070
+ className: 'versionDirty',
1071
+ title: upgradeInfo?.error || 'GitHub 与镜像均不可达',
1072
+ }, '更新检查不可用')
1073
+ : null,
1053
1074
  isDevDeployment && gitDirty
1054
1075
  ? h('span', {
1055
1076
  className: 'versionDirty',
@@ -1083,7 +1104,7 @@ window.__ModuleLoader__.load({
1083
1104
  }, '复制命令')))
1084
1105
  : null,
1085
1106
  upgradePhase === 'failed'
1086
- ? h('div', { className: 'muted versionNote' }, '上次升级失败,可查看 Miloco 的 upgrade.log 后重试。')
1107
+ ? h('div', { className: 'muted versionNote' }, `上次升级失败${upgradeLogPath ? `,可查看 ${upgradeLogPath}` : ',可查看 Miloco 的 upgrade.log'} 后重试。`)
1087
1108
  : null,
1088
1109
  upgradeConfirmOpen
1089
1110
  ? h('div', { className: 'danger' },
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
  }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { access } from 'node:fs/promises'
24
24
  import { dirname, join } from 'node:path'
25
+ import { fetchLatestRelease, inferDeployKind, isNewerRelease, normalizeReleaseVersion } from './github-release.js'
25
26
  import { MilocoError, readMilocoConfig } from './miloco-client.js'
26
27
  import { normalizePerceptionLevel, PERCEPTION_LEVELS } from './perception.js'
27
28
  import { AGENT_MODES, describeMode, readSessions } from './state.js'
@@ -212,6 +213,8 @@ export function registerSettingsApi(options) {
212
213
  const { ctx, config, client, overlay } = options
213
214
  const log = options.logger ?? ctx.logger
214
215
  const disposers = []
216
+ /** @type {null | {at: number, source: string, has_update: boolean, current: string, latest: string}} */
217
+ let lastUpgradeCheck = null
215
218
 
216
219
  /**
217
220
  * Register one exact route.
@@ -485,7 +488,7 @@ export function registerSettingsApi(options) {
485
488
  : (config.deploy?.sourceDir || '')
486
489
 
487
490
  try {
488
- const [version, upgrade, upgradeStatus] = await Promise.all([
491
+ const [version, backendUpgrade, upgradeStatus] = await Promise.all([
489
492
  client.get('/api/admin/version', { signal: AbortSignal.timeout(8_000) }),
490
493
  client.get('/api/admin/upgrade/check', {
491
494
  query: { force: force ? 'true' : 'false' },
@@ -493,6 +496,55 @@ export function registerSettingsApi(options) {
493
496
  }).catch(() => null),
494
497
  client.get('/api/admin/upgrade/status', { signal: AbortSignal.timeout(8_000) }).catch(() => null),
495
498
  ])
499
+ let upgrade = backendUpgrade
500
+ if (!upgrade || upgrade.reachable === false) {
501
+ const current = String(upgrade?.current || version?.version || '')
502
+ const deployKind = upgrade?.deploy_kind || inferDeployKind(current)
503
+ try {
504
+ const latest = await fetchLatestRelease({
505
+ repo: config.deploy?.repo,
506
+ apiBase: config.deploy?.githubApi,
507
+ mirrors: config.deploy?.githubMirrors,
508
+ timeoutMs: 6_000,
509
+ })
510
+ upgrade = {
511
+ current,
512
+ latest: normalizeReleaseVersion(latest.tag),
513
+ has_update: Boolean(current) && deployKind === 'release'
514
+ && isNewerRelease(current, latest.tag),
515
+ deploy_kind: deployKind,
516
+ release_url: latest.release_url || latest.html_url,
517
+ reachable: true,
518
+ source: latest.source,
519
+ mirror: latest.mirror || null,
520
+ checked_at: Math.floor(Date.now() / 1000),
521
+ }
522
+ } catch (fallbackError) {
523
+ const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
524
+ upgrade = {
525
+ ...(upgrade ?? {}),
526
+ current,
527
+ latest: null,
528
+ has_update: false,
529
+ deploy_kind: deployKind,
530
+ release_url: null,
531
+ reachable: false,
532
+ source: 'none',
533
+ error: message,
534
+ }
535
+ }
536
+ } else {
537
+ upgrade = { ...upgrade, source: upgrade.source || 'backend' }
538
+ }
539
+ if (upgrade) {
540
+ lastUpgradeCheck = {
541
+ at: Date.now(),
542
+ source: upgrade.source || 'backend',
543
+ has_update: Boolean(upgrade.has_update),
544
+ current: String(upgrade.current || ''),
545
+ latest: String(upgrade.latest || ''),
546
+ }
547
+ }
496
548
  sendJson(res, 200, {
497
549
  ok: true,
498
550
  reachable: true,
@@ -521,6 +573,24 @@ export function registerSettingsApi(options) {
521
573
  }
522
574
  const body = await mutateBody(req, res)
523
575
  if (body === null) return
576
+ const recent = lastUpgradeCheck && Date.now() - lastUpgradeCheck.at < 10 * 60_000
577
+ const pluginUpgrade = recent
578
+ && lastUpgradeCheck.source !== 'backend'
579
+ && lastUpgradeCheck.has_update
580
+ && typeof options.deploy?.startUpgrade === 'function'
581
+ if (pluginUpgrade) {
582
+ const proto = String(req.headers['x-forwarded-proto'] || 'http').split(',')[0].trim()
583
+ const origin = typeof req.headers.origin === 'string' && req.headers.origin
584
+ ? req.headers.origin
585
+ : `${proto}://${req.headers.host ?? '127.0.0.1:3080'}`
586
+ const outcome = await options.deploy.startUpgrade({ origin })
587
+ if (!outcome.started) {
588
+ sendJson(res, 409, { ok: false, error: outcome.reason || '升级未启动', ...outcome })
589
+ return
590
+ }
591
+ sendJson(res, 200, { ok: true, source: 'plugin', ...outcome })
592
+ return
593
+ }
524
594
  // Upstream only allows /upgrade/run for release deployments. It performs
525
595
  // its own has_update check; surface its 400 reason verbatim.
526
596
  try {
@@ -541,6 +611,20 @@ export function registerSettingsApi(options) {
541
611
  sendJson(res, 405, { ok: false, error: 'method not allowed' })
542
612
  return
543
613
  }
614
+ const pluginState = options.deploy?.upgradeState?.()
615
+ if (pluginState && pluginState.step !== 'idle') {
616
+ const phaseMap = {
617
+ 'downloading-installer': 'downloading',
618
+ 'starting-service': 'restarting',
619
+ wiring: 'installing',
620
+ }
621
+ sendJson(res, 200, {
622
+ ok: true,
623
+ source: 'plugin',
624
+ status: { ...pluginState, phase: phaseMap[pluginState.step] || pluginState.step },
625
+ })
626
+ return
627
+ }
544
628
  try {
545
629
  const status = await client.get('/api/admin/upgrade/status', {
546
630
  signal: AbortSignal.timeout(8_000),
package/lib/tools.js CHANGED
@@ -23,6 +23,16 @@ import { MilocoError } from './miloco-client.js'
23
23
  import { PERCEPTION_LEVELS } from './perception.js'
24
24
  import { noteSession } from './state.js'
25
25
 
26
+ /** Extra device fields that callers may request explicitly. */
27
+ const DEVICE_LIST_EXTRA_FIELDS = [
28
+ 'icon',
29
+ 'local_ip',
30
+ 'home_name',
31
+ 'order_time',
32
+ 'lan_online',
33
+ 'sub_devices',
34
+ ]
35
+
26
36
  /**
27
37
  * Serialise a canonical tool value as readable JSON text.
28
38
  * @param {unknown} value - Value to serialise.
@@ -134,15 +144,63 @@ export function createMilocoTools(options) {
134
144
 
135
145
  tools.push(defineMilocoTool({
136
146
  name: 'miloco_device_list',
137
- description: '列出米家设备(did、名称、在线状态、所属房间、设备类型)。要控制某台设备前,先用本工具拿到 did。',
147
+ description: '列出米家设备。默认返回 did、名称、在线状态、房间、型号和 home_id;'
148
+ + '可用 include 显式取回 icon/local_ip 等额外字段。默认最多 500 台,超过时用 offset 翻页。',
138
149
  parameters: {
139
- room: { type: 'string', description: '按房间名过滤(本地匹配 room_name),留空返回全部设备' },
150
+ room: { type: 'string', description: '按房间名过滤(本地匹配 room_name)' },
151
+ name: { type: 'string', description: '按设备名过滤(本地子串匹配,忽略大小写)' },
152
+ online_only: { type: 'boolean', description: '只返回在线设备' },
153
+ limit: {
154
+ type: 'integer',
155
+ minimum: 1,
156
+ maximum: 500,
157
+ description: '最多返回多少台,默认 500',
158
+ },
159
+ offset: { type: 'integer', minimum: 0, description: '从第几台开始,默认 0' },
160
+ include: {
161
+ type: 'array',
162
+ items: { type: 'string', enum: DEVICE_LIST_EXTRA_FIELDS },
163
+ description: '额外字段:icon / local_ip / home_name / order_time / lan_online / sub_devices',
164
+ },
140
165
  },
141
166
  async run(args, exec) {
142
167
  const devices = await call('GET', '/api/miot/device_list', { signal: exec?.signal })
168
+ const list = Array.isArray(devices) ? devices : []
143
169
  const room = typeof args.room === 'string' ? args.room.trim().toLowerCase() : ''
144
- if (!room || !Array.isArray(devices)) return devices
145
- return devices.filter((device) => String(device?.room_name ?? device?.room ?? '').toLowerCase().includes(room))
170
+ const name = typeof args.name === 'string' ? args.name.trim().toLowerCase() : ''
171
+ const onlineOnly = args.online_only === true
172
+ const includes = Array.isArray(args.include)
173
+ ? [...new Set(args.include.filter(field => typeof field === 'string'))]
174
+ : []
175
+ for (const field of includes) {
176
+ if (!DEVICE_LIST_EXTRA_FIELDS.includes(field)) throw new Error(`include 不支持字段:${field}`)
177
+ }
178
+ const limit = args.limit === undefined ? 500 : Number(args.limit)
179
+ const offset = args.offset === undefined ? 0 : Number(args.offset)
180
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error('limit 必须是 1~500 的整数')
181
+ if (!Number.isInteger(offset) || offset < 0) throw new Error('offset 必须是非负整数')
182
+ const filtered = list.filter((device) => {
183
+ if (!device || typeof device !== 'object') return false
184
+ if (onlineOnly && device.online !== true) return false
185
+ if (room && !String(device.room_name ?? device.room ?? '').toLowerCase().includes(room)) return false
186
+ if (name && !String(device.name ?? '').toLowerCase().includes(name)) return false
187
+ return true
188
+ })
189
+ const page = filtered.slice(offset, offset + limit).map((device) => {
190
+ const row = {
191
+ did: device.did ?? '',
192
+ name: device.name ?? '',
193
+ online: device.online === true,
194
+ room_name: device.room_name ?? '',
195
+ model: device.model ?? '',
196
+ home_id: device.home_id ?? '',
197
+ }
198
+ for (const field of includes) {
199
+ if (Object.prototype.hasOwnProperty.call(device, field)) row[field] = device[field]
200
+ }
201
+ return row
202
+ })
203
+ return { total: filtered.length, returned: page.length, offset, limit, devices: page }
146
204
  },
147
205
  }))
148
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-miloco-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Unofficial DSH bridge for Xiaomi Miloco: an isolated Agent preset for Miloco tools, native tools/skills, Web settings, perception-priority filtering, and Miloco -> DSH push.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -12,7 +12,7 @@ whenToUse: 用户说“开/关/调/查某台设备”“客厅多少度”“执
12
12
 
13
13
  | 目的 | 工具 |
14
14
  |---|---|
15
- | 找设备 did | `miloco_device_list(room?)` |
15
+ | 找设备 did | `miloco_device_list(room?, online_only?, name?, include?)` |
16
16
  | 读 SPEC / iid | `miloco_device_spec(did)` |
17
17
  | 读当前属性 | `miloco_device_status(did, iid?)` |
18
18
  | 控制设备(写) | `miloco_device_control(...)` |
@@ -21,7 +21,7 @@ whenToUse: 用户说“开/关/调/查某台设备”“客厅多少度”“执
21
21
 
22
22
  ## 查询流程
23
23
 
24
- 1. `miloco_device_list` 拿 did。名字相同或跨房间时先缩小范围,不猜。
24
+ 1. `miloco_device_list` 拿 did(结果在 `devices` 数组里)。名字相同或跨房间时用 `name` / `room` / `online_only` 先缩小范围,不猜。
25
25
  2. `miloco_device_status` 读状态。传 `iid` 读单项,不传读全部。
26
26
  3. 回答里带上设备名、房间、关键数值和时间(若后端返回)。
27
27
 
@@ -42,6 +42,7 @@ whenToUse: 用户说“开/关/调/查某台设备”“客厅多少度”“执
42
42
  - 门锁、燃气阀、摄像头开关、厨房电器等有物理风险的操作,先说明将要做什么并取得确认。
43
43
  - 不要把“感知事件里的 did”当成控制目标:事件 did 通常是摄像头/传感器。
44
44
  - 不确定设备是否存在时重新 `miloco_device_list` 或 `miloco_home_info(refresh=true)`,禁止编造 did。
45
+ - 需要 `icon`、`local_ip` 等默认裁剪字段时,用 `include=[...]` 显式请求,不要为了一个字段重拉全量原始对象。
45
46
 
46
47
  ## 场景
47
48