@bolloon/bolloon-agent 0.4.33 → 0.5.0

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 (143) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +493 -35
  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/network-pulse.js +304 -18
  31. package/dist/agents/pi-sdk.js +75 -20
  32. package/dist/agents/run-store.js +18 -5
  33. package/dist/agents/skill-readiness.js +52 -10
  34. package/dist/agents/subagent-manager.js +209 -3
  35. package/dist/agents/task/task-runner.js +9 -3
  36. package/dist/agents/task-group.js +8 -51
  37. package/dist/agents/task-public-text.js +89 -0
  38. package/dist/cli/commands/index.js +1 -0
  39. package/dist/cli/commands/tasks.js +350 -2
  40. package/dist/cli/identity-command.js +177 -0
  41. package/dist/cli/setup-wizard.js +71 -0
  42. package/dist/cli/update-commands.js +150 -24
  43. package/dist/cli-entry.js +12 -2
  44. package/dist/electron-build/agents/contacts/chain.js +26 -6
  45. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  46. package/dist/electron-build/agents/execution-supervisor.js +492 -34
  47. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  48. package/dist/electron-build/agents/external-events.js +34 -5
  49. package/dist/electron-build/agents/external-events.js.map +1 -1
  50. package/dist/electron-build/agents/goal-criteria.js +10 -3
  51. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  52. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  53. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  54. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  55. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  56. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  57. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  58. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  59. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  60. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  61. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  62. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  63. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  64. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  65. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  66. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  67. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  68. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  69. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  70. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  71. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  72. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  73. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  74. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  75. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  76. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  78. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  79. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  80. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  81. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  82. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  83. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  84. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  85. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  86. package/dist/electron-build/agents/goal-store.js +35 -1
  87. package/dist/electron-build/agents/goal-store.js.map +1 -1
  88. package/dist/electron-build/agents/network-pulse.js +307 -19
  89. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  90. package/dist/electron-build/agents/pi-sdk.js +74 -19
  91. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  92. package/dist/electron-build/agents/run-store.js +20 -5
  93. package/dist/electron-build/agents/run-store.js.map +1 -1
  94. package/dist/electron-build/agents/skill-readiness.js +52 -9
  95. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  96. package/dist/electron-build/agents/task/task-runner.js +8 -2
  97. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  98. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  99. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  100. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  101. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  102. package/dist/electron-build/utils/dual-source.js +471 -0
  103. package/dist/electron-build/utils/dual-source.js.map +1 -0
  104. package/dist/electron-build/utils/update-manager.js +331 -57
  105. package/dist/electron-build/utils/update-manager.js.map +1 -1
  106. package/dist/electron-build/utils/update-state.js +16 -1
  107. package/dist/electron-build/utils/update-state.js.map +1 -1
  108. package/dist/electron-build/utils/version-info.js +82 -2
  109. package/dist/electron-build/utils/version-info.js.map +1 -1
  110. package/dist/index.js +103 -12
  111. package/dist/ios/chain-explorer.js +617 -0
  112. package/dist/ios/explorer.html +163 -0
  113. package/dist/ios/index.html +96 -0
  114. package/dist/ios/mobile-agent.js +2 -2
  115. package/dist/ios/mobile-contacts.js +329 -0
  116. package/dist/ios/mobile-core.js +1379 -226
  117. package/dist/ios/mobile-privacy.js +185 -0
  118. package/dist/ios/mobile.css +19 -0
  119. package/dist/ios/mobile.html +96 -0
  120. package/dist/ios/mobile.js +706 -7
  121. package/dist/ios/routes-chain-index.js +108 -0
  122. package/dist/ios/routes-contacts.js +420 -0
  123. package/dist/ios/routes-onchain-trade.js +99 -0
  124. package/dist/ios/routes-x402-info.js +1 -0
  125. package/dist/ios/server.js +762 -0
  126. package/dist/ios/task-frame-server.js +211 -0
  127. package/dist/orbitdb/cid-database.js +81 -16
  128. package/dist/orbitdb/ipfs-node.js +31 -2
  129. package/dist/utils/dual-source.js +426 -0
  130. package/dist/utils/update-cli.js +16 -21
  131. package/dist/utils/update-health.js +35 -5
  132. package/dist/utils/update-manager.js +329 -57
  133. package/dist/utils/update-state.js +16 -1
  134. package/dist/utils/version-info.js +75 -1
  135. package/dist/web/mobile-contacts.js +44 -11
  136. package/dist/web/mobile-core.js +1197 -513
  137. package/dist/web/mobile-tasks.js +339 -0
  138. package/dist/web/mobile.css +19 -0
  139. package/dist/web/mobile.html +64 -0
  140. package/dist/web/mobile.js +531 -1
  141. package/dist/web/routes-mobile-tasks.js +353 -0
  142. package/dist/web/server.js +163 -4
  143. package/package.json +3 -1
@@ -24,8 +24,10 @@ import * as fsp from 'fs/promises';
24
24
  import * as https from 'https';
25
25
  import * as http from 'http';
26
26
  import { spawnSync } from 'child_process';
27
- import { PKG_NAME, CONSTRAINT_PKG_NAME, NPM_REGISTRY_BASE, resolveUpdateChannel, distTagForChannel, detectInstallation, readPackageAt, } from './version-info.js';
27
+ import { PKG_NAME, CONSTRAINT_PKG_NAME, NPM_REGISTRY_BASE, resolveUpdateChannel, distTagForChannel, detectInstallation, readPackageAt, channelKindOf, isDevIdentity, devShaFromIdentity, devIdentity, baseVersionOf, DEV_CHANNEL_WARNING, DEV_BACK_TO_STABLE_HINT, } from './version-info.js';
28
+ import { fetchGithubFacts, toGithubReport, crossCheckStable, compareDevSnapshots, prepareDevSnapshot, renderGithubReportLine, } from './dual-source.js';
28
29
  import { readUpdateState, writeUpdateState, appendUpdateHistory, readUpdateHistory, acquireUpdateLock, releaseUpdateLock, readUpdateLock, lockIsStale, readUpdatePrefs, } from './update-state.js';
30
+ export { DEV_CHANNEL_WARNING, DEV_BACK_TO_STABLE_HINT };
29
31
  // ── 版本比较 (唯一一份) ─────────────────────────────────────────────────────
30
32
  /**
31
33
  * 从子进程 stdout 里取 JSON —— **不要**按"行首是否 { "过滤:
@@ -133,31 +135,66 @@ export async function queryRegistryDoc(pkg = PKG_NAME, timeoutMs = 10000) {
133
135
  return { ok: false, ...classifyRegistryError(e) };
134
136
  }
135
137
  }
138
+ /**
139
+ * 检查结论 → 退出码 (稳定约定, §2): 0 正常 / 2 检查不可用。
140
+ *
141
+ * §12.5 的核心就是这一句: **源不可达 / 版本不存在 → 拒绝并说清 (退出码 2)**,
142
+ * 不许静默装回旧版, 也不许打印"✅ 已是最新"。
143
+ */
144
+ export function checkExitCode(status) {
145
+ switch (status) {
146
+ case 'offline':
147
+ case 'registry_unavailable':
148
+ case 'local_version_unknown':
149
+ case 'github_unavailable':
150
+ case 'cross_check_mismatch':
151
+ return 2;
152
+ default:
153
+ return 0;
154
+ }
155
+ }
156
+ /**
157
+ * "源不可达 / 版本不存在" 这一组结论 —— 它们必须**拒绝执行并说清**。
158
+ * 这是 §12.5 那一条的代码落点: 不许静默装回旧版, 也不许打印"已是最新"。
159
+ */
160
+ export const REFUSED_STATUSES = ['offline', 'registry_unavailable', 'local_version_unknown', 'github_unavailable', 'cross_check_mismatch'];
136
161
  /**
137
162
  * 检查更新。**这是唯一的检查入口** —— CLI / 启动后台 / Python 检查器 / 安装脚本都走它。
138
163
  *
139
- * 结论优先级 (刻意定死, 防"模糊行为"):
164
+ * 结论优先级 (刻意定死, 防"模糊行为"; §12.3 在 stable 序列里插入两个双源结论):
140
165
  * 1. 读不到本地版本 → local_version_unknown (绝不默认 0.0.0 后继续)
141
166
  * 2. 安装方式不支持自动更新 → unsupported_installation (仍会带出 latestVersion)
142
167
  * 3. 显式跳过 / 节流 → check_skipped (结论来自缓存)
143
168
  * 4. 网络不可达 → offline (绝不显示"已是最新")
144
- * 5. registry 不可用 / 包不存在 → registry_unavailable
145
- * 6. latest > current → update_available
146
- * 7. 否则 → up_to_date
169
+ * 5. GitHub 不可达 (**dev 通道**) → github_unavailable (dev 只有这一个源, 拒绝)
170
+ * 6. registry 不可用 / 包不存在 → registry_unavailable
171
+ * 7. 两个源指向不同版本 → cross_check_mismatch (stable, 拒绝)
172
+ * 8. 有新版 (semver / dev 比 sha) → update_available
173
+ * 9. 否则 → up_to_date
174
+ *
175
+ * 两套比较语义**显式分开** (§12.1): stable 比 semver; dev 比 commit sha (不碰 semver)。
147
176
  */
148
177
  export async function checkForUpdate(opts = {}) {
149
178
  const home = opts.home ?? (await import('../setup/setup-store.js')).resolveBolloonHome();
150
179
  const prefs = await readUpdatePrefs({ home, env: opts.env });
151
180
  const channel = resolveUpdateChannel(opts.channel || prefs.channel);
181
+ const channelKind = channelKindOf(channel);
152
182
  const install = opts.installation || detectInstallation({ home });
153
183
  const pkg = readPackageAt(install.packageRoot);
154
184
  const current = isKnownVersion(pkg?.version) ? String(pkg?.version) : 'unknown';
185
+ const currentBase = baseVersionOf(current);
186
+ /** 磁盘身份里就是 dev 快照的 sha —— 不从状态文件猜 */
187
+ const installedDevSha = devShaFromIdentity(current);
188
+ const installedChannel = installedDevSha ? 'dev' : 'stable';
155
189
  const now = new Date().toISOString();
156
190
  const state = await readUpdateState(home);
157
191
  const persist = opts.persist !== false;
192
+ /** 注入 registry = 受控检查 (GitHub 事实同样只认注入, 不打真网) */
193
+ const controlled = !!opts.registry;
158
194
  const base = {
159
195
  currentVersion: current,
160
196
  channel,
197
+ channelKind,
161
198
  source: install.updateSource,
162
199
  installMethod: install.method,
163
200
  checkedAt: now,
@@ -178,13 +215,22 @@ export async function checkForUpdate(opts = {}) {
178
215
  lastCheckAt: r.checkedAt,
179
216
  lastCheckStatus: r.status,
180
217
  lastCheckReason: r.reason,
218
+ // 双源 (§12): 谁装的 / 哪个 sha / 能切回哪 / 两个源各给了什么
219
+ installedChannel: r.installedChannel ?? installedChannel,
220
+ installedDevSha: r.installedDevSha ?? null,
221
+ sourceFacts: r.sourceFacts ?? null,
222
+ switchableTo: r.switchableTo ?? null,
181
223
  }, home);
182
224
  }
183
225
  return r;
184
226
  };
185
227
  // 1. 本地版本读不到 —— 不猜
186
228
  if (!isKnownVersion(current)) {
187
- return commit({ ...base, status: 'local_version_unknown', latestVersion: null, reason: `读不到 ${install.packageRoot}/package.json 的版本` });
229
+ return commit({
230
+ ...base, status: 'local_version_unknown', latestVersion: null,
231
+ installedChannel: null, installedDevSha: null,
232
+ reason: `读不到 ${install.packageRoot}/package.json 的版本`,
233
+ });
188
234
  }
189
235
  // 3. 显式跳过 / 节流
190
236
  const throttled = !opts.force && state.lastCheckAt
@@ -200,6 +246,9 @@ export async function checkForUpdate(opts = {}) {
200
246
  status: 'check_skipped',
201
247
  cachedStatus: cached,
202
248
  latestVersion: state.latestVersion,
249
+ installedChannel, installedDevSha,
250
+ switchableTo: state.switchableTo ?? null,
251
+ sourceFacts: state.sourceFacts ?? null,
203
252
  reason: opts.offline
204
253
  ? `离线模式: 使用缓存结论 (${cached})`
205
254
  : `距上次检查不足 ${prefs.checkIntervalHours}h, 使用缓存结论 (${cached})`,
@@ -209,38 +258,124 @@ export async function checkForUpdate(opts = {}) {
209
258
  await writeUpdateState({ lastCheckReason: r.reason }, home);
210
259
  return r;
211
260
  }
212
- // 2. 安装方式不支持自动更新 —— 仍把 latest / 可回滚性带出来, 但结论就是"不支持"
213
- const registry = opts.registry || await queryRegistryDoc(PKG_NAME);
261
+ // 两个源并行查 (受控检查里 GitHub 事实只认注入)
262
+ const [registry, githubRes] = await Promise.all([
263
+ opts.registry ? Promise.resolve(opts.registry) : queryRegistryDoc(PKG_NAME),
264
+ opts.github
265
+ ? Promise.resolve(opts.github)
266
+ : (controlled || opts.offline ? Promise.resolve(null) : fetchGithubFacts()),
267
+ ]);
214
268
  const latest = registry.ok ? (registry.doc.distTags[distTagForChannel(channel)] || registry.doc.latest) : null;
215
- const targetPublishedOf = (v) => v && registry.ok ? registry.doc.versions.includes(v) : undefined;
216
- const rollbackSupportedOf = registry.ok ? registry.doc.versions.includes(current) : undefined;
269
+ const githubReport = githubRes ? toGithubReport(githubRes) : null;
270
+ const sourceFacts = {
271
+ npm: registry.ok
272
+ ? { reachable: true, latest }
273
+ : { reachable: false, latest: null, detail: registry.detail },
274
+ github: githubReport
275
+ ? {
276
+ reachable: githubReport.reachable,
277
+ reason: githubReport.reason, detail: githubReport.detail, retryAt: githubReport.retryAt ?? null,
278
+ ref: githubReport.ref, headSha: githubReport.headSha, newestVersion: githubReport.newestVersion,
279
+ releases: githubReport.releases, tags: githubReport.tags,
280
+ }
281
+ : null,
282
+ crossCheck: null,
283
+ };
284
+ const targetPublishedOf = (v) => v && registry.ok ? registry.doc.versions.includes(baseVersionOf(v)) : undefined;
285
+ const rollbackSupportedOf = registry.ok ? registry.doc.versions.includes(currentBase) : undefined;
286
+ // 2. 安装方式不支持自动更新 —— 仍把 latest / 可回滚性带出来, 但结论就是"不支持"
217
287
  if (install.method === 'development' || install.method === 'unknown' || install.method === 'release-binary') {
218
288
  return commit({
219
289
  ...base, status: 'unsupported_installation', latestVersion: latest,
290
+ installedChannel, installedDevSha, github: githubReport, sourceFacts,
291
+ switchableTo: state.switchableTo ?? null,
220
292
  reason: `${install.reason}; 这种安装方式不支持自动更新`,
221
293
  targetPublished: targetPublishedOf(latest),
222
294
  rollbackSupported: rollbackSupportedOf,
223
295
  });
224
296
  }
225
- // 4/5. registry 拿不到 —— 绝不显示"已是最新"
297
+ // ── dev 通道: git ref + commit sha (不碰 semver) ─────────────────────────
298
+ if (channelKind === 'git-ref') {
299
+ /** dev 的"一键回 stable"目标 = registry 上的 latest (§12.4 硬约束 3) */
300
+ const backToStable = { channel: 'stable', source: 'npm', target: latest };
301
+ const devBase = {
302
+ ...base, installedChannel, installedDevSha, github: githubReport,
303
+ switchableTo: backToStable, sourceFacts,
304
+ };
305
+ // dev 只有 GitHub 一个源 → 不可达就是**直接拒** (没有第二个源可退, §12.3)
306
+ if (!githubReport || !githubReport.reachable) {
307
+ const reason = githubReport?.reason || (opts.offline ? 'offline' : 'http_error');
308
+ const detail = githubReport?.detail
309
+ || (opts.offline ? '离线模式: 未查询 GitHub 源' : '本次未查询 GitHub 源 (受控检查未注入 GitHub 事实)');
310
+ return commit({
311
+ ...devBase, status: 'github_unavailable', latestVersion: null, dev: null, targetIdentity: null,
312
+ reason: `github_unavailable(${reason}): ${detail} — dev 通道只有 GitHub 一个源, 拒绝安装 (不回落到 stable 装一个 npm 版本)`,
313
+ });
314
+ }
315
+ if (!githubReport.headSha) {
316
+ return commit({
317
+ ...devBase, status: 'github_unavailable', latestVersion: null, dev: null, targetIdentity: null,
318
+ reason: `github_unavailable(not_found): GitHub 上没有 refs/heads/master 或读不到 HEAD — 拒绝安装一个身份不明的快照`,
319
+ });
320
+ }
321
+ const dev = compareDevSnapshots(installedDevSha, githubReport.headSha, githubReport.ref);
322
+ const targetIdentity = devIdentity(currentBase, githubReport.headSha);
323
+ const common = {
324
+ ...devBase, dev, targetIdentity, latestVersion: targetIdentity, targetPublished: true,
325
+ rollbackSupported: rollbackSupportedOf,
326
+ };
327
+ if (dev.same)
328
+ return commit({ ...common, status: 'up_to_date', reason: dev.detail });
329
+ return commit({
330
+ ...common, status: 'update_available',
331
+ reason: `${dev.detail}${registry.ok ? '' : ` · 注意: registry 不可达 (${registry.detail}) — 回 stable 会需要它`}`,
332
+ });
333
+ }
334
+ // ── stable 通道: npm 是权威, GitHub 是交叉校验 (§12.1) ───────────────────
335
+ // 4/5. registry 拿不到 —— 绝不显示"已是最新" (GitHub 的事实一并摆出来, 方便判断是哪一源的问题)
226
336
  if (!registry.ok) {
227
337
  return commit({
228
338
  ...base,
229
339
  status: registry.kind === 'offline' ? 'offline' : 'registry_unavailable',
230
340
  latestVersion: null,
231
- reason: registry.detail,
341
+ installedChannel, installedDevSha, github: githubReport, sourceFacts,
342
+ switchableTo: state.switchableTo ?? null,
343
+ reason: `${registry.detail}${githubReport ? ` · ${renderGithubReportLine(githubReport)}` : ''}`,
232
344
  });
233
345
  }
346
+ const crossCheck = githubRes && githubRes.ok ? crossCheckStable(latest, githubRes.facts) : null;
347
+ sourceFacts.crossCheck = crossCheck ? { kind: crossCheck.kind, blocking: crossCheck.blocking, detail: crossCheck.detail } : null;
348
+ const switchableTo = installedDevSha
349
+ ? { channel: 'stable', source: 'npm', target: latest }
350
+ : { channel: 'dev', source: 'github', target: githubReport?.headSha ? githubReport.headSha.slice(0, 7) : null };
351
+ const stableBase = {
352
+ ...base, installedChannel, installedDevSha, github: githubReport, crossCheck, sourceFacts, switchableTo,
353
+ };
234
354
  const targetPublished = !!latest && registry.doc.versions.includes(latest);
235
355
  const rollbackSupported = rollbackSupportedOf === true;
236
- // 6/7. 比较
237
- if (latest && compareVersions(current, latest) < 0) {
356
+ // 7. 两个源指向不同版本 → 拒绝 (不许按 GitHub 的记录去装)
357
+ if (crossCheck?.kind === 'mismatch') {
238
358
  return commit({
239
- ...base, status: 'update_available', latestVersion: latest, targetPublished, rollbackSupported,
240
- reason: `当前 ${current} → 目标 ${latest}`,
359
+ ...stableBase, status: 'cross_check_mismatch', latestVersion: latest,
360
+ targetPublished, rollbackSupported,
361
+ reason: `cross_check_mismatch: ${crossCheck.detail} (npm latest=${latest} / GitHub ${crossCheck.githubVersion || '无记录'})`,
241
362
  });
242
363
  }
243
- return commit({ ...base, status: 'up_to_date', latestVersion: latest, targetPublished, rollbackSupported });
364
+ // 8/9. 比较: 当前是 dev 快照时**显式**判"切回 stable", 而不是拿 semver 硬比
365
+ if (isDevIdentity(current)) {
366
+ return commit({
367
+ ...stableBase, status: 'update_available', latestVersion: latest,
368
+ targetPublished, rollbackSupported,
369
+ reason: `当前装的是 dev 快照 ${current} → 切回 stable 的 ${latest || '未知'} (一键: ${DEV_BACK_TO_STABLE_HINT})`,
370
+ });
371
+ }
372
+ if (latest && compareVersions(currentBase, latest) < 0) {
373
+ return commit({
374
+ ...stableBase, status: 'update_available', latestVersion: latest, targetPublished, rollbackSupported,
375
+ reason: `当前 ${current} → 目标 ${latest}${crossCheck ? ` · 交叉校验: ${crossCheck.kind}` : ''}`,
376
+ });
377
+ }
378
+ return commit({ ...stableBase, status: 'up_to_date', latestVersion: latest, targetPublished, rollbackSupported });
244
379
  }
245
380
  const WILL_NOT_TOUCH = [
246
381
  '~/.bolloon/config.json',
@@ -362,6 +497,7 @@ export async function buildUpdatePlan(opts = {}) {
362
497
  const home = opts.home ?? (await import('../setup/setup-store.js')).resolveBolloonHome();
363
498
  const install = opts.installation || detectInstallation({ home });
364
499
  const check = await checkForUpdate({ ...opts, home, persist: false, installation: install });
500
+ const isDev = check.channelKind === 'git-ref';
365
501
  const risk = [
366
502
  ...await collectInstallRisk(install),
367
503
  ...(opts.workloadRiskOverride || await collectWorkloadRisk()),
@@ -376,6 +512,33 @@ export async function buildUpdatePlan(opts = {}) {
376
512
  : check.status === 'registry_unavailable' ? `registry 不可用: ${check.reason}`
377
513
  : 'npm registry 可达',
378
514
  });
515
+ /**
516
+ * GitHub 侧风险 (§12.3) —— **dev 通道阻塞, stable 通道只提醒**:
517
+ * dev 只有 GitHub 一个源, 不可达就没有"退回哪去"这件事; stable 的权威是 npm, GitHub 只是交叉校验源。
518
+ */
519
+ const gh = check.github;
520
+ risk.push({
521
+ id: 'github_reachable',
522
+ label: 'GitHub 源可达 (dev 通道的源 / stable 的交叉校验源)',
523
+ ok: !gh ? true : gh.reachable,
524
+ blocking: isDev && (!gh || !gh.reachable),
525
+ advisory: !isDev,
526
+ detail: !gh
527
+ ? '未查询 GitHub 源 (离线或受控检查未注入)'
528
+ : gh.reachable
529
+ ? `可达 · master HEAD ${gh.headSha ? gh.headSha.slice(0, 7) : '未知'} · Release ${gh.releases ?? 0} 个 · Tag ${gh.tags ?? 0} 个`
530
+ : `github_unavailable(${gh.reason}): ${gh.detail || ''}${gh.retryAt ? ` (限流重试 ${gh.retryAt})` : ''}`,
531
+ });
532
+ risk.push({
533
+ id: 'cross_check',
534
+ label: '两个源指向同一版本 (npm ↔ GitHub)',
535
+ ok: check.crossCheck ? check.crossCheck.kind !== 'mismatch' : true,
536
+ blocking: !!check.crossCheck?.blocking,
537
+ advisory: check.crossCheck?.kind === 'missing_record' || check.crossCheck?.kind === 'no_record_at_all',
538
+ detail: !check.crossCheck
539
+ ? (isDev ? 'dev 通道不看 npm (GitHub 是唯一源)' : '未做交叉校验 (GitHub 源未查)')
540
+ : check.crossCheck.detail,
541
+ });
379
542
  risk.push({
380
543
  id: 'target_published',
381
544
  label: '目标版本真实存在',
@@ -391,24 +554,36 @@ export async function buildUpdatePlan(opts = {}) {
391
554
  ok: check.rollbackSupported !== false,
392
555
  blocking: false,
393
556
  detail: check.rollbackSupported === false
394
- ? `registry 上找不到当前版本 ${check.currentVersion} — 更新失败将无法用 npm 回滚`
395
- : check.rollbackSupported === true ? `registry 上存在 ${check.currentVersion} (可回滚)` : '未检查 (离线)',
557
+ ? `registry 上找不到当前版本 ${baseVersionOf(check.currentVersion)} — 更新失败将无法用 npm 回滚`
558
+ : check.rollbackSupported === true ? `registry 上存在 ${baseVersionOf(check.currentVersion)} (可回滚)` : '未检查 (离线)',
396
559
  });
397
560
  const blockers = risk.filter((r) => !r.ok && r.blocking).map((r) => `${r.label}: ${r.detail}`);
398
561
  const advisories = risk.filter((r) => !r.ok && !r.blocking).map((r) => `${r.label}: ${r.detail}`);
399
- const target = check.latestVersion && compareVersions(check.currentVersion, check.latestVersion) < 0
400
- ? check.latestVersion
562
+ const target = check.status === 'update_available'
563
+ ? (check.targetIdentity || check.latestVersion || null)
401
564
  : null;
565
+ const warnings = [];
566
+ if (isDev) {
567
+ warnings.push(DEV_CHANNEL_WARNING);
568
+ warnings.push(DEV_BACK_TO_STABLE_HINT);
569
+ }
570
+ if (check.installedDevSha && !isDev)
571
+ warnings.push(`当前装的是 dev 快照 (commit ${check.installedDevSha}), 本次要切回 stable: 会把 GitHub master 快照换成 registry 上的 latest`);
402
572
  return {
403
573
  ok: blockers.length === 0 && !!target,
404
574
  currentVersion: check.currentVersion,
405
575
  targetVersion: target,
406
576
  channel: check.channel,
577
+ channelKind: check.channelKind || channelKindOf(check.channel),
578
+ targetIdentity: check.targetIdentity ?? null,
579
+ warnings,
407
580
  installMethod: check.installMethod,
408
581
  source: check.source,
409
582
  installDir: install.installDir,
410
583
  needsRestart: !!target,
411
- willUpdate: target && install.method === 'npm-global' ? [PKG_NAME, CONSTRAINT_PKG_NAME] : [],
584
+ willUpdate: target && install.method === 'npm-global'
585
+ ? (isDev ? [`GitHub master 快照 ${check.github?.headSha?.slice(0, 7) || ''} (身份 ${target})`, PKG_NAME] : [PKG_NAME, CONSTRAINT_PKG_NAME])
586
+ : [],
412
587
  willNotTouch: WILL_NOT_TOUCH,
413
588
  risk, blockers, advisories,
414
589
  strategies: ['now', 'wait', 'cancel'],
@@ -418,10 +593,18 @@ export async function buildUpdatePlan(opts = {}) {
418
593
  }
419
594
  export function renderUpdatePlan(plan) {
420
595
  const L = [];
421
- L.push(`当前版本: ${plan.currentVersion}`);
596
+ L.push(`当前版本: ${plan.currentVersion}${plan.check.installedDevSha ? ` (dev 快照 commit ${plan.check.installedDevSha})` : ''}`);
422
597
  L.push(`目标版本: ${plan.targetVersion || '(无可用更新)'}`);
423
598
  L.push(`安装方式: ${plan.installMethod}`);
424
- L.push(`更新通道: ${plan.channel} (来源: ${plan.source})`);
599
+ L.push(`更新通道: ${plan.channel} (比较语义: ${plan.channelKind})`);
600
+ L.push(`更新来源: ${plan.source}${plan.channelKind === 'git-ref' ? ' · GitHub master (唯一源)' : ' · npm registry (权威) + GitHub (交叉校验)'}`);
601
+ L.push(renderGithubReportLine(plan.check.github));
602
+ if (plan.check.crossCheck)
603
+ L.push(`交叉校验: ${plan.check.crossCheck.kind}${plan.check.crossCheck.blocking ? ' (阻塞)' : ''} — ${plan.check.crossCheck.detail}`);
604
+ if (plan.check.dev)
605
+ L.push(`dev 快照: ${plan.check.dev.detail}`);
606
+ for (const w of plan.warnings)
607
+ L.push(w);
425
608
  L.push(`安装目录: ${plan.installDir}`);
426
609
  L.push('');
427
610
  L.push('将更新:');
@@ -452,7 +635,9 @@ export function renderUpdatePlan(plan) {
452
635
  L.push(` 建议策略: 等当前 Run 结束后更新 (bolloon update --now --wait)`);
453
636
  }
454
637
  L.push('');
455
- L.push('可选: 立即更新 (bolloon update --now) / 等当前 Run 结束 (bolloon update --now --wait) / 取消');
638
+ L.push(`可选: 立即更新 (bolloon update --now --channel ${plan.channel}) / 等当前 Run 结束 (bolloon update --now --wait --channel ${plan.channel}) / 取消`);
639
+ if (plan.channelKind === 'git-ref')
640
+ L.push(`一键回稳定版: bolloon update now --channel stable`);
456
641
  L.push(`默认: ${plan.defaultStrategy === 'wait' ? '等待当前 Run 结束后更新' : '立即更新'}`);
457
642
  return L.join('\n');
458
643
  }
@@ -517,21 +702,44 @@ export async function applyUpdate(opts = {}) {
517
702
  };
518
703
  const plan = await buildUpdatePlan({
519
704
  home, env: opts.env, force: true, installation: install, registry: opts.registry,
705
+ github: opts.github, channel: opts.channel,
520
706
  workloadRiskOverride: opts.workloadRiskOverride,
521
707
  });
522
708
  const from = plan.currentVersion;
523
709
  const to = plan.targetVersion || plan.currentVersion;
710
+ const isDev = plan.channelKind === 'git-ref';
711
+ /** dev 的目标 commit (身份就是它, 不是版本号) */
712
+ const devSha = plan.check.dev?.headSha || plan.check.github?.headSha || null;
524
713
  const record = async (r, historyStatus) => {
525
714
  const rec = { at: new Date().toISOString(), from: r.from, to: r.to, status: historyStatus, durationMs: r.durationMs, reason: r.reason };
526
715
  // 记账失败**不能**把"更新其实成功了"变成向上抛异常 (真被测试 teardown 竞态抓到过:
527
716
  // HOME 在写到一半时消失 → rename ENOENT)。失败就如实记进 reason, 结果照常返回。
528
717
  try {
529
718
  await appendUpdateHistory(rec, home);
719
+ /**
720
+ * 双源落盘 (§12.4 硬约束 2): 装成 dev 时记下 `{channel, devSha, devRef, checkedAt}`;
721
+ * 切回 stable 时把安装通道改回 stable 并把 dev 记录保留成"上次那个 dev 快照"。
722
+ */
723
+ const dualSource = isDev && r.ok
724
+ ? {
725
+ installedChannel: 'dev',
726
+ installedDevSha: devSha ? devSha.slice(0, 7) : devShaFromIdentity(r.to),
727
+ devSha: devSha || null,
728
+ devRef: `refs/heads/master`,
729
+ devCheckedAt: rec.at,
730
+ switchableTo: { channel: 'stable', source: 'npm', target: baseVersionOf(r.from) },
731
+ }
732
+ : {};
733
+ const backToStable = !isDev && r.ok
734
+ ? { installedChannel: 'stable', installedDevSha: null, switchableTo: { channel: 'dev', source: 'github', target: devSha ? devSha.slice(0, 7) : null } }
735
+ : {};
530
736
  await writeUpdateState({
531
737
  lastUpdate: rec,
532
738
  lastFailure: r.ok ? null : { at: rec.at, stage: r.failedAt || r.stage, reason: r.reason || '未知原因' },
533
739
  needsRestart: r.ok ? r.needsRestart : false,
534
740
  currentVersion: r.from,
741
+ ...dualSource,
742
+ ...backToStable,
535
743
  }, home);
536
744
  }
537
745
  catch (e) {
@@ -541,8 +749,16 @@ export async function applyUpdate(opts = {}) {
541
749
  return r;
542
750
  };
543
751
  if (!plan.targetVersion) {
544
- await stage('blocked', '没有可执行的更新');
545
- return record({ stage: 'blocked', ok: false, from, to, durationMs: Date.now() - started, reason: plan.blockers[0] || '已是最新, 无需更新', needsRestart: false }, 'blocked');
752
+ /**
753
+ * §12.5 的核心分流: **源不可达 / 版本不存在 → 拒绝并说清**,
754
+ * 绝不把"没查到"说成"已是最新"。
755
+ */
756
+ const refused = REFUSED_STATUSES.includes(plan.check.status);
757
+ await stage('blocked', refused ? `拒绝更新 (${plan.check.status})` : '没有可执行的更新');
758
+ const reason = refused
759
+ ? `拒绝更新 (${plan.check.status}): ${plan.check.reason || plan.blockers[0] || '源不可达 / 版本不存在'}`
760
+ : (plan.blockers[0] || '已是最新, 无需更新');
761
+ return record({ stage: 'blocked', ok: false, from, to, durationMs: Date.now() - started, reason, needsRestart: false }, 'blocked');
546
762
  }
547
763
  if (plan.blockers.length > 0) {
548
764
  await stage('blocked', plan.blockers.join('; '));
@@ -564,42 +780,79 @@ export async function applyUpdate(opts = {}) {
564
780
  const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'bolloon-update-'));
565
781
  const runNpm = opts.runNpm || defaultRunNpm;
566
782
  let stagedTarball = null;
783
+ /** 切换时交给 npm 的安装目标: stable = `<包>@<semver>`; dev = 本地 dev 快照 tarball */
784
+ let switchSpec = `${PKG_NAME}@${to}`;
785
+ /**
786
+ * 实际装入的身份。dev 的身份来自**源码树的 package.json + master sha**, 可能与检查阶段的预测
787
+ * 略有差别 (源码树的版本号才是真的) → 以产物为准, 校验/验证/记账都用它, 不用预测值糊过去。
788
+ */
789
+ let effectiveTo = to;
567
790
  try {
568
- // 1. 下载 tarball 到临时目录 (不动现有安装)
569
- await stage('downloading', `npm pack ${PKG_NAME}@${to}`);
570
- const packed = runNpm(['pack', `${PKG_NAME}@${to}`, '--pack-destination', tmpRoot, '--loglevel=error'], tmpRoot);
571
- if (packed.code !== 0) {
572
- throw Object.assign(new Error(`下载 ${PKG_NAME}@${to} 失败: ${(packed.stderr || packed.stdout).trim().slice(0, 400)}`), { stage: 'downloading' });
791
+ // 1. 取产物到临时目录 (不动现有安装)
792
+ if (isDev) {
793
+ // dev: 源是 GitHub master 的 git ref, 身份是 commit sha (§12.1)
794
+ if (!devSha) {
795
+ throw Object.assign(new Error('dev 通道没有拿到 master HEAD 的 commit sha — 拒绝安装一个身份不明的快照'), { stage: 'downloading' });
796
+ }
797
+ await stage('downloading', `取 GitHub master @ ${devSha.slice(0, 7)} (${DEV_BACK_TO_STABLE_HINT})`);
798
+ const snap = opts.devSnapshot
799
+ ? { ok: true, tarball: opts.devSnapshot.tarball, identity: opts.devSnapshot.identity, sha: opts.devSnapshot.sha, source: 'injected', built: false, dir: '' }
800
+ : await prepareDevSnapshot({
801
+ sha: devSha, workDir: tmpRoot, env: opts.env,
802
+ runNpm,
803
+ onStage: (s, d) => { try {
804
+ opts.onStage?.(s, d);
805
+ }
806
+ catch { /* 回调失败不影响流程 */ } },
807
+ });
808
+ if (!snap.ok) {
809
+ const kind = 'kind' in snap ? String(snap.kind) : 'dev_source_failed';
810
+ const reason = 'reason' in snap ? String(snap.reason) : '';
811
+ const detail = 'detail' in snap ? String(snap.detail) : '未知原因';
812
+ throw Object.assign(new Error(`${kind}/${reason}: ${detail}`), { stage: 'downloading' });
813
+ }
814
+ stagedTarball = snap.tarball;
815
+ switchSpec = snap.tarball;
816
+ effectiveTo = snap.identity;
817
+ await stage('staged', `dev 快照身份 ${snap.identity} (来源 ${snap.source}${snap.built ? ', 已现场构建' : ''})`);
573
818
  }
574
- const tarballs = (await fsp.readdir(tmpRoot)).filter((f) => f.endsWith('.tgz'));
575
- if (tarballs.length === 0)
576
- throw Object.assign(new Error('npm pack 没有产出 tarball'), { stage: 'downloading' });
577
- stagedTarball = path.join(tmpRoot, tarballs[0]);
578
- // 2. 解压 + 校验包内容 (版本 + 入口)
579
- await stage('staged', `解压并校验 ${path.basename(stagedTarball)}`);
819
+ else {
820
+ await stage('downloading', `npm pack ${PKG_NAME}@${to}`);
821
+ const packed = runNpm(['pack', `${PKG_NAME}@${to}`, '--pack-destination', tmpRoot, '--loglevel=error'], tmpRoot);
822
+ if (packed.code !== 0) {
823
+ throw Object.assign(new Error(`下载 ${PKG_NAME}@${to} 失败: ${(packed.stderr || packed.stdout).trim().slice(0, 400)}`), { stage: 'downloading' });
824
+ }
825
+ const tarballs = (await fsp.readdir(tmpRoot)).filter((f) => f.endsWith('.tgz'));
826
+ if (tarballs.length === 0)
827
+ throw Object.assign(new Error('npm pack 没有产出 tarball'), { stage: 'downloading' });
828
+ stagedTarball = path.join(tmpRoot, tarballs[0]);
829
+ }
830
+ // 2. 解压 + 校验包内容 (版本 + 入口) —— 两个源共用同一条校验
831
+ if (!isDev)
832
+ await stage('staged', `解压并校验 ${path.basename(stagedTarball)}`);
580
833
  const extractDir = path.join(tmpRoot, 'extract');
581
834
  await fsp.mkdir(extractDir, { recursive: true });
582
835
  const tar = spawnSync('tar', ['-xzf', stagedTarball, '-C', extractDir], { encoding: 'utf-8', timeout: 120_000 });
583
836
  if (tar.status !== 0)
584
837
  throw Object.assign(new Error(`解压失败: ${(tar.stderr || '').slice(0, 300)}`), { stage: 'staged' });
585
- const stagedCheck = validateStagedPackage(path.join(extractDir, 'package'), to);
838
+ const stagedCheck = validateStagedPackage(path.join(extractDir, 'package'), effectiveTo);
586
839
  if (!stagedCheck.ok)
587
840
  throw Object.assign(new Error(`目标包校验失败: ${stagedCheck.detail}`), { stage: 'staged' });
588
- // 3. 切换 (npm 完成替换; 旧版本在替换成功前不会消失)
589
- await stage('switching', `npm install -g ${PKG_NAME}@${to}`);
590
- const installed = runNpm(['install', '-g', `${PKG_NAME}@${to}`, '--no-fund', '--no-audit', '--loglevel=error'], os.tmpdir());
841
+ // 3. 切换 (npm 完成替换; 旧版本在替换成功前不会消失) —— 两个源共用同一条替换路径
842
+ await stage('switching', isDev ? `npm install -g <dev 快照 ${path.basename(switchSpec)}>` : `npm install -g ${PKG_NAME}@${to}`);
843
+ const installed = runNpm(['install', '-g', switchSpec, '--no-fund', '--no-audit', '--loglevel=error'], os.tmpdir());
591
844
  if (installed.code !== 0) {
592
845
  throw Object.assign(new Error(`npm 安装失败: ${(installed.stderr || installed.stdout).trim().slice(0, 400)}`), { stage: 'switching' });
593
846
  }
594
847
  // 4. 验证: 磁盘版本 + 新入口真能启动 + 健康检查
595
848
  await stage('verifying', '核对磁盘版本并试启新入口');
596
849
  const verify = opts.verifyInstall || defaultVerifyInstall;
597
- const ok = await verify(to, home);
850
+ const ok = await verify(effectiveTo, home);
598
851
  if (!ok)
599
- throw Object.assign(new Error(`切换后验证失败: 新版本 ${to} 未能正确启动`), { stage: 'verifying' });
600
- await stage('succeeded', `已更新到 ${to}`);
852
+ throw Object.assign(new Error(`切换后验证失败: 新版本 ${effectiveTo} 未能正确启动`), { stage: 'verifying' });
853
+ await stage('succeeded', `已更新到 ${effectiveTo}`);
601
854
  const out = {
602
- stage: 'succeeded', ok: true, from, to, durationMs: Date.now() - started, needsRestart: true, stagedTarball,
855
+ stage: 'succeeded', ok: true, from, to: effectiveTo, durationMs: Date.now() - started, needsRestart: true, stagedTarball,
603
856
  };
604
857
  await record(out, 'succeeded');
605
858
  return out;
@@ -609,25 +862,26 @@ export async function applyUpdate(opts = {}) {
609
862
  const reason = e?.message || String(e);
610
863
  await stage(failureStage, reason);
611
864
  // 失败处理: 保留旧版本 → 能回滚就回滚 → 写原因 → 释放锁
865
+ const fromBase = baseVersionOf(from);
612
866
  let finalStage = 'failed';
613
867
  let finalReason = reason;
614
868
  try {
615
869
  const nowVersion = installedVersionOnDisk(install.packageRoot);
616
- if (install.method === 'npm-global' && nowVersion && nowVersion !== from && nowVersion !== to) {
617
- await stage('rolled_back', `检测到半更新 (磁盘版本 ${nowVersion}), 回滚到 ${from}`);
618
- const rb = runNpm(['install', '-g', `${PKG_NAME}@${from}`, '--no-fund', '--no-audit', '--loglevel=error'], os.tmpdir());
870
+ if (install.method === 'npm-global' && nowVersion && nowVersion !== from && nowVersion !== effectiveTo) {
871
+ await stage('rolled_back', `检测到半更新 (磁盘版本 ${nowVersion}), 回滚到 ${fromBase}`);
872
+ const rb = runNpm(['install', '-g', `${PKG_NAME}@${fromBase}`, '--no-fund', '--no-audit', '--loglevel=error'], os.tmpdir());
619
873
  const after = installedVersionOnDisk(install.packageRoot);
620
- if (rb.code === 0 && after === from) {
874
+ if (rb.code === 0 && after === fromBase) {
621
875
  finalStage = 'rolled_back';
622
- finalReason = `${reason}; 已回滚到 ${from}`;
876
+ finalReason = `${reason}; 已回滚到 ${fromBase}`;
623
877
  }
624
878
  else {
625
- finalReason = `${reason}; 回滚也未成功 (磁盘版本 ${after || 'unknown'}) — 请手动: npm install -g ${PKG_NAME}@${from}`;
879
+ finalReason = `${reason}; 回滚也未成功 (磁盘版本 ${after || 'unknown'}) — 请手动: npm install -g ${PKG_NAME}@${fromBase}`;
626
880
  }
627
881
  }
628
- else if (nowVersion === to && failureStage === 'verifying') {
882
+ else if (nowVersion === effectiveTo && failureStage === 'verifying') {
629
883
  // 装上了但验证没过: 保留新版本但如实报失败, 并给出回滚指令
630
- finalReason = `${reason}; 磁盘版本已是 ${to}, 如需回退: npm install -g ${PKG_NAME}@${from}`;
884
+ finalReason = `${reason}; 磁盘版本已是 ${effectiveTo}, 如需回退: npm install -g ${PKG_NAME}@${fromBase}`;
631
885
  }
632
886
  }
633
887
  catch (e2) {
@@ -668,12 +922,26 @@ export async function readUpdateStatus(opts = {}) {
668
922
  const st = await readUpdateState(home);
669
923
  const prefs = await readUpdatePrefs({ home, env: opts.env });
670
924
  const lock = readUpdateLock(home);
671
- const install = detectInstallation({ home });
925
+ const install = opts.installation || detectInstallation({ home });
672
926
  const pkg = readPackageAt(install.packageRoot);
927
+ const currentVersion = isKnownVersion(pkg?.version) ? String(pkg?.version) : st.currentVersion;
928
+ const installedDevSha = devShaFromIdentity(currentVersion);
929
+ const channel = resolveUpdateChannel(opts.channel || prefs.channel);
673
930
  return {
674
- currentVersion: isKnownVersion(pkg?.version) ? String(pkg?.version) : st.currentVersion,
931
+ currentVersion,
932
+ // 安装通道从**磁盘身份**判定 (不从状态文件猜); 状态里的 dev 记录另外带出来做历史
933
+ installedChannel: installedDevSha ? 'dev' : 'stable',
934
+ installedDevSha,
935
+ switchableTo: st.switchableTo ?? (installedDevSha
936
+ ? { channel: 'stable', source: 'npm', target: st.latestVersion }
937
+ : { channel: 'dev', source: 'github', target: null }),
938
+ lastDevSha: st.devSha ?? null,
939
+ devRef: st.devRef ?? null,
940
+ devCheckedAt: st.devCheckedAt ?? null,
941
+ sourceFacts: st.sourceFacts ?? null,
675
942
  latestVersion: st.latestVersion,
676
- channel: prefs.channel,
943
+ channel,
944
+ channelKind: channelKindOf(channel),
677
945
  installMethod: install.method,
678
946
  installDir: install.installDir,
679
947
  entryPath: install.entryPath,
@@ -712,5 +980,9 @@ export async function updateSummaryFor(home) {
712
980
  latestVersion: st.latestVersion,
713
981
  lastUpdate: st.lastUpdate ? { at: st.lastUpdate.at, from: st.lastUpdate.from, to: st.lastUpdate.to, status: st.lastUpdate.status, reason: st.lastUpdate.reason } : null,
714
982
  needsRestart: st.needsRestart,
983
+ // 双源 (§12.5): "我装的是哪个源 / 哪个 commit / 能切回哪" 从同一份状态读
984
+ installedChannel: st.installedChannel ?? null,
985
+ installedDevSha: st.installedChannel === 'dev' ? (st.installedDevSha ?? null) : null,
986
+ switchableTo: st.switchableTo ?? null,
715
987
  };
716
988
  }
@@ -18,7 +18,13 @@ import * as fs from 'fs';
18
18
  import * as fsp from 'fs/promises';
19
19
  import { resolveBolloonHome } from '../setup/setup-store.js';
20
20
  // ── 枚举 (冻结: 出口/状态都不许临时造词) ────────────────────────────────────
21
- /** 一次"检查"的结论。7 个值就是全部, 不认识的一律落进 registry_unavailable 并带原因。 */
21
+ /**
22
+ * 一次"检查"的结论。**9 个值就是全部**, 不认识的一律落进 registry_unavailable 并带原因。
23
+ *
24
+ * 2026-09-25 (update-protocol §12.3, 双源): **只增不改** —— 新增 2 个, 错误分类**不合并**:
25
+ * github_unavailable GitHub 不可达 / 403·429 限流 / 没有 Release / 没有 master
26
+ * cross_check_mismatch stable 下 npm 与 GitHub 指向不同版本 (两个源的事实都摆出来)
27
+ */
22
28
  export const CHECK_STATUSES = [
23
29
  'up_to_date', // 确认最新
24
30
  'update_available', // 有新版本
@@ -27,6 +33,8 @@ export const CHECK_STATUSES = [
27
33
  'registry_unavailable', // 可达但拿不到有效数据 (5xx / 包不存在 / 无 dist-tags)
28
34
  'local_version_unknown', // 读不到本地版本 (不猜 0.0.0)
29
35
  'unsupported_installation', // 认得出本地版本, 但这种安装方式不支持自动更新
36
+ 'github_unavailable', // GitHub 源不可用 (offline / rate_limited / not_found / http_error)
37
+ 'cross_check_mismatch', // npm 与 GitHub 两个源不一致 → 拒绝更新
30
38
  ];
31
39
  /** 一次"更新执行"的流水线状态。 */
32
40
  export const UPDATE_RUN_STATUSES = [
@@ -72,6 +80,13 @@ export function emptyUpdateState(now = new Date()) {
72
80
  autoRestart: false,
73
81
  needsRestart: false,
74
82
  updatedAt: now.toISOString(),
83
+ installedChannel: null,
84
+ installedDevSha: null,
85
+ devSha: null,
86
+ devRef: null,
87
+ devCheckedAt: null,
88
+ sourceFacts: null,
89
+ switchableTo: null,
75
90
  };
76
91
  }
77
92
  // ── 读写 (原子) ─────────────────────────────────────────────────────────────