@whyshu/argent-engine 0.0.0-stage → 0.6.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 (340) hide show
  1. package/MANIFEST.json +341 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +576 -0
  55. package/engine/scripts/billing-mode-daily.sh +13 -0
  56. package/engine/scripts/build-engine-package.mjs +126 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +378 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/collect-evidence.mjs +42 -0
  81. package/engine/scripts/config-explain.mjs +278 -0
  82. package/engine/scripts/configure-provider.rb +29 -0
  83. package/engine/scripts/customer-config-migrate.mjs +179 -0
  84. package/engine/scripts/customer-preflight.py +892 -0
  85. package/engine/scripts/customer-readiness.sh +135 -0
  86. package/engine/scripts/daily-report-inbox.py +242 -0
  87. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  88. package/engine/scripts/daily-report.mjs +1310 -0
  89. package/engine/scripts/daily-report.sh +47 -0
  90. package/engine/scripts/data/lip-product-dict.json +306 -0
  91. package/engine/scripts/deliver-customer.mjs +932 -0
  92. package/engine/scripts/delivery-faces.txt +31 -0
  93. package/engine/scripts/desktop-trial.sh +146 -0
  94. package/engine/scripts/dev-window.sh +261 -0
  95. package/engine/scripts/drill-clean-machine.sh +315 -0
  96. package/engine/scripts/dsh-api-client.mjs +215 -0
  97. package/engine/scripts/dsh-backup-scope.sh +83 -0
  98. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  99. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  100. package/engine/scripts/dsh-plane.mjs +153 -0
  101. package/engine/scripts/dsh-portforward.py +223 -0
  102. package/engine/scripts/dsh-preflight.mjs +179 -0
  103. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  104. package/engine/scripts/dsh-release-watch.mjs +458 -0
  105. package/engine/scripts/dsh-remote-check.sh +99 -0
  106. package/engine/scripts/dsh-restart.sh +179 -0
  107. package/engine/scripts/dsh-restore.sh +111 -0
  108. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  109. package/engine/scripts/dsh-snapshot.mjs +116 -0
  110. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  111. package/engine/scripts/dsh-upgrade.mjs +612 -0
  112. package/engine/scripts/dsh-url.sh +86 -0
  113. package/engine/scripts/dsh-web-launch.mjs +155 -0
  114. package/engine/scripts/ensure-store-windows.py +509 -0
  115. package/engine/scripts/entitlements.py +458 -0
  116. package/engine/scripts/env_probe.py +411 -0
  117. package/engine/scripts/feishu-card.mjs +252 -0
  118. package/engine/scripts/final-check.mjs +43 -0
  119. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  120. package/engine/scripts/flow-auto-retry.py +454 -0
  121. package/engine/scripts/flow-requirements.json +735 -0
  122. package/engine/scripts/flow-scaffold.py +275 -0
  123. package/engine/scripts/flow-timers-plist.sh +87 -0
  124. package/engine/scripts/flow-timers.sh +184 -0
  125. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  126. package/engine/scripts/flow-trigger.sh +571 -0
  127. package/engine/scripts/flow_billing.py +354 -0
  128. package/engine/scripts/flow_capability_stats.py +314 -0
  129. package/engine/scripts/flow_case.py +194 -0
  130. package/engine/scripts/flow_claim.py +497 -0
  131. package/engine/scripts/flow_config.py +1025 -0
  132. package/engine/scripts/flow_failure.py +588 -0
  133. package/engine/scripts/flow_learn.py +105 -0
  134. package/engine/scripts/flow_ledger.py +235 -0
  135. package/engine/scripts/flow_policy.py +238 -0
  136. package/engine/scripts/flow_preflight.py +339 -0
  137. package/engine/scripts/flow_registry.py +341 -0
  138. package/engine/scripts/flow_retry.py +304 -0
  139. package/engine/scripts/flow_return_to_ops.py +191 -0
  140. package/engine/scripts/flow_source.py +315 -0
  141. package/engine/scripts/flow_sources/__init__.py +53 -0
  142. package/engine/scripts/flow_sources/cases_json.py +106 -0
  143. package/engine/scripts/flow_sources/common.py +428 -0
  144. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  145. package/engine/scripts/flow_stale.py +185 -0
  146. package/engine/scripts/flow_status_reconcile.py +344 -0
  147. package/engine/scripts/flow_triage.py +849 -0
  148. package/engine/scripts/flow_write_verify.py +496 -0
  149. package/engine/scripts/fulfillment.js +586 -0
  150. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  151. package/engine/scripts/gen-preset-rows.mjs +259 -0
  152. package/engine/scripts/init-ml.sh +2278 -0
  153. package/engine/scripts/install-internal-role.sh +190 -0
  154. package/engine/scripts/install-license.mjs +641 -0
  155. package/engine/scripts/install-one-shot.mjs +665 -0
  156. package/engine/scripts/integration_contract.py +220 -0
  157. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  158. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  159. package/engine/scripts/invoice-mapping.example.json +40 -0
  160. package/engine/scripts/invoice-table.sh +195 -0
  161. package/engine/scripts/invoice_carrier_notice.py +279 -0
  162. package/engine/scripts/invoice_fail_closeout.py +297 -0
  163. package/engine/scripts/invoice_lip_runner.py +1508 -0
  164. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  165. package/engine/scripts/invoice_ready_audit.py +191 -0
  166. package/engine/scripts/invoice_runner.py +675 -0
  167. package/engine/scripts/invoice_stageb.py +516 -0
  168. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  169. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  170. package/engine/scripts/lease.py +377 -0
  171. package/engine/scripts/lib/agent-install.mjs +649 -0
  172. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  173. package/engine/scripts/lib/argent-datadir.sh +18 -0
  174. package/engine/scripts/lib/argent_datadir.py +285 -0
  175. package/engine/scripts/lib/argent_filelock.py +507 -0
  176. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  177. package/engine/scripts/lib/cdp_ports.py +197 -0
  178. package/engine/scripts/lib/customer-env.mjs +102 -0
  179. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  180. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  181. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  182. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  183. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  184. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  185. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  186. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  187. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  188. package/engine/scripts/lib/patch-compose.mjs +241 -0
  189. package/engine/scripts/lib/patch-target.mjs +35 -0
  190. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  191. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  192. package/engine/scripts/lib/preset-compat.mjs +127 -0
  193. package/engine/scripts/lib/preset-install.mjs +575 -0
  194. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  195. package/engine/scripts/lib/preset-sync.mjs +59 -0
  196. package/engine/scripts/lib/profile-retire.mjs +97 -0
  197. package/engine/scripts/lib/python-runtime.mjs +190 -0
  198. package/engine/scripts/lib/registry.mjs +72 -0
  199. package/engine/scripts/lib/repo-root.sh +56 -0
  200. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  201. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  202. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  203. package/engine/scripts/lib/settings-panels.mjs +816 -0
  204. package/engine/scripts/lib/stdio_utf8.py +86 -0
  205. package/engine/scripts/lib/wake-auth.mjs +392 -0
  206. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  207. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  208. package/engine/scripts/license-e2e-drill.mjs +426 -0
  209. package/engine/scripts/license-gate.mjs +245 -0
  210. package/engine/scripts/lip_backlog.py +171 -0
  211. package/engine/scripts/lip_table.py +185 -0
  212. package/engine/scripts/local-usage.mjs +214 -0
  213. package/engine/scripts/migrate-providers.mjs +127 -0
  214. package/engine/scripts/migrate-skills.sh +65 -0
  215. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  216. package/engine/scripts/ml-store-ids.json +26 -0
  217. package/engine/scripts/ml_artifact_attribution.py +333 -0
  218. package/engine/scripts/ml_capacity_probe.py +1158 -0
  219. package/engine/scripts/ml_hub_status.py +1556 -0
  220. package/engine/scripts/ml_schema_check.py +115 -0
  221. package/engine/scripts/ml_store_ids.py +139 -0
  222. package/engine/scripts/npm-publish-argent.mjs +499 -0
  223. package/engine/scripts/npm-publish-argent.sh +20 -0
  224. package/engine/scripts/orchestrator.py +13811 -0
  225. package/engine/scripts/pack-plugins.mjs +140 -0
  226. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  227. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  228. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  229. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  230. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  231. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  232. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  233. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  234. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  235. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  236. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  237. package/engine/scripts/plan-role-rename.mjs +125 -0
  238. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  239. package/engine/scripts/plugin-composition.json +15 -0
  240. package/engine/scripts/poc-mcp-host.mjs +455 -0
  241. package/engine/scripts/poll-fulfillment.sh +295 -0
  242. package/engine/scripts/poll-invoice.sh +76 -0
  243. package/engine/scripts/profile-doctor.mjs +686 -0
  244. package/engine/scripts/provider-select.sh +71 -0
  245. package/engine/scripts/provider-switch.sh +146 -0
  246. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  247. package/engine/scripts/python-runtime.sh +78 -0
  248. package/engine/scripts/query_runner.py +310 -0
  249. package/engine/scripts/release-argent.sh +574 -0
  250. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  251. package/engine/scripts/retire-inventory.mjs +40 -0
  252. package/engine/scripts/run-tests.sh +257 -0
  253. package/engine/scripts/s1-audit.mjs +85 -0
  254. package/engine/scripts/self-sign-license.mjs +179 -0
  255. package/engine/scripts/session-health-scan.mjs +63 -0
  256. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  257. package/engine/scripts/setup.sh +22 -0
  258. package/engine/scripts/skill-install.mjs +629 -0
  259. package/engine/scripts/sku_translate.py +148 -0
  260. package/engine/scripts/sync-plugins.sh +90 -0
  261. package/engine/scripts/sync-presets.sh +27 -0
  262. package/engine/scripts/sync-scripts.sh +445 -0
  263. package/engine/scripts/telemetry.py +873 -0
  264. package/engine/scripts/ui-copy-count.mjs +220 -0
  265. package/engine/scripts/usage-anchor.sh +137 -0
  266. package/engine/scripts/usage_report.py +531 -0
  267. package/engine/scripts/validate-preset-schema.mjs +208 -0
  268. package/engine/scripts/vertical-init.mjs +323 -0
  269. package/engine/scripts/vertical-lint.mjs +482 -0
  270. package/engine/scripts/wenyao-browser.sh +74 -0
  271. package/engine/scripts/wenyao-fixture-export.py +116 -0
  272. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  273. package/engine/scripts/wenyao-login.sh +35 -0
  274. package/engine/scripts/wenyao-template-map.json +292 -0
  275. package/engine/scripts/wenyao_attach.py +131 -0
  276. package/engine/scripts/wenyao_download_template.py +144 -0
  277. package/engine/scripts/wenyao_fill_template.py +568 -0
  278. package/engine/scripts/wenyao_login.py +145 -0
  279. package/engine/scripts/wenyao_portal.py +633 -0
  280. package/engine/scripts/wenyao_recon.py +230 -0
  281. package/engine/scripts/wenyao_run.py +503 -0
  282. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  283. package/engine/scripts/wenyao_skill_contract.py +156 -0
  284. package/engine/scripts/wenyao_template_fetch.py +175 -0
  285. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  286. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  287. package/engine/scripts/ziniao-keepalive.sh +217 -0
  288. package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
  289. package/engine/scripts/ziniao-windows.sh +154 -0
  290. package/engine/skills/README.md +86 -0
  291. package/engine/skills/argent-dev/SKILL.md +41 -0
  292. package/engine/skills/internal.json +37 -0
  293. package/engine/skills/lktop-pm/SKILL.md +35 -0
  294. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  295. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  296. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  297. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  298. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  299. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  300. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  301. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  302. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  303. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  304. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  305. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  306. package/engine/skills/ml-invoice/SKILL.md +506 -0
  307. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  308. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  309. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  310. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  311. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  312. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  313. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  314. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  315. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  316. package/engine/skills/pet-dev/SKILL.md +42 -0
  317. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  318. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  319. package/engine/version.mjs +1 -0
  320. package/engine/verticals/README.md +103 -0
  321. package/engine/verticals/_engine-words.baseline.txt +237 -0
  322. package/engine/verticals/_engine-words.json +19 -0
  323. package/engine/verticals/mercadolibre/README.md +43 -0
  324. package/engine/verticals/mercadolibre/config.json +709 -0
  325. package/engine/verticals/mercadolibre/flows.json +738 -0
  326. package/engine/verticals/mercadolibre/manifest.json +538 -0
  327. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  328. package/engine/verticals/mercadolibre/preflight.py +143 -0
  329. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  330. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  331. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  332. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  333. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  334. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  335. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  336. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  337. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  338. package/package.json +22 -4
  339. package/requirements.txt +5 -0
  340. package/README.md +0 -3
@@ -0,0 +1,641 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-license —— 把签名许可文件**安全安装**到客户机(2026-09-30)
4
+ *
5
+ * 装机三步曲的第 ③ 步:服务端 `issue-license-file.mjs` 签发 → 传到客户机 → 本脚本安装。
6
+ * 安全要求(都是"装机时最容易做错"的地方):
7
+ * · **先验签再落盘**:验不过绝不写(否则客户机上多一份"看起来有授权"的坏文件);
8
+ * · 落盘 **0600**、临时文件 + rename(写一半断电不会留下半份许可);
9
+ * · 覆盖前**备份**旧文件(*.bak-<时间戳>),便于回滚到上一份许可;
10
+ * · 密钥文件(`--secret-file`)同样 0600 + 备份,且**从不回显密钥值**。
11
+ *
12
+ * 用法:
13
+ * node scripts/install-license.mjs --file <许可文件> [--secret-file <密钥文件>] [--json] [--dry-run]
14
+ * node scripts/install-license.mjs --file <许可文件> --sign-v2 [--device-code <64hex>|--device-from-local|--no-device]
15
+ * [--secret-file <密钥文件>] [--json] [--dry-run] # 本地**重签**成 v2(把 device 纳入签名)
16
+ * node scripts/install-license.mjs --file <许可文件> --instance-token-file <实例令牌文件> [--json] [--dry-run]
17
+ * # ↑ **机器自带凭证**(2026-10-05):与许可同一条离线下发链路一并安装
18
+ * 目标路径:`$ARGENT_DATA_DIR/license.json`(未设则 `~/.dsh/argent/license.json`);
19
+ * 密钥 `$ARGENT_DATA_DIR/license.secret`;实例令牌 `$ARGENT_DATA_DIR/instance-token.json`。
20
+ * 退出码:0 已安装(含宽限;**含"与现役一致、什么都没做"**)| 1 验签失败/已超期/设备码不匹配或自助重绑用尽/
21
+ * **现役倒退被拒**(`regression_refused`)(三者都**未写入**)| 2 用法或读取错误
22
+ *
23
+ * `--json` 回显形状(**2026-10-04 起恒定**):任何退出路径里 `device` 与 `rebind` 两个键**都存在** ——
24
+ * `device` = `deviceEcho()` 的稳定对象或 `null`(`null` = 设备这一层没到,如用法错);
25
+ * `rebind` = `rebindEcho()` 的稳定对象 `{consumed,count,rebindsLeft,store,canRebind,note}`(guard 拒绝)
26
+ * 或 `null`(用法/读取错)。**动机**:`regression_refused` 曾经既没有 `device` 也没有 `rebind` ⇒
27
+ * 消费者按成功路径读 `.rebind.consumed` 当场 `TypeError`,把一次**可读的拒绝**变成调用方崩溃
28
+ * (2026-10-04 定位的间歇失败正是这么暴露的)。补占位**不改**拒绝语义、退出码与既有字段含义。
29
+ *
30
+ * 设备绑定(§3.6① 第 3 步,2026-10-03):载荷里**可选**的 `device` 声明存在时**先比对**本机设备码 ——
31
+ * 不一致 ⇒ **不落盘**(原许可一个字节都不动)+ exit 1 + `reason:device_mismatch`;
32
+ * 自助重绑用 `--rebind`(最多 3 次,第 4 次起 `rebind_exhausted` + 人工指引),计数落
33
+ * `$ARGENT_DATA_DIR/license-rebind.json`。
34
+ * · `device.signed`:v1 ⇒ **false**(该字段**未进签名**,是"服务端声明 + 客户端比对");
35
+ * v2(`--sign-v2` 或服务端升 v2 后签发)⇒ **true**(内容/缺位都被签名覆盖)。
36
+ *
37
+ * 重绑(`--rebind`)与**现役许可比对**(2026-10-04,装机/续期侧的两条守卫):
38
+ * · `--rebind`:设备码不匹配但还有自助次数 ⇒ 消耗 1 次并放行(计数落 `$ARGENT_DATA_DIR/license-rebind.json`);
39
+ * · **比对现役**(`$ARGENT_DATA_DIR/license.json`,唯一判据:它自己也能验签过):
40
+ * ⓐ **幂等**:与现役**载荷+签名逐字节相同** ⇒ `unchanged:true`,**不备份、不重写**(续期重放不再产生 churn);
41
+ * ⓑ **拒绝倒退**(`regression_refused`,exit 1,**不落盘**):更早到期(`stale`)或能力/业务线/席位被收窄(`narrowed`)
42
+ * —— 一次手滑就能把客户有效期改短 / 静默收回能力,所以默认拒绝;确实要装(如纠错、吊销)⇒ 显式 `--allow-regression`;
43
+ * ⓒ **设备不一致**(续期被换绑)只**如实标注**(`renewal.deviceChanged`),不在这一层拒绝:
44
+ * 设备这一侧的拒绝由 ② 的比对负责(不匹配 ⇒ 不落盘),两处判据不重复。
45
+ *
46
+ * 重签(`--sign-v2` / `--sign-version <1|2>`,2026-10-03):把**已验签**的原件按 v2 载荷重签一份
47
+ * (`device` = `--device-code`,缺省取本机设备码)。三条纪律:
48
+ * ① **只签不改**:capabilities/verticals/seats/issuedAt/expiresAt/graceDays 逐字继承验签过的原件
49
+ * ⇒ 重签**不扩权**(且原件必须**先验签通过**,否则拒绝——不给人"拿垃圾换成有效许可"的入口);
50
+ * ② **密钥必须显式**:只认 `--secret-file` 或 `ARGENT_LICENSE_SECRET`,**不**自动用本机已装的
51
+ * `license.secret`(那会让"拿生产密钥自签"变成一次无意识的默认动作);
52
+ * ③ `--sign-version 1` 是**回滚**用(重签回 v1 ⇒ 产物里**没有** `device`:v1 根本签不了它)。
53
+ * ⚠️ 签名是 HMAC-SHA256(对称密钥)⇒ 能读密钥的人**总能自签**;重签工具只保证"口径与契约一致",
54
+ * 不构成安全边界(要防伪造得换非对称签名,见 §3.6③)。
55
+ *
56
+ * ## 实例令牌(`--instance-token-file`,2026-10-05「机器自带凭证」)
57
+ * 客户机是"一直开着、没人登录"的 ⇒ 账号 JWT(**30 天**)到期即 401,服务端一轮换密钥就全灭
58
+ * (2026-09-14 实况)。实例令牌是**离线签名**的长期凭证(默认 365 天),由服务端
59
+ * `whyshu-svr/scripts/issue-instance-token.mjs` 签发,本脚本与许可**一并**装到
60
+ * `$ARGENT_DATA_DIR/instance-token.json`(0600,只 `token` 一个键是权威)。
61
+ * **先判后写**(三条,全在写盘之前):① 形态(`it1.` 三段 + `typ=instance-token`);
62
+ * ② 归属(令牌 `userId` == 本次许可 `userId`);③ 绑定(两边都有设备码时必须一致);
63
+ * ④ 期限(已过期 ⇒ 拒装)。契约与续期/吊销见 `whyshu-svr/docs/ops/instance-token.md`。
64
+ */
65
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
66
+ import { join, resolve } from 'node:path'
67
+ import {
68
+ dataDir, defaultLicensePath, defaultSecretPath, loadLicenseFile, signaturePayloadOf, signLicenseFile, verifyLicenseFile,
69
+ } from '../core/lib/license-file.mjs'
70
+ import { MAX_REBINDS, bindingOf, currentDevice, deviceClaimOf, readRebindStore, rebindStorePath, recordRebind } from '../core/lib/license-device.mjs'
71
+
72
+ const argOf = (name, dflt = null) => {
73
+ const i = process.argv.indexOf(name)
74
+ return i > -1 ? process.argv[i + 1] ?? dflt : dflt
75
+ }
76
+ const has = (name) => process.argv.includes(name)
77
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, '-')
78
+
79
+ /**
80
+ * `rebind` 回显的**稳定形状**(2026-10-04 补;起因是一次间歇失败暴露的接口缺陷)。
81
+ * 键集合恒定 `{consumed, count, rebindsLeft, store, canRebind, note}`,其中 `consumed` **恒为布尔**。
82
+ * 为什么必须恒定:消费者(宿主 UI / `license` verb / 测试)按**成功路径**读 `.rebind.consumed`
83
+ * 是最自然的写法;若某条拒绝路径压根没有这个键,一次**可读的拒绝**就表现成调用方 TypeError
84
+ * ——`TypeError: Cannot read properties of undefined (reading 'consumed')`(2026-10-04 定位的
85
+ * 间歇失败正是这么暴露的:`regression_refused` 的 JSON 里没有 `rebind`)。
86
+ * 语义(三条路径一致,不因路径不同而变):
87
+ * · `consumed` = 本次是否**真的**消耗了一次自助重绑(拒绝路径恒 false:拒绝 ⇒ 未落盘 ⇒ 未计数);
88
+ * · `count` = 计数文件里的**当前**值(拒绝路径 = 未变;`null` = 还没读到计数);
89
+ * · `rebindsLeft`= 自助重绑还剩几次(`null` = 这一层没到);
90
+ * · `store` = 计数文件路径(`null` = 还没读到计数);
91
+ * · `canRebind` = `--rebind` 这条路还走不走得通(= 还有剩余次数;与"本次拒绝的原因"无关,
92
+ * 拒绝原因在 `reason`,补救动作写在 `note`/`next`);
93
+ * · `note` = 人话(点名补救动作)。
94
+ * ⛔ 纪律:只是**补占位/统一形状** —— 不改拒绝语义、不改退出码、不改任何既有字段的含义。
95
+ */
96
+ const rebindEcho = ({ consumed = false, count = null, rebindsLeft = null, store = null, canRebind = false, note = '' } = {}) => ({
97
+ consumed: consumed === true, count, rebindsLeft, store, canRebind, note,
98
+ })
99
+
100
+ /**
101
+ * 失败出口:**A2 形状**(`--json` 时 stdout 只有一份 JSON;人话走 stderr)。
102
+ * `reason` 是机器可判短码(`core/contracts/argent.contracts.json#reasonCodes`),**不要**靠 grep 中文。
103
+ * **形状恒定(2026-10-04)**:任何退出路径里 `rebind` 与 `device` 两个键**都存在**,调用方不必猜
104
+ * 字段是否存在 —— `device` 是对象或 `null`(`null` = 设备这一层根本没到,如用法错);
105
+ * `rebind` 是 `rebindEcho()` 的稳定对象(guard 拒绝,exit 1)或 `null`(用法/读取错,exit 2)。
106
+ * 具体路径给得出更全的回显时(如设备比对拒绝)由调用方在 `extra` 里显式覆盖,**默认只是兜底**。
107
+ */
108
+ const fail = (reason, message, code = 2, extra = {}) => {
109
+ if (has('--json')) {
110
+ const body = { ok: false, empty: false, code, reason, error: message, dryRun: has('--dry-run'), ...extra }
111
+ // 兜底占位:guard 拒绝(exit 1)= 调用方很可能就是要读 `.rebind.consumed` 的那条路 ⇒ 给完整形状;
112
+ // 用法错(exit 2)= `null`("没做这件事",与"做了但结果为空"一眼分得开,同 `resign` 的口径)。
113
+ if (!('rebind' in body)) body.rebind = code === 1 ? rebindEcho({ note: `未消耗自助重绑次数(在「${reason}」这一步被拒,未落盘)` }) : null
114
+ if (!('device' in body)) body.device = null
115
+ console.log(JSON.stringify(body, null, 2))
116
+ }
117
+ console.error(`✗ ${message}`)
118
+ process.exit(code)
119
+ }
120
+ const die = (m, code = 2) => fail(code === 1 ? 'guard_rejected' : 'usage_error', m, code)
121
+
122
+ if (has('--help') || has('-h')) {
123
+ console.log('用法:node scripts/install-license.mjs --file <许可文件> [--secret-file <密钥文件>] [--instance-token-file <实例令牌文件>] [--rebind] [--allow-regression] [--json] [--dry-run]')
124
+ console.log(' node scripts/install-license.mjs --file <许可文件> --sign-v2 [--device-code <64hex>|--device-from-local|--no-device] [--secret-file <密钥文件>] [--json] [--dry-run]')
125
+ console.log(' --instance-token-file:可选;与许可**同一条链路**安装"机器自带凭证"(默认 365 天,不再依赖 30 天账号令牌)')
126
+ console.log(' --allow-regression:显式放行"比现役更早到期 / 能力席位被收窄"的安装(默认拒绝:regression_refused)')
127
+ process.exit(0)
128
+ }
129
+
130
+ const dryRun = has('--dry-run')
131
+ const rebindRequested = has('--rebind')
132
+ const allowRegression = has('--allow-regression')
133
+
134
+ // ── 实例令牌(**机器自带凭证**,2026-10-05):与许可走**同一条离线下发链路** ─────────
135
+ // 为什么要一起装:交付一台机器时要交付"它能自己活下去"的凭证。账号 JWT 只有 30 天、服务端
136
+ // 一轮换就全灭(2026-09-14 实况)⇒ 遥测会静默转 401 长退避。实例令牌是离线签名的长期凭证,
137
+ // 客户机原样回传、服务端本地验签(契约:whyshu-svr/docs/ops/instance-token.md)。
138
+ // 三条纪律:
139
+ // ① **先判后写**:令牌里的 `userId`/`deviceCode` 必须与**本次安装的许可**一致 —— 装错一份令牌
140
+ // 不会立刻报错,只会让那台机器的上报长期 401/403(静默故障),所以在**装机这一刻**就拦;
141
+ // ② **结构化单键**:文件里只有 `token` 一个键是**权威**(其余键仅便于人工核对,不参与鉴权);
142
+ // ③ **不装不算失败**:没给 `--instance-token-file` ⇒ 只回显 `installed:false`,绝不改许可那一步的结论。
143
+ const INSTANCE_TOKEN_FILENAME = 'instance-token.json'
144
+ const INSTANCE_TOKEN_PREFIX = 'it1.'
145
+ const instanceTokenSrc = argOf('--instance-token-file') ? resolve(argOf('--instance-token-file')) : null
146
+
147
+ /** 解析令牌文件:JSON 信封(`{token}`)或裸紧凑串都接受。返回 `{token, payload, raw, error}`。 */
148
+ function parseInstanceToken(rawText) {
149
+ const raw = String(rawText || '').trim()
150
+ if (!raw) return { token: '', payload: null, raw, error: 'empty' }
151
+ let token = raw
152
+ if (raw.startsWith('{')) {
153
+ try {
154
+ const doc = JSON.parse(raw)
155
+ token = String((doc || {}).token || '').trim()
156
+ } catch {
157
+ return { token: '', payload: null, raw, error: 'unparsable-json' }
158
+ }
159
+ }
160
+ if (!token) return { token: '', payload: null, raw, error: 'missing-token-key' }
161
+ if (!token.startsWith(INSTANCE_TOKEN_PREFIX)) {
162
+ return { token, payload: null, raw, error: 'bad_prefix' }
163
+ }
164
+ const parts = token.split('.')
165
+ if (parts.length !== 3) return { token, payload: null, raw, error: 'malformed' }
166
+ try {
167
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
168
+ if (!payload || typeof payload !== 'object') return { token, payload: null, raw, error: 'malformed' }
169
+ // 客户端**不验签**(没有签名密钥,那是服务端的事);只做形状自检 + 装机期的绑定核对
170
+ if (payload.typ !== 'instance-token') return { token, payload, raw, error: 'bad_type' }
171
+ return { token, payload, raw, error: '' }
172
+ } catch {
173
+ return { token, payload: null, raw, error: 'malformed' }
174
+ }
175
+ }
176
+ const signV2 = has('--sign-v2')
177
+ const signVersionArg = argOf('--sign-version')
178
+ const deviceCodeArg = argOf('--device-code')
179
+ const deviceFromLocal = has('--device-from-local')
180
+ const noDevice = has('--no-device')
181
+ // 重签版本:`--sign-v2` = `--sign-version 2`(同一件事的两个写法;都不给 ⇒ 不重签)
182
+ const signVersion = signV2 ? 2 : (signVersionArg === null ? null : Number(signVersionArg))
183
+ if (signVersion !== null && ![1, 2].includes(signVersion)) {
184
+ die(`--sign-version 只认 1 或 2(本客户端支持的签名版本:v1 9 项 / v2 9 项 + device):${signVersionArg}`, 2)
185
+ }
186
+ if (signVersion === null && (deviceCodeArg || deviceFromLocal || noDevice)) {
187
+ die('`--device-code` / `--device-from-local` / `--no-device` 只在重签时才有意义 ⇒ 请与 `--sign-v2`(或 `--sign-version 2`)一起用', 2)
188
+ }
189
+
190
+ const src = argOf('--file')
191
+ if (!src) die('必须给 --file <许可文件>(用法见 --help)')
192
+ const srcPath = resolve(src)
193
+ if (!existsSync(srcPath)) die(`许可文件不存在:${srcPath}`, 2)
194
+ let file = null
195
+ try { file = JSON.parse(readFileSync(srcPath, 'utf-8')) } catch (e) { die(`许可文件解析失败:${e.message}`, 2) }
196
+
197
+ // ── 密钥:显式 --secret-file 优先(随后会被安装到本机密钥位)──────────
198
+ const secretSrc = argOf('--secret-file') ? resolve(argOf('--secret-file')) : null
199
+ if (secretSrc && !existsSync(secretSrc)) die(`密钥文件不存在:${secretSrc}`, 2)
200
+ // 兼容两种交付形态:① 纯密钥文本(本客户端 license.secret 的形态);② 从服务端 .env 直接拷来的一行
201
+ // `LICENSE_SIGNING_SECRET=xxxx`。hex 里不带 `=`,所以按 `键=值` 解析不会误伤纯文本。
202
+ const secretRaw = secretSrc ? readFileSync(secretSrc, 'utf-8') : ''
203
+ const secretText = (secretRaw.match(/^[ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*(.+)$/m)?.[1] ?? secretRaw).trim()
204
+ if (secretSrc && !secretText) die(`密钥文件是空的:${secretSrc}`, 2)
205
+
206
+ // ① **先验签**:密钥优先用 --secret-file 带来的那把,其次本机既有来源(env / 已装密钥文件)
207
+ const originalVersion = Number(file.v) === 1 ? 1 : (Number(file.v) === 2 ? 2 : null)
208
+ let r = verifyLicenseFile(file, { secret: secretText || undefined })
209
+ if (!r.ok) {
210
+ const detail = r.reason === 'expired' ? r.warnings.join(';')
211
+ : (r.reason === 'no_secret' ? '本机没有签名密钥' : '文件被改动过,或者不是用本机这把密钥签的')
212
+ const hint = r.reason === 'no_secret'
213
+ ? '用 --secret-file <密钥文件> 指定服务端那把密钥,或先设置 ARGENT_LICENSE_SECRET'
214
+ : (r.reason === 'expired'
215
+ ? '请让服务端重新签发(node scripts/issue-license-file.mjs --user <id>)'
216
+ : '不要把这份文件装到客户机(先确认传输过程没有损坏/被改)')
217
+ fail('bad_signature', `验签未通过(${r.reason}):${detail} ⇒ ${hint}`, 1)
218
+ }
219
+
220
+ // ①′ **重签**(可选):把**已验签**的原件按 v2 载荷重签(`device` 进签名)。
221
+ // 纪律见文件头:只签不改(不扩权)、密钥必须显式、`--sign-version 1` 是回滚用。
222
+ let resign = null
223
+ if (signVersion !== null) {
224
+ // ⛔ **不**走 `resolveSecret()` 的完整来源链:它兜底会读本机已装的 `license.secret`
225
+ // (= 拿生产密钥自签变成无意识的默认动作)⇒ 重签只认**显式**来源:`--secret-file` 或环境变量。
226
+ const keySecret = String(secretText || process.env.ARGENT_LICENSE_SECRET || '').trim()
227
+ if (!keySecret) {
228
+ die('重签必须**显式**给密钥来源:`--secret-file <密钥文件>` 或 `ARGENT_LICENSE_SECRET`'
229
+ + '(⛔ 不会自动用本机已装的 license.secret —— 那会让"拿生产密钥自签"变成一次无意识的默认动作)', 2)
230
+ }
231
+ let device = null
232
+ if (signVersion === 2 && !noDevice) {
233
+ if (deviceCodeArg) {
234
+ const code = String(deviceCodeArg).trim()
235
+ if (!/^[0-9a-f]{64}$/i.test(code)) {
236
+ die(`--device-code 必须是 64 位十六进制的**设备码**(\`argent license request\` 打印的那串);`
237
+ + `⛔ 不要给主机名/MAC/IP(D3:指纹只认硬件 UUID 一项):${code.slice(0, 32)}…`, 2)
238
+ }
239
+ device = { code: code.toLowerCase(), algo: 'ioplatformuuid-sha256', issuedAt: new Date().toISOString() }
240
+ } else {
241
+ // 缺省:读**本机**设备码(`--device-from-local` 与"什么都没给"是同一条路)
242
+ const dev = currentDevice({})
243
+ if (!dev.ok) {
244
+ die(`读不到本机设备码(${dev.reason}):${dev.error} ⇒ 要么显式给 --device-code <64hex>,要么显式 --no-device`
245
+ + '(不静默降级成 unbound:那会把"绑不上"悄悄变成"没绑定")', 2)
246
+ }
247
+ device = { code: dev.code, algo: dev.algo, issuedAt: dev.issuedAt }
248
+ }
249
+ }
250
+ // `issuedAt` 原件不动("只签不改");载荷字段全部取自**验签过**的 `r`(不是原始 JSON)
251
+ const source = {
252
+ v: signVersion, userId: r.userId, capabilities: r.capabilities, verticals: r.verticals, seats: r.seats,
253
+ issuedAt: r.issuedAt, expiresAt: r.expiresAt, graceDays: r.graceDays, instanceHint: r.instanceHint,
254
+ ...(device ? { device } : {}),
255
+ }
256
+ const signedRes = signLicenseFile(source, keySecret, { version: signVersion })
257
+ if (!signedRes.ok) die(`重签失败(${signedRes.reason}${signedRes.warning ? `:${signedRes.warning}` : ''})`, 2)
258
+ file = signedRes.file
259
+ // **重签完必须自验**:回显与后续判定一律看**产物**(不是原件的结论);自验不过 ⇒ 不落盘
260
+ const rv = verifyLicenseFile(file, { secret: keySecret })
261
+ if (!rv.ok) die(`重签产物自验失败(${rv.reason}):拒绝安装(原许可未改动)`, 2)
262
+ r = rv
263
+ resign = {
264
+ requested: true, from: originalVersion, to: signVersion,
265
+ payload: signedRes.payload,
266
+ deviceCode: device ? device.code : null,
267
+ keySource: secretSrc ? 'secret-file' : 'env',
268
+ note: `已按 v${signVersion} 重签:capabilities/verticals/seats/issuedAt/expiresAt/graceDays **逐字继承验签过的原件**`
269
+ + '(重签**不扩权**)'
270
+ + (signVersion === 2
271
+ ? ';`device` 已进签名 ⇒ 此后改它/摘它/降 v 重拼都会验签失败'
272
+ : ';⚠️ 重签回 v1 ⇒ 产物里**没有** `device`(v1 的载荷签不了它),设备绑定随之失效'),
273
+ }
274
+ }
275
+
276
+ // ② **设备比对**(§3.6① 第 3 步):`device` 是**可选**字段 ⇒ 无声明 = unbound 放行(D1-兼容,存量许可不受影响)
277
+ const claimed = deviceClaimOf(file)
278
+ const storePath = rebindStorePath()
279
+ const store = readRebindStore({ path: storePath })
280
+ const deviceInfo = currentDevice({})
281
+ const binding = bindingOf({ claimed, current: deviceInfo, store })
282
+ const mismatch = binding.binding === 'mismatch'
283
+ // `--rebind` 只在**确实不匹配**且**还有自助次数**时才有意义(本就一致 ⇒ 不消耗次数)
284
+ const rebindConsumed = mismatch && rebindRequested && binding.rebindsLeft > 0
285
+ /**
286
+ * `device` 回显的**唯一构造点**(成功 / 设备拒绝 / 现役倒退拒绝三条路径共用 ⇒ 形状不会各自漂移)。
287
+ * `signed` = 该版本的签名载荷**覆盖 device 这一项**(v2 ⇒ true;v1 ⇒ false:服务端声明 + 客户端比对)。
288
+ */
289
+ const deviceEcho = () => ({
290
+ present: Boolean(claimed), signed: r.deviceSigned === true, binding: binding.binding, reason: binding.reason,
291
+ sanctioned: binding.sanctioned, code: claimed ? claimed.code : null, algo: claimed ? claimed.algo : null,
292
+ machineCode: binding.machineCode, machineReadable: deviceInfo.ok === true,
293
+ rebindsLeft: binding.rebindsLeft, note: binding.note,
294
+ })
295
+ if (mismatch && !rebindConsumed) {
296
+ // 不匹配 ⇒ **不落盘**(原文件一个字节都不动),exit 1 + 结构化 reason(**回显设备态**,宿主才好指引人)
297
+ fail(binding.reason, `设备码不匹配 ⇒ 拒绝安装(原许可未改动):${binding.note}`, 1, {
298
+ signatureVersion: r.signatureVersion === 2 ? 2 : (r.signatureVersion === 1 ? 1 : null),
299
+ deviceSigned: r.deviceSigned === true,
300
+ device: deviceEcho(),
301
+ rebind: rebindEcho({
302
+ count: store.count, rebindsLeft: binding.rebindsLeft, store: storePath,
303
+ canRebind: binding.rebindsLeft > 0,
304
+ note: binding.rebindsLeft > 0 ? `可用 --rebind 自助重绑(还剩 ${binding.rebindsLeft} 次)` : '自助重绑次数已用尽 ⇒ 需人工(服务端重签)',
305
+ }),
306
+ })
307
+ }
308
+
309
+ const targetLicense = defaultLicensePath()
310
+ const targetSecret = defaultSecretPath()
311
+
312
+ // ── ②′ **现役许可比对**(2026-10-04):幂等 / 拒绝倒退 / 换绑如实标注 ────────────
313
+ // 判据只有一条前置:**现役自己也得验签过**(或"验签过但已过期"——那正是最常见的续期场景)。
314
+ // 现役根本验不过(坏文件/别人的密钥/没密钥)⇒ 不做比对(本次安装是修复,不是倒退),只告警。
315
+ let incumbentRaw = null
316
+ try { incumbentRaw = JSON.parse(readFileSync(targetLicense, 'utf-8')) } catch { incumbentRaw = null }
317
+ const incumbent = loadLicenseFile(targetLicense, { secret: secretText || undefined })
318
+ const incumbentUsable = incumbent.ok === true || incumbent.reason === 'expired'
319
+ const incPayload = incumbentRaw ? signaturePayloadOf(incumbentRaw) : null
320
+ const newPayload = signaturePayloadOf(file)
321
+ const incumbentDeviceCode = incumbent.deviceClaimed ? incumbent.deviceClaimed.code : null
322
+ const unchanged = Boolean(incPayload && newPayload && incumbentRaw
323
+ && incPayload.version === newPayload.version
324
+ && incPayload.payload === newPayload.payload
325
+ && String(incumbentRaw.sig || '').toLowerCase() === String(file.sig || '').toLowerCase())
326
+ let comparison = null
327
+ let renewal = null
328
+ if (incumbent.present !== false || incumbentRaw) {
329
+ const incCaps = incumbentUsable ? (incumbent.capabilities || []) : []
330
+ const incVerts = incumbentUsable ? (incumbent.verticals || []) : []
331
+ const missingCapabilities = incumbentUsable ? incCaps.filter((c) => !r.capabilities.includes(c)) : []
332
+ // `verticals` 空数组 = **不限**(口径见 license-file.mjs)⇒ 只在"两边都非空"时才谈收窄
333
+ const missingVerticals = (incVerts.length && r.verticals.length) ? incVerts.filter((v) => !r.verticals.includes(v)) : []
334
+ const seatsNarrowed = incumbentUsable && Number(r.seats) < Number(incumbent.seats || 0)
335
+ comparison = {
336
+ comparable: incumbentUsable,
337
+ incumbentReason: incumbent.reason,
338
+ unchanged,
339
+ stale: incumbentUsable && Date.parse(r.expiresAt) < Date.parse(incumbent.expiresAt),
340
+ narrowed: missingCapabilities.length > 0 || missingVerticals.length > 0 || seatsNarrowed,
341
+ missingCapabilities, missingVerticals, seatsNarrowed,
342
+ incumbentExpiresAt: incumbentUsable ? incumbent.expiresAt : null,
343
+ incomingExpiresAt: r.expiresAt,
344
+ }
345
+ renewal = {
346
+ incumbentExpiresAt: incumbentUsable ? incumbent.expiresAt : null,
347
+ incomingExpiresAt: r.expiresAt,
348
+ // 同机续期**不得**换绑:设备码变了 = 换绑(要么拿错文件,要么这台机器不是签发对象)
349
+ deviceChanged: Boolean(incumbentDeviceCode && claimed && incumbentDeviceCode !== claimed.code),
350
+ incumbentDeviceCode, incomingDeviceCode: claimed ? claimed.code : null,
351
+ note: '同机续期应保持 `device.code` 逐字一致;不同码 = **换绑**(`--rebind` 自助 3 次,超限人工)',
352
+ }
353
+ if (incumbentUsable && (comparison.stale || comparison.narrowed) && !allowRegression) {
354
+ const why = [
355
+ comparison.stale ? `新文件更早到期(现役 ${incumbent.expiresAt} → 新 ${r.expiresAt})` : '',
356
+ comparison.narrowed ? `能力/业务线/席位被收窄(缺 ${[...missingCapabilities, ...missingVerticals].join(',') || '—'}`
357
+ + `${seatsNarrowed ? `;席位 ${incumbent.seats} → ${r.seats}` : ''})` : '',
358
+ ].filter(Boolean).join(';')
359
+ fail('regression_refused', `拒绝安装:这次安装会让现役许可**倒退**(${why})⇒ 原许可未改动。`
360
+ + '确实要装(纠错/吊销/换绑)请显式加 `--allow-regression`(会把差异写进回显与告警)', 1, {
361
+ signatureVersion: r.signatureVersion === 2 ? 2 : (r.signatureVersion === 1 ? 1 : null),
362
+ deviceSigned: r.deviceSigned === true,
363
+ // 设备态与重绑态在**这条拒绝里也不能缺席**(2026-10-04):同样的消费者会读
364
+ // `.device.binding` / `.rebind.consumed`;缺键 = 一次可读的拒绝变成调用方 TypeError。
365
+ device: deviceEcho(),
366
+ rebind: rebindEcho({
367
+ count: store.count, rebindsLeft: binding.rebindsLeft, store: storePath,
368
+ canRebind: binding.rebindsLeft > 0,
369
+ note: '未消耗自助重绑次数(本次在「②′ 现役许可比对」这一步被拒 ⇒ **未落盘**、计数未变);'
370
+ + '要放行一次倒退请显式加 `--allow-regression`(**不是** `--rebind`)'
371
+ + (rebindConsumed ? `;若改用 --allow-regression 重装,本次设备重绑会消耗 1 次(还剩 ${binding.rebindsLeft} 次)` : ''),
372
+ }),
373
+ incumbent: {
374
+ present: true, path: targetLicense, verified: incumbent.ok === true, reason: incumbent.reason,
375
+ expiresAt: incumbentUsable ? incumbent.expiresAt : null,
376
+ signatureVersion: incumbent.signatureVersion === 2 ? 2 : (incumbent.signatureVersion === 1 ? 1 : null),
377
+ capabilities: incCaps, verticals: incVerts, seats: incumbentUsable ? incumbent.seats : null, deviceCode: incumbentDeviceCode,
378
+ },
379
+ comparison, renewal, allowRegression: false,
380
+ next: '核对是不是拿错了文件;确需倒退 ⇒ 重跑并加 `--allow-regression`',
381
+ })
382
+ }
383
+ }
384
+
385
+ // ── ②″ 实例令牌**先判后写**(2026-10-05):装错一份令牌只会造成长期静默 401/403 ──────
386
+ // 判据(三条,全部**在写任何文件之前**):
387
+ // ⓐ 形态:`it1.` 前缀 + 三段 + 载荷可解 + `typ=instance-token`;
388
+ // ⓑ 归属:令牌的 `userId` 必须等于**本次许可的** `userId`(拿错账号的文件 = 上报会挂到别人名下);
389
+ // ⓒ 绑定:两边都声明了设备码时必须一致(拿错机器的文件 = 遥测长期 403)。
390
+ // ⓓ 期限:已过期 ⇒ 拒装(装了也是死信,还容易被误当成"已切换")。
391
+ let tokenInstall = null
392
+ if (instanceTokenSrc) {
393
+ if (!existsSync(instanceTokenSrc)) fail('token_usage_error', `--instance-token-file 不存在:${instanceTokenSrc}`, 2)
394
+ const parsed = parseInstanceToken(readFileSync(instanceTokenSrc, 'utf-8'))
395
+ if (parsed.error) {
396
+ fail('token_malformed', `实例令牌文件不可用(${parsed.error}):${instanceTokenSrc}`
397
+ + ';请用 whyshu-svr 的 `node scripts/issue-instance-token.mjs --user … --instance …` 重新签发', 1)
398
+ }
399
+ const p = parsed.payload
400
+ if (String(p.userId || '') !== String(r.userId)) {
401
+ fail('token_user_mismatch', `实例令牌属于账号 ${String(p.userId).slice(0, 24)}…,与本次许可的账号 `
402
+ + `${String(r.userId).slice(0, 24)}… **不是同一个** ⇒ 拒绝安装(上报会挂到别人名下)`, 1)
403
+ }
404
+ if (claimed && p.deviceCode && String(p.deviceCode).toLowerCase() !== String(claimed.code).toLowerCase()) {
405
+ fail('token_device_mismatch', `实例令牌绑定的是设备 ${String(p.deviceCode).slice(0, 12)}…,`
406
+ + `本机许可是 ${String(claimed.code).slice(0, 12)}… ⇒ 拒绝安装(换机/换绑后需按新设备码重签令牌)`, 1)
407
+ }
408
+ const expMs = Date.parse(String(p.exp || ''))
409
+ if (!Number.isFinite(expMs) || expMs <= Date.now()) {
410
+ fail('token_expired', `实例令牌${Number.isFinite(expMs) ? `已于 ${p.exp} 过期` : '缺少可解析的 exp'} ⇒ 拒绝安装,请重新签发`, 1)
411
+ }
412
+ tokenInstall = { src: instanceTokenSrc, parsed, path: join(dataDir(), INSTANCE_TOKEN_FILENAME) }
413
+ }
414
+
415
+ const result = {
416
+ // A2 最小集(--json 的消费方按字段判,不 grep 中文)
417
+ ok: true, empty: false, code: 0, reason: null,
418
+ dryRun, source: srcPath, target: targetLicense, secretInstalled: false,
419
+ userId: r.userId, capabilities: r.capabilities, verticals: r.verticals, seats: r.seats,
420
+ expiresAt: r.expiresAt, graceDays: r.graceDays, graceUntil: r.graceUntil, inGrace: r.inGrace,
421
+ // 签名版本(1|2)与"device 是否进签名"的唯一回显(判据来自验签结果,本脚本不重算)
422
+ signatureVersion: r.signatureVersion === 2 ? 2 : (r.signatureVersion === 1 ? 1 : null),
423
+ deviceSigned: r.deviceSigned === true,
424
+ // 重签(`--sign-v2`):如未重签 ⇒ null("没做这件事"与"做了但结果为空"必须一眼分得开)
425
+ resign,
426
+ // 设备侧如实回显(`signed:true` = v2 已进签名;`false` = v1 未进签名)
427
+ // —— 与两条拒绝路径**共用** `deviceEcho()`:形状只在那一处定义(拒绝路径不再自建一份)
428
+ device: deviceEcho(),
429
+ rebind: rebindConsumed
430
+ ? { consumed: false, pending: true, note: `将消耗 1 次自助重绑(已用 ${store.count}/${MAX_REBINDS})` }
431
+ : { consumed: false, note: mismatch ? '未重绑(缺 --rebind 或次数用尽)' : '设备码一致或未声明 ⇒ 未消耗重绑次数' },
432
+ // 现役比对(②′):`unchanged` = 与现役**载荷+签名**逐字节相同 ⇒ 不备份、不重写(幂等)
433
+ unchanged,
434
+ incumbent: comparison ? {
435
+ present: true, path: targetLicense, verified: incumbent.ok === true, reason: incumbent.reason,
436
+ expiresAt: comparison.incumbentExpiresAt,
437
+ signatureVersion: incumbent.signatureVersion === 2 ? 2 : (incumbent.signatureVersion === 1 ? 1 : null),
438
+ capabilities: incumbentUsable ? incumbent.capabilities : [], verticals: incumbentUsable ? incumbent.verticals : [],
439
+ seats: incumbentUsable ? incumbent.seats : null, deviceCode: incumbentDeviceCode,
440
+ } : { present: false, path: targetLicense, verified: false, reason: incumbent.reason || 'missing' },
441
+ comparison, renewal, allowRegression,
442
+ backups: [], warnings: [
443
+ ...r.warnings,
444
+ ...(binding.warnings || []),
445
+ ...(comparison && comparison.comparable === false && comparison.incumbentReason !== 'missing'
446
+ ? [`现役许可未通过验签(${comparison.incumbentReason})⇒ 本次**不做**降级/缩权比对(按"修复"处理;`
447
+ + `要核对现役实情请看 \`argent license status\`)`] : []),
448
+ ...(comparison && comparison.unchanged ? ['本文件与现役许可**逐字节一致** ⇒ 未重写、未备份(幂等)'] : []),
449
+ ...(comparison && (comparison.stale || comparison.narrowed) && allowRegression
450
+ ? [`⚠️ 已用 --allow-regression 放行一次**倒退**安装:${comparison.stale ? '更早到期;' : ''}`
451
+ + `${comparison.narrowed ? `收窄(缺 ${[...comparison.missingCapabilities, ...comparison.missingVerticals].join(',') || '—'})` : ''}`] : []),
452
+ ...(renewal && renewal.deviceChanged
453
+ ? [`设备码与现役不同(${String(incumbentDeviceCode || '').slice(0, 12)}… → ${String(claimed && claimed.code || '').slice(0, 12)}…)`
454
+ + '⇒ 这是**换绑**而不是续期:自助 3 次(`--rebind`),超限需人工(服务端按 `license request` 的设备码重签)'] : []),
455
+ ...(resign && resign.to === 2 ? ['本许可已按 **v2** 重签:`device` 进签名 ⇒ 改它/摘它/降 v 重拼都会验签失败'
456
+ + '(⚠️ 但签名仍是 HMAC 对称密钥 ⇒ 不防"持有 license.secret 的人重签")'] : []),
457
+ ],
458
+ }
459
+
460
+ if (!dryRun) {
461
+ mkdirSync(dataDir(), { recursive: true, mode: 0o700 })
462
+ // ⓪ 幂等:与现役逐字节一致 ⇒ **什么都不做**(不备份、不重写、不动 mtime)——
463
+ // 续期/重放同一份文件是常态,不能每次都在客户机上留一堆 .bak 与新的 mtime。
464
+ if (unchanged) {
465
+ try { result.mode = `0${(statSync(targetLicense).mode & 0o777).toString(8)}` } catch { /* 权限拿不到不致命 */ }
466
+ result.verifiedAfterInstall = true
467
+ } else {
468
+ // ② 备份旧文件(许可 + 密钥各自备份;备份里同样有秘密 ⇒ 0600)
469
+ const backupOf = (p) => {
470
+ if (!existsSync(p)) return null
471
+ const b = `${p}.bak-${stamp()}`
472
+ copyFileSync(p, b)
473
+ chmodSync(b, 0o600)
474
+ return b
475
+ }
476
+ const licBackup = backupOf(targetLicense)
477
+ if (licBackup) result.backups.push(licBackup)
478
+ // ③ 密钥先落盘(先密钥后许可:中途失败时至少不会出现"有许可没密钥"的假象)
479
+ if (secretSrc) {
480
+ const secBackup = backupOf(targetSecret)
481
+ if (secBackup) result.backups.push(secBackup)
482
+ writeFileSync(`${targetSecret}.tmp`, `${secretText}\n`, { mode: 0o600 })
483
+ renameSync(`${targetSecret}.tmp`, targetSecret)
484
+ chmodSync(targetSecret, 0o600)
485
+ result.secretInstalled = true
486
+ }
487
+ // ④ 原子写许可:tmp(0600)→ rename;失败不留半份
488
+ const tmp = `${targetLicense}.tmp`
489
+ try {
490
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { mode: 0o600 })
491
+ chmodSync(tmp, 0o600)
492
+ renameSync(tmp, targetLicense)
493
+ } catch (e) {
494
+ rmSync(tmp, { force: true })
495
+ die(`写入失败(已保留原许可):${e.message}`, 2)
496
+ }
497
+ chmodSync(targetLicense, 0o600)
498
+ result.mode = `0${(statSync(targetLicense).mode & 0o777).toString(8)}`
499
+ // ⑤ 读回验收:装完必须能**用真实加载路径**验签通过(不是"我写完了"就算数)
500
+ const back = loadLicenseFile(targetLicense)
501
+ result.verifiedAfterInstall = back.ok
502
+ if (!back.ok) die(`安装后复验失败(${back.reason}):${targetLicense}`, 2)
503
+ }
504
+ // ⑥ 自助重绑计数(**只在真写且确实不匹配时**消耗一次;dry-run 绝不落盘 ⇒ A3 零副作用) // 放在"幂等跳过"之外:即便许可文件与现役一致,这一次重绑也是**真发生过**的(不能少记一次)。
505
+ if (rebindConsumed) {
506
+ const rec = recordRebind(store, { code: claimed.code })
507
+ result.rebind = {
508
+ consumed: true,
509
+ count: rec.count,
510
+ rebindsLeft: Math.max(0, MAX_REBINDS - rec.count),
511
+ store: rec.path,
512
+ storeWritten: rec.ok === true,
513
+ note: `自助重绑已消耗 1 次(第 ${rec.count}/${MAX_REBINDS} 次);这是**礼貌上限**(计数文件删掉即归零),`
514
+ + '不是可强制执行的限制 —— 要真额度控制只能在服务端签发侧',
515
+ }
516
+ if (rec.ok !== true) result.warnings.push(`重绑计数写不下(${rec.error}):本次已放行,但下次会被再算一次`)
517
+ // ⚠️ 回显**落盘后**的真相:重绑已记录 ⇒ 设备这一侧现在是 `rebound`(放行),
518
+ // 剩余次数也要减 1。若照抄落盘前的结论,宿主会看到"刚重绑成功但仍是 mismatch"的自相矛盾。
519
+ const after = bindingOf({ claimed, current: deviceInfo, store: readRebindStore({ path: storePath }) })
520
+ result.device = { ...result.device, binding: after.binding, reason: after.reason, rebindsLeft: after.rebindsLeft, note: after.note }
521
+ }
522
+ }
523
+
524
+ // ── ⑦ 实例令牌落盘(与许可**同一条离线下发链路**;只有给了 `--instance-token-file` 才走这里)──
525
+ // 与许可同一套纪律:**0600 + tmp→rename(原子)+ 覆盖前备份 + 逐字节相同则不重写(幂等)**。
526
+ // ⛔ 绝不回显令牌值;回显只有 userId / instanceId / 设备码前 12 位 / 到期日。
527
+ if (tokenInstall && !dryRun) {
528
+ const target = tokenInstall.path
529
+ const incoming = JSON.stringify({
530
+ _doc: '实例令牌(机器自带凭证)。只读 `token` 一个键;其余键仅便于人工核对,不参与鉴权。'
531
+ + '契约见 whyshu-svr/docs/ops/instance-token.md',
532
+ v: Number(tokenInstall.parsed.payload.v || 1),
533
+ token: tokenInstall.parsed.token,
534
+ userId: String(tokenInstall.parsed.payload.userId || ''),
535
+ instanceId: String(tokenInstall.parsed.payload.instanceId || ''),
536
+ deviceCode: String(tokenInstall.parsed.payload.deviceCode || ''),
537
+ scope: String(tokenInstall.parsed.payload.scope || ''),
538
+ issuedAt: String(tokenInstall.parsed.payload.issuedAt || ''),
539
+ exp: String(tokenInstall.parsed.payload.exp || ''),
540
+ }, null, 2) + '\n'
541
+ let existing = null
542
+ try { existing = readFileSync(target, 'utf-8') } catch { existing = null }
543
+ // 幂等判据:**只比 token 那一串**(元数据措辞变化不该造成 churn)
544
+ const existingToken = existing && existing.trim().startsWith('{')
545
+ ? String((JSON.parse(existing) || {}).token || '').trim()
546
+ : String(existing || '').trim()
547
+ const tokenUnchanged = existingToken === tokenInstall.parsed.token
548
+ if (tokenUnchanged) {
549
+ tokenInstall.unchanged = true
550
+ } else {
551
+ if (existing !== null) {
552
+ const b = `${target}.bak-${stamp()}`
553
+ copyFileSync(target, b)
554
+ chmodSync(b, 0o600)
555
+ tokenInstall.backup = b
556
+ }
557
+ const tmp = `${target}.tmp`
558
+ try {
559
+ writeFileSync(tmp, incoming, { mode: 0o600 })
560
+ chmodSync(tmp, 0o600)
561
+ renameSync(tmp, target)
562
+ } catch (e) {
563
+ rmSync(tmp, { force: true })
564
+ // 令牌装不上**不该**把已经装好的许可一起判失败:如实回显 + 告警,退出码保持 0
565
+ result.warnings.push(`实例令牌写入失败(许可已装好,机器仍可用账号令牌兜底):${e.message}`)
566
+ tokenInstall = null
567
+ }
568
+ }
569
+ if (tokenInstall) {
570
+ try { tokenInstall.mode = `0${(statSync(target).mode & 0o777).toString(8)}` } catch { /* 权限拿不到不致命 */ }
571
+ // 读回验收:装完必须能**按客户端真实读取路径**解出同一串(不是"我写完了"就算数)
572
+ const back = parseInstanceToken(readFileSync(target, 'utf-8'))
573
+ tokenInstall.verified = back.token === tokenInstall.parsed.token
574
+ if (!tokenInstall.verified) result.warnings.push(`实例令牌安装后复验失败(${back.error || '内容不一致'}):${target}`)
575
+ }
576
+ }
577
+ result.instanceToken = tokenInstall ? {
578
+ installed: !dryRun,
579
+ path: tokenInstall.path,
580
+ source: tokenInstall.src,
581
+ mode: tokenInstall.mode || (dryRun ? null : undefined),
582
+ unchanged: tokenInstall.unchanged === true,
583
+ verified: tokenInstall.verified === true,
584
+ backup: tokenInstall.backup || null,
585
+ userId: String(tokenInstall.parsed.payload.userId || ''),
586
+ instanceId: String(tokenInstall.parsed.payload.instanceId || ''),
587
+ deviceBound: Boolean(tokenInstall.parsed.payload.deviceCode),
588
+ deviceCodePrefix: String(tokenInstall.parsed.payload.deviceCode || '').slice(0, 12),
589
+ scope: String(tokenInstall.parsed.payload.scope || ''),
590
+ exp: String(tokenInstall.parsed.payload.exp || ''),
591
+ header: 'x-instance-token',
592
+ note: '客户机 `scripts/telemetry.py` 会优先用它上报(`--status` 的 credential.kind=instance);账号令牌保留为兜底',
593
+ } : { installed: false, path: null, note: '未提供 --instance-token-file ⇒ 只装了许可(机器仍靠账号令牌上报,30 天到期需重配)' }
594
+
595
+ if (has('--json')) {
596
+ console.log(JSON.stringify(result, null, 2))
597
+ } else {
598
+ console.log(`${dryRun ? '🔎 [dry-run] 会安装' : (unchanged ? '✅ 与现役逐字节一致、未改动' : '✅ 已安装')}离线许可:${srcPath} → ${targetLicense}`)
599
+ console.log(` 账号:${r.userId} | 席位:${r.seats}`)
600
+ console.log(` 能力:${r.capabilities.join(',') || '(未列能力 ⇒ 客户端拒一切能力)'}`)
601
+ console.log(` 业务线:${r.verticals.join(',') || '(空 = 不限)'}`)
602
+ console.log(` 到期:${r.expiresAt} | 宽限 ${r.graceDays} 天(至 ${r.graceUntil})`)
603
+ console.log(` 签名版本:v${result.signatureVersion} | device 进签名:${result.deviceSigned ? '是' : '否'}`)
604
+ console.log(` 设备绑定:${result.device.binding}(${result.device.reason})`
605
+ + `${result.device.present ? ` | 许可设备码 ${String(result.device.code).slice(0, 12)}…` : ''}`
606
+ + ` | 自助重绑还剩 ${result.device.rebindsLeft} 次`)
607
+ if (resign) {
608
+ console.log(` ✍️ 重签:v${resign.from ?? '?'} → v${resign.to}`
609
+ + `${resign.deviceCode ? `(device=${String(resign.deviceCode).slice(0, 12)}…)` : '(未声明 device)'}`
610
+ + ` | 密钥来源 ${resign.keySource}`)
611
+ console.log(` · ${resign.note}`)
612
+ }
613
+ if (rebindConsumed) console.log(` 🔁 ${result.rebind.note}`)
614
+ if (comparison && result.incumbent.present) {
615
+ console.log(` 现役:${result.incumbent.reason}${result.incumbent.expiresAt ? ` | 到期 ${result.incumbent.expiresAt}` : ''}`
616
+ + ` | 能力 ${(result.incumbent.capabilities || []).join(',') || '(空)'}`
617
+ + `${result.comparison.unchanged ? ' | **与本文件逐字节一致**(幂等:未重写、未备份)' : ''}`
618
+ + `${result.comparison.stale ? ' | ⚠️ 本文件更早到期' : ''}`
619
+ + `${result.comparison.narrowed ? ' | ⚠️ 本文件收窄了能力/席位' : ''}`)
620
+ }
621
+ if (renewal && renewal.deviceChanged) console.log(` ↻ 换绑:device ${String(renewal.incumbentDeviceCode || '').slice(0, 12)}… → ${String(renewal.incomingDeviceCode || '').slice(0, 12)}…(不是续期)`)
622
+ if (secretSrc) console.log(` 密钥:已安装到 ${targetSecret}(0600,值不打印)`)
623
+ else console.log(' 密钥:沿用本机既有来源(ARGENT_LICENSE_SECRET 或已装密钥文件)')
624
+ if (result.instanceToken.installed) {
625
+ console.log(`${result.instanceToken.unchanged ? '🔁 实例令牌与现役逐字节一致、未改动' : '🔑 已安装实例令牌(机器自带凭证)'}:`
626
+ + `${result.instanceToken.path}(${result.instanceToken.mode || '0600'})`)
627
+ console.log(` 归属 ${result.instanceToken.userId} | 实例 ${result.instanceToken.instanceId}`
628
+ + ` | 设备 ${result.instanceToken.deviceBound ? `${result.instanceToken.deviceCodePrefix}…(已绑定)` : '(未绑定)'}`
629
+ + ` | 用途 ${result.instanceToken.scope} | 有效至 ${result.instanceToken.exp}`)
630
+ console.log(' ⛔ 令牌值不打印;本机上报从此**不再依赖 30 天账号令牌**,`--status` 的 credential.kind 应显示 instance')
631
+ } else if (instanceTokenSrc) {
632
+ console.log(` ⚠️ 实例令牌:${dryRun ? 'dry-run(未写入)' : '未安装'} ⇒ ${result.instanceToken.note}`)
633
+ } else {
634
+ console.log(' ℹ️ 未提供 --instance-token-file ⇒ 机器仍靠**账号令牌**上报(30 天到期需重配;'
635
+ + '要根治请用 whyshu-svr 的 `scripts/issue-instance-token.mjs` 签发后带上该参数)')
636
+ }
637
+ for (const b of result.backups) console.log(` 备份:${b}`)
638
+ for (const w of result.warnings) console.log(` ⚠️ ${w}`)
639
+ console.log(` 自检:argent license status${dryRun ? '(本次是 dry-run:**没有**写任何文件,连备份/重绑计数都没落)' : ''}`)
640
+ }
641
+ process.exit(0)