@bolloon/bolloon-agent 0.4.33 → 0.5.1

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.
Files changed (214) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +580 -40
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/model-policy.js +651 -0
  31. package/dist/agents/network-pulse.js +304 -18
  32. package/dist/agents/pi-sdk-tools.js +84 -33
  33. package/dist/agents/pi-sdk.js +157 -42
  34. package/dist/agents/run-store.js +71 -5
  35. package/dist/agents/skill-readiness.js +52 -10
  36. package/dist/agents/subagent-manager.js +209 -3
  37. package/dist/agents/task/task-runner.js +17 -4
  38. package/dist/agents/task-group.js +8 -51
  39. package/dist/agents/task-public-text.js +89 -0
  40. package/dist/agents/workflow-pivot-loop.js +19 -12
  41. package/dist/cli/clipboard.js +65 -0
  42. package/dist/cli/commands/index.js +1 -0
  43. package/dist/cli/commands/tasks.js +350 -2
  44. package/dist/cli/identity-command.js +177 -0
  45. package/dist/cli/ink-app.js +145 -17
  46. package/dist/cli/log-gate.js +386 -0
  47. package/dist/cli/mention-data.js +2 -0
  48. package/dist/cli/model-selector.js +538 -0
  49. package/dist/cli/reply-hygiene.js +54 -0
  50. package/dist/cli/setup-wizard.js +757 -69
  51. package/dist/cli/startup-notice.js +160 -0
  52. package/dist/cli/stores.js +22 -1
  53. package/dist/cli/theme.js +49 -0
  54. package/dist/cli/tui-select.js +970 -0
  55. package/dist/cli/update-commands.js +150 -24
  56. package/dist/cli-entry.js +137 -20
  57. package/dist/electron-build/agents/contacts/chain.js +26 -6
  58. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  59. package/dist/electron-build/agents/execution-supervisor.js +578 -38
  60. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  61. package/dist/electron-build/agents/external-events.js +34 -5
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/goal-criteria.js +10 -3
  64. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  65. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  66. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  67. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  68. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  69. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  70. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  71. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  72. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  73. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  74. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  75. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  76. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  86. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  87. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  88. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  89. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  90. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  91. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  92. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  93. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  94. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  95. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  96. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  97. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  98. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  99. package/dist/electron-build/agents/goal-store.js +35 -1
  100. package/dist/electron-build/agents/goal-store.js.map +1 -1
  101. package/dist/electron-build/agents/model-policy.js +706 -0
  102. package/dist/electron-build/agents/model-policy.js.map +1 -0
  103. package/dist/electron-build/agents/network-pulse.js +307 -19
  104. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  105. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  106. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  107. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  108. package/dist/electron-build/agents/pi-sdk.js +156 -41
  109. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  110. package/dist/electron-build/agents/run-store.js +75 -6
  111. package/dist/electron-build/agents/run-store.js.map +1 -1
  112. package/dist/electron-build/agents/skill-readiness.js +52 -9
  113. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  114. package/dist/electron-build/agents/task/task-runner.js +16 -3
  115. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  116. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  117. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  118. package/dist/electron-build/llm/config-store.js +146 -6
  119. package/dist/electron-build/llm/config-store.js.map +1 -1
  120. package/dist/electron-build/llm/connection-probe.js +764 -0
  121. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  122. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  123. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  124. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  125. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  126. package/dist/electron-build/llm/model-catalog.js +778 -0
  127. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  128. package/dist/electron-build/llm/model-discovery.js +1202 -0
  129. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  130. package/dist/electron-build/llm/model-selection.js +1315 -0
  131. package/dist/electron-build/llm/model-selection.js.map +1 -0
  132. package/dist/electron-build/llm/pi-ai.js +70 -11
  133. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  134. package/dist/electron-build/llm/provider-catalog.js +969 -0
  135. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  136. package/dist/electron-build/llm/provider-registry.js +950 -0
  137. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  138. package/dist/electron-build/llm/tool-name.js +266 -0
  139. package/dist/electron-build/llm/tool-name.js.map +1 -0
  140. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  141. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  142. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  143. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  144. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  145. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  146. package/dist/electron-build/utils/dual-source.js +347 -0
  147. package/dist/electron-build/utils/dual-source.js.map +1 -0
  148. package/dist/electron-build/utils/update-manager.js +328 -104
  149. package/dist/electron-build/utils/update-manager.js.map +1 -1
  150. package/dist/electron-build/utils/update-state.js +9 -11
  151. package/dist/electron-build/utils/update-state.js.map +1 -1
  152. package/dist/electron-build/utils/version-identity.js +193 -0
  153. package/dist/electron-build/utils/version-identity.js.map +1 -0
  154. package/dist/electron-build/utils/version-info.js +57 -31
  155. package/dist/electron-build/utils/version-info.js.map +1 -1
  156. package/dist/index.js +385 -108
  157. package/dist/ios/bolloon-web.json +10 -0
  158. package/dist/ios/chain-explorer.js +617 -0
  159. package/dist/ios/explorer.html +163 -0
  160. package/dist/ios/index.html +96 -0
  161. package/dist/ios/mobile-agent.js +2 -2
  162. package/dist/ios/mobile-contacts.js +362 -0
  163. package/dist/ios/mobile-core.js +3102 -310
  164. package/dist/ios/mobile-privacy.js +185 -0
  165. package/dist/ios/mobile-tasks.js +339 -0
  166. package/dist/ios/mobile-update.js +1442 -0
  167. package/dist/ios/mobile.css +19 -0
  168. package/dist/ios/mobile.html +96 -0
  169. package/dist/ios/mobile.js +1013 -41
  170. package/dist/ios/routes-chain-index.js +108 -0
  171. package/dist/ios/routes-contacts.js +420 -0
  172. package/dist/ios/routes-llm-config.js +344 -52
  173. package/dist/ios/routes-mobile-tasks.js +353 -0
  174. package/dist/ios/routes-onchain-trade.js +99 -0
  175. package/dist/ios/routes-x402-info.js +1 -0
  176. package/dist/ios/server.js +921 -0
  177. package/dist/ios/sw.js +1 -1
  178. package/dist/ios/task-frame-server.js +211 -0
  179. package/dist/llm/config-store.js +146 -6
  180. package/dist/llm/connection-probe.js +744 -0
  181. package/dist/llm/custom-provider-store.js +224 -0
  182. package/dist/llm/data/provider-catalog-baked.js +19 -0
  183. package/dist/llm/model-catalog.js +717 -0
  184. package/dist/llm/model-discovery.js +1137 -0
  185. package/dist/llm/model-selection.js +1241 -0
  186. package/dist/llm/pi-ai.js +70 -11
  187. package/dist/llm/provider-catalog.js +898 -0
  188. package/dist/llm/provider-registry.js +917 -0
  189. package/dist/llm/tool-name.js +220 -0
  190. package/dist/orbitdb/cid-database.js +81 -16
  191. package/dist/orbitdb/ipfs-node.js +31 -2
  192. package/dist/setup/onboard.js +34 -8
  193. package/dist/utils/dual-source-facts.js +223 -0
  194. package/dist/utils/dual-source.js +297 -0
  195. package/dist/utils/update-cli.js +16 -21
  196. package/dist/utils/update-health.js +35 -5
  197. package/dist/utils/update-manager.js +325 -96
  198. package/dist/utils/update-state.js +13 -10
  199. package/dist/utils/version-identity.js +176 -0
  200. package/dist/utils/version-info.js +27 -22
  201. package/dist/web/bolloon-web.json +10 -0
  202. package/dist/web/mobile-contacts.js +44 -11
  203. package/dist/web/mobile-core.js +2901 -580
  204. package/dist/web/mobile-tasks.js +339 -0
  205. package/dist/web/mobile-update.js +1442 -0
  206. package/dist/web/mobile.css +19 -0
  207. package/dist/web/mobile.html +64 -0
  208. package/dist/web/mobile.js +838 -35
  209. package/dist/web/routes-llm-config.js +344 -52
  210. package/dist/web/routes-mobile-tasks.js +353 -0
  211. package/dist/web/server.js +163 -4
  212. package/dist/web/sw.js +1 -1
  213. package/package.json +3 -1
  214. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * dual-source-facts.ts — 双源的**语义核心** (2026-09-26, update-protocol §12)
3
+ *
4
+ * 为什么从 `dual-source.ts` 里抽出这一半: 手机端 WebView **没有 node:**, 但手机端双源更新
5
+ * 必须用**和桌面同一份**交叉校验 / 错误分类 / dev 快照比较 —— 否则同一件事在电脑上和手机上
6
+ * 会长出两套说法 (而"两个源指向不同版本就拒绝"这种事, 两套说法迟早有一处是错的)。
7
+ *
8
+ * 这里只放**纯函数与类型** (不 import 任何 `node:*`, 不在顶层读 `process.env`):
9
+ * · GitHub 侧错误分类 `classifyGithubError` (§12.3, 与 registry 侧并列不合并)
10
+ * · stable 的交叉校验 `crossCheckStable` (§12.1)
11
+ * · dev 的按 sha 比较 `compareDevSnapshots` (§12.1)
12
+ * · 事实/报告的紧凑视图 `toGithubReport` / `renderGithubReportLine`
13
+ *
14
+ * 需要网络与子进程的那一半 (真调 api.github.com / codeload 下载 / 现场构建 dev 快照)
15
+ * 仍在 `dual-source.ts` —— 它在这份纯模块之上, 且把这些名字原样再导出。
16
+ *
17
+ * 硬口径 (§12.5, 验收核心, 两边共用):
18
+ * - 源不可达 / 版本不存在 → **拒绝并说清** (带具体分类), 绝不静默装回旧版
19
+ * - 不编造不存在的路径: 本仓 GitHub 上**真的没有 Release** (0 个), 所以 "Release 缺" 时
20
+ * 如实报 "发布记录缺 Tag / Release" (提醒级), **不是** fake 一个 Release 出来
21
+ */
22
+ import { GITHUB_UPSTREAM_SLUG, DEV_BRANCH, DEV_CHANNEL_WARNING } from './version-identity.js';
23
+ export { DEV_CHANNEL_WARNING };
24
+ // ── 类型 ────────────────────────────────────────────────────────────────────
25
+ /**
26
+ * GitHub 侧错误的分类 (§12.3) —— 与 registry 的分类**并列且不合并**:
27
+ * offline 网络不可达 (ENOTFOUND/EAI_AGAIN/ECONNREFUSED/超时)
28
+ * rate_limited HTTP 403/429 (限流; 带重试时间)
29
+ * not_found HTTP 404 (没有 Release / 没有 master 分支 / 仓库不存在)
30
+ * http_error 其它 HTTP 状态 (5xx 等) —— 不塞进上面三类, 免得把"服务有问题"说成"网不通"
31
+ * parse_error 拿到了内容但解析不了
32
+ */
33
+ export const GITHUB_REASONS = ['offline', 'rate_limited', 'not_found', 'http_error', 'parse_error'];
34
+ export function toGithubReport(r) {
35
+ if (!r.ok) {
36
+ return {
37
+ reachable: false, reason: r.reason, detail: r.detail, retryAt: r.retryAt,
38
+ headSha: r.facts?.headSha ?? null, ref: r.facts?.ref ?? `refs/heads/${DEV_BRANCH}`,
39
+ releases: r.facts?.releases.length, tags: r.facts?.tags.length,
40
+ newestVersion: r.facts ? newestGithubVersion(r.facts) : null,
41
+ partial: r.facts?.partial, fetchedAt: r.facts?.fetchedAt,
42
+ };
43
+ }
44
+ return {
45
+ reachable: true, headSha: r.facts.headSha, ref: r.facts.ref,
46
+ releases: r.facts.releases.length, tags: r.facts.tags.length,
47
+ newestVersion: newestGithubVersion(r.facts), partial: r.facts.partial, fetchedAt: r.facts.fetchedAt,
48
+ };
49
+ }
50
+ export function renderGithubReportLine(g) {
51
+ if (!g)
52
+ return 'GitHub 源: 未查询';
53
+ if (!g.reachable) {
54
+ const retry = g.retryAt ? `, 限流重试时间 ${g.retryAt}` : '';
55
+ return `GitHub 源: 不可达 — github_unavailable(${g.reason}) ${g.detail || ''}${retry}`;
56
+ }
57
+ return `GitHub 源: 可达 · master HEAD ${g.headSha ? g.headSha.slice(0, 7) : '未知'} · Release ${g.releases ?? 0} 个 · Tag ${g.tags ?? 0} 个 · 最新版本标签 ${g.newestVersion || '无'}${g.partial ? ' (部分请求失败)' : ''}`;
58
+ }
59
+ // ── 错误分类 ────────────────────────────────────────────────────────────────
60
+ export const OFFLINE_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'EAI_FAIL', 'UND_ERR_CONNECT_TIMEOUT', 'CERT_HAS_EXPIRED']);
61
+ /**
62
+ * 从**错误链**里挖网络类证据。
63
+ *
64
+ * 为什么不能只看 `err.code`: `fetch()` 抛出来的是 `TypeError: fetch failed`, 真正的 socket 错误挂在
65
+ * `err.cause.code` (undici)。只看 `err.code` ⇒ 连不上的端口会被判成 http_error / registry_unavailable,
66
+ * 于是"源不可达"这一条在真跑里变成**分类错误**(真跑抓到过: 死端口 → registry_unavailable)。
67
+ * 浏览器侧则是 `message: 'Failed to fetch'` / `'Load failed'`。
68
+ */
69
+ export function collectNetEvidence(err) {
70
+ const codes = [];
71
+ const msgs = [];
72
+ let cur = err;
73
+ for (let depth = 0; cur && depth < 6; depth++) {
74
+ // AggregateError (多地址并发失败) 的成员各带一个 code
75
+ const inner = Array.isArray(cur.errors) ? cur.errors : [];
76
+ for (const e of inner) {
77
+ if (e?.code)
78
+ codes.push(String(e.code));
79
+ if (e?.message)
80
+ msgs.push(String(e.message));
81
+ }
82
+ if (cur.code)
83
+ codes.push(String(cur.code));
84
+ if (cur.errno)
85
+ codes.push(String(cur.errno));
86
+ if (cur.message)
87
+ msgs.push(String(cur.message));
88
+ cur = cur.cause;
89
+ }
90
+ const code = codes.find((c) => OFFLINE_CODES.has(c)) || codes[0] || '';
91
+ const message = msgs.join(' · ');
92
+ const offline = codes.some((c) => OFFLINE_CODES.has(c))
93
+ || /timeout|timed out|ENOTFOUND|getaddrinfo|network|socket hang up|超时|Failed to fetch|fetch failed|NetworkError|Load failed|ECONNREFUSED|ECONNRESET/i.test(message);
94
+ return { code, message, offline };
95
+ }
96
+ /** 网络类 → offline; 403/429 → rate_limited; 404 → not_found; 其它 → http_error。 */
97
+ export function classifyGithubError(err, httpStatus, headers = {}) {
98
+ const retryAt = headers['x-ratelimit-reset']
99
+ ? new Date(Number(headers['x-ratelimit-reset']) * 1000).toISOString()
100
+ : null;
101
+ const net = collectNetEvidence(err);
102
+ if (net.offline) {
103
+ return { kind: 'github_unavailable', reason: 'offline', detail: `${net.code || net.message || '网络不可达'}`, retryAt: null };
104
+ }
105
+ if (httpStatus === 403 || httpStatus === 429) {
106
+ return { kind: 'github_unavailable', reason: 'rate_limited', detail: `GitHub 返回 HTTP ${httpStatus} (限流/权限)`, retryAt };
107
+ }
108
+ if (httpStatus === 404) {
109
+ return { kind: 'github_unavailable', reason: 'not_found', detail: `GitHub 上没有 ${GITHUB_UPSTREAM_SLUG} 的该项记录 (HTTP 404)`, retryAt: null };
110
+ }
111
+ if (httpStatus && httpStatus >= 400) {
112
+ return { kind: 'github_unavailable', reason: 'http_error', detail: `GitHub 返回 HTTP ${httpStatus}`, retryAt };
113
+ }
114
+ return { kind: 'github_unavailable', reason: 'http_error', detail: net.code || net.message || '未知错误', retryAt: null };
115
+ }
116
+ /** registry 侧错误的分类 (§3) —— 与 `classifyGithubError` **并列, 不合并**。 */
117
+ export function classifyRegistryError(err, httpStatus) {
118
+ const net = collectNetEvidence(err);
119
+ if (net.offline) {
120
+ return { kind: 'offline', detail: `${net.code || net.message || '网络不可达'}` };
121
+ }
122
+ if (httpStatus && httpStatus >= 500)
123
+ return { kind: 'registry_unavailable', detail: `registry 返回 HTTP ${httpStatus}` };
124
+ if (httpStatus === 404)
125
+ return { kind: 'registry_unavailable', detail: `registry 上没有该包 (HTTP 404)` };
126
+ if (httpStatus && httpStatus >= 400)
127
+ return { kind: 'registry_unavailable', detail: `registry 返回 HTTP ${httpStatus}` };
128
+ return { kind: 'registry_unavailable', detail: net.code || net.message || '未知错误' };
129
+ }
130
+ // ── 版本标签比较 ────────────────────────────────────────────────────────────
131
+ /** GitHub 上出现过的最高版本号 (Release ∪ Tag, 去掉 v 前缀)。没有 → null。 */
132
+ export function newestGithubVersion(facts) {
133
+ const all = [...facts.releases, ...facts.tags.map((t) => t.name)]
134
+ .map((n) => String(n).trim().replace(/^v/, ''))
135
+ .filter((n) => /^\d+\.\d+\.\d+/.test(n));
136
+ if (all.length === 0)
137
+ return null;
138
+ return all.sort((a, b) => (numericCompare(a, b) < 0 ? 1 : -1))[0];
139
+ }
140
+ /** 只比数值段 (与 version-identity 的 compareVersions 同语义, 这里不再反向依赖它)。 */
141
+ export function numericCompare(a, b) {
142
+ const p = (v) => v.split('-')[0].split('.').map((x) => parseInt(x.replace(/\D.*$/, ''), 10) || 0);
143
+ const pa = p(a);
144
+ const pb = p(b);
145
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
146
+ const x = pa[i] ?? 0;
147
+ const y = pb[i] ?? 0;
148
+ if (x < y)
149
+ return -1;
150
+ if (x > y)
151
+ return 1;
152
+ }
153
+ return 0;
154
+ }
155
+ function hasName(names, v) {
156
+ const set = new Set(names.map((n) => String(n).trim().replace(/^v/, '')));
157
+ return set.has(v.replace(/^v/, ''));
158
+ }
159
+ /**
160
+ * stable 的交叉校验 (§12.1/§12.5): npm 仍是权威, GitHub 只回答"这次发布的记录在不在"。
161
+ *
162
+ * agree npm 的 latest 在 GitHub 上有同名 tag/Release → 两条记录指向同一版
163
+ * mismatch GitHub 记录了**更新的**版本而 registry 上没有 → 拒绝 (不许按 tag 去装)
164
+ * missing_record registry 有该版本但 GitHub 缺这条记录 → **提醒级, 不阻塞** (§12.5 原文:
165
+ * "报发布记录缺 Tag (提醒级, 不阻塞 stable 更新)")
166
+ * no_record_at_all GitHub 上一条版本记录都没有 → 如实报"暂无 tag 可交叉校验"(不编造路径)
167
+ */
168
+ export function crossCheckStable(registryLatest, facts) {
169
+ const common = {
170
+ registryLatest, githubVersion: newestGithubVersion(facts),
171
+ releases: facts.releases.length, tags: facts.tags.length,
172
+ };
173
+ if (!registryLatest) {
174
+ return { ...common, kind: 'no_record_at_all', hasTag: false, hasRelease: false, blocking: false, detail: 'npm 没有给出 latest, 无从交叉校验' };
175
+ }
176
+ const allNames = [...facts.releases, ...facts.tags.map((t) => t.name)];
177
+ const hasTag = hasName(facts.tags.map((t) => t.name), registryLatest);
178
+ const hasRelease = hasName(facts.releases, registryLatest);
179
+ const ghNewest = common.githubVersion;
180
+ /**
181
+ * **先判"GitHub 记录了比 registry 更新的发布"** —— 这是 §12.5 的 "Release 存在但 registry 上没有该版本 → cross_check_mismatch"。
182
+ * 放在 agree 之前: 即使 registry 的 latest 自己也有 tag, 只要 GitHub 上还多出一条更新的发布记录, 两个源对
183
+ * "当前最新是哪一版"就是不一致的 —— 这种不一致必须拦住, 不能因为"latest 有 tag"就宣布一致。
184
+ */
185
+ if (ghNewest && numericCompare(ghNewest, registryLatest) > 0) {
186
+ return {
187
+ ...common, kind: 'mismatch', hasTag, hasRelease, blocking: true,
188
+ detail: `GitHub 上有 ${ghNewest} 的发布记录, 但 npm registry 的 latest 还是 ${registryLatest} — 拒绝按 GitHub 的记录安装`,
189
+ };
190
+ }
191
+ if (hasTag || hasRelease) {
192
+ return {
193
+ ...common, kind: 'agree', hasTag, hasRelease, blocking: false,
194
+ detail: `npm latest=${registryLatest} 在 GitHub 上有同名记录 (${hasRelease ? `Release v${registryLatest}` : `Tag v${registryLatest}`}) — 两个源指向同一版`,
195
+ };
196
+ }
197
+ if (allNames.length === 0) {
198
+ return {
199
+ ...common, kind: 'no_record_at_all', hasTag: false, hasRelease: false, blocking: false,
200
+ detail: `GitHub 上暂无任何 tag / Release (0 个) — 暂无可交叉校验的记录, npm 仍是权威 (latest=${registryLatest})`,
201
+ };
202
+ }
203
+ return {
204
+ ...common, kind: 'missing_record', hasTag, hasRelease, blocking: false,
205
+ detail: `npm latest=${registryLatest} 在 GitHub 上没有对应记录 (GitHub 最新版本标签 ${ghNewest || '无'}; Release ${facts.releases.length} 个 / Tag ${facts.tags.length} 个) — 发布记录缺 Tag, 不阻塞 stable 更新`,
206
+ };
207
+ }
208
+ export function compareDevSnapshots(installedSha, headSha, ref = `refs/heads/${DEV_BRANCH}`) {
209
+ const short = (s) => (s ? s.slice(0, 7) : '未知');
210
+ if (!headSha) {
211
+ return { ref, headSha, installedSha, same: false, behind: false, detail: `${ref} 的 HEAD 读不到 — 无法判定 dev 快照新旧` };
212
+ }
213
+ if (!installedSha) {
214
+ return { ref, headSha, installedSha, same: false, behind: false, detail: `当前不是 dev 快照 (stable 安装) — 切到 dev 将得到 ${ref} @ ${short(headSha)}` };
215
+ }
216
+ const same = installedSha.slice(0, 7) === headSha.slice(0, 7);
217
+ return {
218
+ ref, headSha, installedSha, same, behind: !same,
219
+ detail: same
220
+ ? `已是最新的 dev 快照 (${ref} @ ${short(headSha)}) — 同一个 commit`
221
+ : `dev 快照落后: 已装 ${short(installedSha)} → master HEAD ${short(headSha)} (按 commit sha 判定, 不看版本号)`,
222
+ };
223
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * dual-source.ts — 双源 (npm registry + GitHub) 的**源事实** (2026-09-25, update-protocol §12)
3
+ *
4
+ * 为什么单独一个模块: §1.4 冻结的是 "npm 是唯一稳定渠道", §12 把它改成**双源但权威顺序不变**。
5
+ * 这件事不该塞进 update-manager (那会让"唯一决策者"同时变成"网络客户端"), 也不该让
6
+ * CLI / 健康检查各自造一份 GitHub 事实。所以:
7
+ *
8
+ * - 源事实 (GitHub 可达性 / tag / Release / master HEAD) 只在本模块查
9
+ * - 错误分类 `classifyGithubError` 与 §3 的 `classifyRegistryError` **并列, 不合并**
10
+ * - dev 快照的**取源与构建**也在这里 (update-manager 只负责"切换 + 验证 + 回滚", 不另造一套)
11
+ *
12
+ * 2026-09-26 拆分 (手机端要复用同一份语义): **纯语义那一半**去 `dual-source-facts.ts`
13
+ * (分类 / 交叉校验 / dev 快照比较 —— 无 `node:`, 因此能打进手机端 WebView bundle);
14
+ * 本文件只留**需要网络与子进程**的那一半 (真调 api.github.com / codeload / 现场构建),
15
+ * 并把纯那一半的名字原样再导出 —— 既有 `from './dual-source.js'` 的 import 一行不用改。
16
+ *
17
+ * 硬口径 (§12.5, 验收核心):
18
+ * - 源不可达 / 版本不存在 → **拒绝并说清** (带具体分类), 绝不静默装回旧版
19
+ * - 不编造不存在的路径: 本仓 GitHub 上**真的没有 Release** (0 个), 所以 "Release 缺" 时
20
+ * 如实报 "发布记录缺 Tag / Release" (提醒级), **不是** fake 一个 Release 出来
21
+ */
22
+ import * as fs from 'fs';
23
+ import * as fsp from 'fs/promises';
24
+ import * as path from 'path';
25
+ import * as http from 'http';
26
+ import * as https from 'https';
27
+ import { spawnSync } from 'child_process';
28
+ import { GITHUB_API_BASE, GITHUB_UPSTREAM_SLUG, DEV_BRANCH, devIdentity, baseVersionOf, readPackageAt, DEV_CHANNEL_WARNING, } from './version-info.js';
29
+ import { classifyGithubError, } from './dual-source-facts.js';
30
+ /**
31
+ * 纯语义那一半 (错误分类 / 交叉校验 / dev 快照比较 / 报告渲染) —— **唯一一份在
32
+ * `dual-source-facts.ts`** (桌面与手机端共用)。这里原样再导出: 桌面既有 import 路径不变,
33
+ * 手机端 WebView 直接 import 那份纯模块 (没有 `node:` ⇒ 能进 bundle)。
34
+ */
35
+ export { GITHUB_REASONS, OFFLINE_CODES, classifyGithubError, classifyRegistryError, newestGithubVersion, numericCompare, toGithubReport, renderGithubReportLine, crossCheckStable, compareDevSnapshots, DEV_CHANNEL_WARNING, } from './dual-source-facts.js';
36
+ /** 可选 GitHub token (只为提配额: 匿名 60 次/小时, 认证 5000 次/小时)。**永不打印、永不入库**。 */
37
+ function githubToken(env = process.env) {
38
+ const t = env.GITHUB_TOKEN || env.GH_TOKEN || env.BOLLOON_GITHUB_TOKEN;
39
+ return t && String(t).trim() ? String(t).trim() : null;
40
+ }
41
+ function httpGetRaw(url, timeoutMs, token) {
42
+ return new Promise((resolve, reject) => {
43
+ const client = url.startsWith('https') ? https : http;
44
+ const req = client.get(url, {
45
+ timeout: timeoutMs,
46
+ headers: {
47
+ Accept: 'application/vnd.github+json, application/json',
48
+ 'User-Agent': 'bolloon-update',
49
+ // token 只进请求头, 不进日志/状态/输出 (凭证红线)
50
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
51
+ },
52
+ }, (res) => {
53
+ let data = '';
54
+ res.on('data', (c) => { data += c; });
55
+ res.on('end', () => resolve({ status: res.statusCode || 0, body: data, headers: (res.headers || {}) }));
56
+ });
57
+ req.on('error', reject);
58
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
59
+ });
60
+ }
61
+ /**
62
+ * 查 GitHub 的三个事实: Releases / Tags / master HEAD。
63
+ * **任何一个请求失败 → 整条 `github_unavailable`** (带分类), 但把已拿到的那部分 facts 一起带回去
64
+ * (上层因此能说清"哪些拿到了、哪些没拿到", 而不是一句"更新失败")。
65
+ */
66
+ export async function fetchGithubFacts(opts = {}) {
67
+ const base = (opts.apiBase || GITHUB_API_BASE).replace(/\/+$/, '');
68
+ const slug = opts.slug || GITHUB_UPSTREAM_SLUG;
69
+ const timeout = opts.timeoutMs ?? 10000;
70
+ // 有 token 就用 (配额 60/h → 5000/h); 没有也能跑, 只是可能 403 限流
71
+ const token = opts.token !== undefined ? opts.token : githubToken();
72
+ const missing = [];
73
+ let releases = [];
74
+ let tags = [];
75
+ let headSha = null;
76
+ /** 失败累积 (第一个失败决定分类, 但所有失败都记进 missing) */
77
+ const failures = [];
78
+ const note = (e, what) => {
79
+ missing.push(what);
80
+ failures.push({ reason: e.reason, detail: `${what}: ${e.detail}`, retryAt: e.retryAt });
81
+ };
82
+ // 1. Releases (本仓真的没有 → 200 + [] , 不是错误)
83
+ try {
84
+ const r = await httpGetRaw(`${base}/repos/${slug}/releases?per_page=100`, timeout, token);
85
+ if (r.status >= 200 && r.status < 300) {
86
+ const arr = JSON.parse(r.body);
87
+ releases = Array.isArray(arr) ? arr.map((x) => String(x?.tag_name || '')).filter(Boolean) : [];
88
+ }
89
+ else {
90
+ note(classifyGithubError(null, r.status, r.headers), 'releases');
91
+ }
92
+ }
93
+ catch (e) {
94
+ note(classifyGithubError(e), 'releases');
95
+ }
96
+ // 2. Tags
97
+ try {
98
+ const r = await httpGetRaw(`${base}/repos/${slug}/tags?per_page=100`, timeout, token);
99
+ if (r.status >= 200 && r.status < 300) {
100
+ const arr = JSON.parse(r.body);
101
+ tags = Array.isArray(arr)
102
+ ? arr.map((x) => ({ name: String(x?.name || ''), sha: x?.commit?.sha ? String(x.commit.sha) : null })).filter((t) => t.name)
103
+ : [];
104
+ }
105
+ else {
106
+ note(classifyGithubError(null, r.status, r.headers), 'tags');
107
+ }
108
+ }
109
+ catch (e) {
110
+ note(classifyGithubError(e), 'tags');
111
+ }
112
+ // 3. master HEAD (dev 通道的身份来源)
113
+ try {
114
+ const r = await httpGetRaw(`${base}/repos/${slug}/commits/${DEV_BRANCH}`, timeout, token);
115
+ if (r.status >= 200 && r.status < 300) {
116
+ const j = JSON.parse(r.body);
117
+ headSha = j?.sha ? String(j.sha) : null;
118
+ if (!headSha)
119
+ note({ reason: 'parse_error', detail: 'commits/master 没有 sha 字段', retryAt: null }, 'head');
120
+ }
121
+ else {
122
+ // 404 在这里的语义就是"没有 master 分支"(§12.3)
123
+ note(classifyGithubError(null, r.status, r.headers), 'head');
124
+ }
125
+ }
126
+ catch (e) {
127
+ note(classifyGithubError(e), 'head');
128
+ }
129
+ const facts = {
130
+ slug, ref: `refs/heads/${DEV_BRANCH}`, headSha, releases, tags,
131
+ fetchedAt: new Date().toISOString(), partial: missing.length > 0, missing,
132
+ };
133
+ if (failures.length > 0) {
134
+ const first = failures[0];
135
+ return { ok: false, kind: 'github_unavailable', reason: first.reason, detail: first.detail, retryAt: first.retryAt, facts };
136
+ }
137
+ return { ok: true, facts };
138
+ }
139
+ function defaultRunNpm(args, cwd) {
140
+ const r = spawnSync('npm', args, { cwd, encoding: 'utf-8', timeout: 900_000, maxBuffer: 32 * 1024 * 1024 });
141
+ return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
142
+ }
143
+ /** 从 GitHub 取 master 的源码快照 (codeload tarball); 解到 destParent。 */
144
+ async function downloadGithubSnapshot(sha, destParent, env, timeoutMs) {
145
+ const template = env.BOLLOON_DEV_TARBALL_URL
146
+ || `https://codeload.github.com/${GITHUB_UPSTREAM_SLUG}/tar.gz/{sha}`;
147
+ const url = template.replace('{sha}', sha).replace('{branch}', DEV_BRANCH);
148
+ await fsp.mkdir(destParent, { recursive: true });
149
+ const file = path.join(destParent, `dev-${sha.slice(0, 7)}.tar.gz`);
150
+ try {
151
+ const buf = await new Promise((resolve, reject) => {
152
+ const client = url.startsWith('https') ? https : http;
153
+ const tk = githubToken(env);
154
+ const req = client.get(url, { timeout: timeoutMs, headers: { 'User-Agent': 'bolloon-update', ...(tk ? { Authorization: `Bearer ${tk}` } : {}) } }, (res) => {
155
+ if ((res.statusCode || 0) >= 400) {
156
+ reject(classifyGithubError(null, res.statusCode, res.headers));
157
+ return;
158
+ }
159
+ const chunks = [];
160
+ res.on('data', (c) => chunks.push(c));
161
+ res.on('end', () => resolve(Buffer.concat(chunks)));
162
+ });
163
+ req.on('error', reject);
164
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
165
+ });
166
+ await fsp.writeFile(file, buf);
167
+ }
168
+ catch (e) {
169
+ const cls = e?.kind === 'github_unavailable' ? e : classifyGithubError(e);
170
+ return { ok: false, kind: 'github_unavailable', reason: cls.reason, detail: `取 ${url} 失败: ${cls.detail}` };
171
+ }
172
+ const extract = path.join(destParent, 'src');
173
+ await fsp.mkdir(extract, { recursive: true });
174
+ const tar = spawnSync('tar', ['-xzf', file, '-C', extract], { encoding: 'utf-8', timeout: 300_000 });
175
+ if (tar.status !== 0)
176
+ return { ok: false, kind: 'github_unavailable', reason: 'http_error', detail: `解压快照失败: ${String(tar.stderr || '').slice(0, 200)}` };
177
+ const kids = (await fsp.readdir(extract)).filter((f) => !f.startsWith('.'));
178
+ if (kids.length !== 1)
179
+ return { ok: false, kind: 'github_unavailable', reason: 'http_error', detail: `快照解压后目录数不是 1 (${kids.length})` };
180
+ return { ok: true, dir: path.join(extract, kids[0]), source: 'github-tarball' };
181
+ }
182
+ /**
183
+ * 把 master HEAD 的一个 commit 变成**可安装的 dev 快照 tarball**:
184
+ * 取源码 → 校验 sha (本地源码树必须对得上) → 版本改成 `<base>+dev.<sha7>` → 需要时构建 → npm pack
185
+ *
186
+ * 复用原则 (§5 的刻意偏差): **不另造一套替换机制** —— 产物仍是 tarball, 仍交给 npm 替换,
187
+ * 仍然"切换后验证 + 失败回滚"。这里只负责"从哪个源取、装出来是什么身份"。
188
+ */
189
+ export async function prepareDevSnapshot(opts) {
190
+ const env = opts.env ?? process.env;
191
+ const runNpm = opts.runNpm || defaultRunNpm;
192
+ const stage = (s, d) => { try {
193
+ opts.onStage?.(s, d);
194
+ }
195
+ catch { /* 回调不许影响流程 */ } };
196
+ const sha = String(opts.sha || '').trim();
197
+ if (!/^[0-9a-f]{7,40}$/i.test(sha)) {
198
+ return { ok: false, kind: 'github_unavailable', reason: 'not_found', detail: `master HEAD 的 sha 不合法 (${sha || '空'}) — 拒绝按未知 commit 安装` };
199
+ }
200
+ let dir = '';
201
+ let source = '';
202
+ const envDir = env.BOLLOON_DEV_SOURCE_DIR;
203
+ if (envDir) {
204
+ if (!fs.existsSync(path.join(envDir, 'package.json'))) {
205
+ return { ok: false, kind: 'dev_source_failed', reason: 'source_dir_missing', detail: `BOLLOON_DEV_SOURCE_DIR=${envDir} 下没有 package.json` };
206
+ }
207
+ // 本地源码树必须**真的**是这个 commit —— 否则不许把它当 master HEAD 的快照
208
+ const local = spawnSync('git', ['-C', envDir, 'rev-parse', 'HEAD'], { encoding: 'utf-8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] });
209
+ const localSha = (local.stdout || '').trim();
210
+ if (local.status !== 0 || !localSha) {
211
+ if (env.BOLLOON_DEV_ALLOW_UNVERIFIED_SOURCE !== '1') {
212
+ return { ok: false, kind: 'dev_source_failed', reason: 'sha_unverifiable', detail: `${envDir} 不是 git 检出, 无法核对 sha=${sha.slice(0, 7)} (要强制使用请设 BOLLOON_DEV_ALLOW_UNVERIFIED_SOURCE=1)` };
213
+ }
214
+ }
215
+ else if (!localSha.startsWith(sha.slice(0, 7)) && !sha.startsWith(localSha.slice(0, 7))) {
216
+ return { ok: false, kind: 'dev_source_failed', reason: 'sha_mismatch', detail: `${envDir} 的 HEAD 是 ${localSha.slice(0, 7)}, 与 master HEAD ${sha.slice(0, 7)} 不是同一个 commit — 拒绝用错的源码做 dev 快照` };
217
+ }
218
+ dir = envDir;
219
+ source = `local-tree@${(localSha || sha).slice(0, 7)}`;
220
+ }
221
+ else {
222
+ stage('downloading', `取 GitHub master @ ${sha.slice(0, 7)}`);
223
+ const dl = await downloadGithubSnapshot(sha, opts.workDir, env, opts.timeoutMs ?? 120_000);
224
+ if (!dl.ok)
225
+ return dl;
226
+ dir = dl.dir;
227
+ source = dl.source;
228
+ }
229
+ // 身份 = 源码树自己的版本号 + dev.<sha>
230
+ const pkg = readPackageAt(dir);
231
+ if (!pkg?.version)
232
+ return { ok: false, kind: 'dev_source_failed', reason: 'no_version', detail: `${dir}/package.json 读不到版本` };
233
+ const baseVersion = baseVersionOf(String(pkg.version));
234
+ const identity = devIdentity(baseVersion, sha);
235
+ stage('staged', `打上 dev 身份 ${identity}`);
236
+ // 需要时构建 (GitHub tarball 里没有 dist/; 本地树通常已构建)
237
+ let built = false;
238
+ if (!fs.existsSync(path.join(dir, 'dist', 'cli-entry.js'))) {
239
+ stage('staged', '源码树缺少 dist/, 先装依赖再构建 (npm run build:main)');
240
+ if (!fs.existsSync(path.join(dir, 'node_modules'))) {
241
+ /**
242
+ * 验收加速开关: 把已有 node_modules 直接软链进来, 跳过 `npm install` (本仓依赖 889MB)。
243
+ * **只省"装依赖"这一步**, 构建与打包仍然真跑 —— 刻意留给验收脚本用, 别在生产路径上设。
244
+ */
245
+ const reuse = env.BOLLOON_DEV_REUSE_NODE_MODULES;
246
+ if (reuse && fs.existsSync(path.join(reuse, '.bin'))) {
247
+ stage('staged', `复用现成依赖 (${reuse}) 跳过 npm install`);
248
+ try {
249
+ fs.symlinkSync(reuse, path.join(dir, 'node_modules'), 'dir');
250
+ }
251
+ catch { /* 已存在就跳过 */ }
252
+ }
253
+ else {
254
+ const inst = runNpm(['install', '--no-audit', '--no-fund', '--loglevel=error', '--fetch-retries=5'], dir);
255
+ if (inst.code !== 0) {
256
+ return { ok: false, kind: 'dev_source_failed', reason: 'npm_install_failed', detail: `dev 快照装依赖失败: ${(inst.stderr || inst.stdout).slice(0, 400)}` };
257
+ }
258
+ }
259
+ }
260
+ /**
261
+ * 从 git 源码树构建要**两步** (真跑踩到的坑):
262
+ * ① `npm run build --workspaces --if-present` —— 先建 workspace 依赖 (@bolloon/constraint-runtime),
263
+ * 否则根包 tsc 会因为 `../constraint-runtime/dist/tools/...` 不存在而直接报 TS2307;
264
+ * ② `npm run build:main` —— 再建根包 (tsc + copy-constraint-runtime)。
265
+ * 原来只跑 ②, 在**干净源码树**上必失败 —— 这个坑只有真从 git 建一次才会暴露。
266
+ */
267
+ const buildWs = runNpm(['run', 'build', '--workspaces', '--if-present', '--loglevel=error'], dir);
268
+ if (buildWs.code !== 0) {
269
+ return { ok: false, kind: 'dev_source_failed', reason: 'build_failed', detail: `dev 快照构建 workspace 依赖失败: ${(buildWs.stderr || buildWs.stdout).slice(0, 400)}` };
270
+ }
271
+ const build = runNpm(['run', 'build:main'], dir);
272
+ if (build.code !== 0) {
273
+ return { ok: false, kind: 'dev_source_failed', reason: 'build_failed', detail: `dev 快照构建失败: ${(build.stderr || build.stdout).slice(0, 400)}` };
274
+ }
275
+ built = true;
276
+ }
277
+ // 写身份 (改 package.json 的版本身份 + 一个可核对的标记文件)
278
+ const pkgPath = path.join(dir, 'package.json');
279
+ const raw = JSON.parse(await fsp.readFile(pkgPath, 'utf8'));
280
+ raw.version = identity;
281
+ await fsp.writeFile(pkgPath, JSON.stringify(raw, null, 2) + '\n', 'utf8');
282
+ await fsp.writeFile(path.join(dir, '.bolloon-dev-snapshot.json'), JSON.stringify({
283
+ channel: 'dev', ref: `refs/heads/${DEV_BRANCH}`, sha, identity, source, built,
284
+ builtAt: new Date().toISOString(),
285
+ warning: DEV_CHANNEL_WARNING,
286
+ }, null, 2) + '\n', 'utf8');
287
+ // 打包 (产物仍是 tarball → 交给 npm 替换, 不另造一套)
288
+ const pack = runNpm(['pack', dir, '--pack-destination', opts.workDir, '--loglevel=error'], opts.workDir);
289
+ if (pack.code !== 0) {
290
+ return { ok: false, kind: 'dev_source_failed', reason: 'pack_failed', detail: `dev 快照打包失败: ${(pack.stderr || pack.stdout).slice(0, 400)}` };
291
+ }
292
+ const tgzs = (await fsp.readdir(opts.workDir)).filter((f) => f.endsWith('.tgz') && f.includes('bolloon-agent'));
293
+ if (tgzs.length === 0)
294
+ return { ok: false, kind: 'dev_source_failed', reason: 'no_tarball', detail: 'npm pack 没有产出 tarball' };
295
+ const tarball = path.join(opts.workDir, tgzs.sort()[tgzs.length - 1]);
296
+ return { ok: true, dir, tarball, identity, sha, baseVersion, source, built };
297
+ }
@@ -15,8 +15,8 @@
15
15
  * 退出码与 `bolloon update` 完全一致: 0 正常 / 1 执行失败 / 2 检查不可用。
16
16
  */
17
17
  import { resolveBolloonHome } from '../setup/setup-store.js';
18
- import { checkForUpdate, readUpdateStatus } from './update-manager.js';
19
- import { renderCheckResult, runDoctorCommand, runUpdateCommand, runVersionCommand } from '../cli/update-commands.js';
18
+ import { checkForUpdate, readUpdateStatus, checkExitCode } from './update-manager.js';
19
+ import { renderCheckResult, renderStatusReport, runDoctorCommand, runUpdateCommand, runVersionCommand, parseChannelArg } from '../cli/update-commands.js';
20
20
  import { runDoctor, renderDoctor } from './update-health.js';
21
21
  function out(s) { process.stdout.write(s + '\n'); }
22
22
  async function main() {
@@ -25,11 +25,17 @@ async function main() {
25
25
  const args = argv.slice(1);
26
26
  const json = args.includes('json') || args.includes('--json');
27
27
  const home = resolveBolloonHome();
28
+ // 双源: `--channel stable|dev` 只覆盖本次进程 (§12.2); 给了不认识的值就直接拒
29
+ const ch = parseChannelArg(args);
30
+ if (ch.error) {
31
+ process.stderr.write(`✗ ${ch.error}\n`);
32
+ return 2;
33
+ }
28
34
  switch (cmd) {
29
35
  case 'check': {
30
- const force = args.includes('force') || args.includes('--force') || args.includes('--fresh');
36
+ const force = args.includes('force') || args.includes('--force') || args.includes('--fresh') || !!ch.channel;
31
37
  const offline = args.includes('offline') || args.includes('--offline');
32
- const r = await checkForUpdate({ home, force, offline });
38
+ const r = await checkForUpdate({ home, force, offline, channel: ch.channel });
33
39
  if (json)
34
40
  out(JSON.stringify(r, null, 2));
35
41
  else if (r.status === 'up_to_date' || r.status === 'check_skipped') {
@@ -40,28 +46,17 @@ async function main() {
40
46
  else {
41
47
  out(renderCheckResult(r));
42
48
  }
43
- return (r.status === 'offline' || r.status === 'registry_unavailable' || r.status === 'local_version_unknown') ? 2 : 0;
49
+ return checkExitCode(r.status);
44
50
  }
45
51
  case 'status': {
46
- const s = await readUpdateStatus({ home });
52
+ const s = await readUpdateStatus({ home, channel: ch.channel });
47
53
  if (json)
48
54
  out(JSON.stringify(s, null, 2));
49
- else {
50
- const L = [
51
- `当前版本: ${s.currentVersion}`,
52
- `最新版本: ${s.latestVersion || '未知'}`,
53
- `检查结论: ${s.lastCheckStatus || '尚未检查'}${s.lastCheckReason ? ` (${s.lastCheckReason})` : ''}`,
54
- `最近检查: ${s.lastCheckAt || '从未'}`,
55
- `安装方式: ${s.installMethod}`,
56
- `安装目录: ${s.installDir}`,
57
- `最近更新: ${s.lastUpdate ? `${s.lastUpdate.at} ${s.lastUpdate.from} → ${s.lastUpdate.to} [${s.lastUpdate.status}]` : '无记录'}`,
58
- `需要重启: ${s.needsRestart ? '是' : '否'}`,
59
- ];
60
- out(L.join('\n'));
61
- }
55
+ else
56
+ out(renderStatusReport(s));
62
57
  return 0;
63
58
  }
64
- case 'plan': return runUpdateCommand(['plan', ...(json ? ['json'] : [])]);
59
+ case 'plan': return runUpdateCommand(['plan', ...(ch.channel ? ['--channel', ch.channel] : []), ...(json ? ['json'] : [])]);
65
60
  case 'history': return runUpdateCommand(['history', ...args.filter((a) => /^\d+$/.test(a)), ...(json ? ['json'] : [])]);
66
61
  case 'version': return runVersionCommand(args);
67
62
  case 'doctor': {
@@ -72,7 +67,7 @@ async function main() {
72
67
  return rep.grade === 'failed' ? 1 : 0;
73
68
  }
74
69
  default:
75
- process.stderr.write(`未知子命令: ${cmd}\n用法: update-cli check|status|plan|history|version|doctor [json]\n`);
70
+ process.stderr.write(`未知子命令: ${cmd}\n用法: update-cli check|status|plan|history|version|doctor [json] [--channel stable|dev]\n`);
76
71
  return 64;
77
72
  }
78
73
  }