@whyshu/argent-engine 0.0.0-stage → 0.6.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 (305) hide show
  1. package/MANIFEST.json +307 -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 +575 -0
  55. package/engine/scripts/billing-mode-daily.sh +12 -0
  56. package/engine/scripts/build-engine-package.mjs +123 -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 +377 -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/config-explain.mjs +278 -0
  81. package/engine/scripts/configure-provider.rb +29 -0
  82. package/engine/scripts/customer-config-migrate.mjs +179 -0
  83. package/engine/scripts/customer-preflight.py +892 -0
  84. package/engine/scripts/customer-readiness.sh +134 -0
  85. package/engine/scripts/daily-report-inbox.py +242 -0
  86. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  87. package/engine/scripts/daily-report.mjs +1310 -0
  88. package/engine/scripts/daily-report.sh +46 -0
  89. package/engine/scripts/data/lip-product-dict.json +306 -0
  90. package/engine/scripts/deliver-customer.mjs +932 -0
  91. package/engine/scripts/delivery-faces.txt +31 -0
  92. package/engine/scripts/desktop-trial.sh +146 -0
  93. package/engine/scripts/dev-window.sh +260 -0
  94. package/engine/scripts/drill-clean-machine.sh +315 -0
  95. package/engine/scripts/dsh-api-client.mjs +215 -0
  96. package/engine/scripts/dsh-backup-scope.sh +83 -0
  97. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  98. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  99. package/engine/scripts/dsh-plane.mjs +153 -0
  100. package/engine/scripts/dsh-portforward.py +223 -0
  101. package/engine/scripts/dsh-preflight.mjs +179 -0
  102. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  103. package/engine/scripts/dsh-release-watch.mjs +458 -0
  104. package/engine/scripts/dsh-remote-check.sh +99 -0
  105. package/engine/scripts/dsh-restart.sh +179 -0
  106. package/engine/scripts/dsh-restore.sh +111 -0
  107. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  108. package/engine/scripts/dsh-snapshot.mjs +116 -0
  109. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  110. package/engine/scripts/dsh-upgrade.mjs +612 -0
  111. package/engine/scripts/dsh-url.sh +86 -0
  112. package/engine/scripts/dsh-web-launch.mjs +155 -0
  113. package/engine/scripts/ensure-store-windows.py +509 -0
  114. package/engine/scripts/entitlements.py +458 -0
  115. package/engine/scripts/env_probe.py +411 -0
  116. package/engine/scripts/feishu-card.mjs +252 -0
  117. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  118. package/engine/scripts/flow-auto-retry.py +454 -0
  119. package/engine/scripts/flow-requirements.json +720 -0
  120. package/engine/scripts/flow-scaffold.py +275 -0
  121. package/engine/scripts/flow-timers-plist.sh +86 -0
  122. package/engine/scripts/flow-timers.sh +182 -0
  123. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  124. package/engine/scripts/flow-trigger.sh +571 -0
  125. package/engine/scripts/flow_billing.py +354 -0
  126. package/engine/scripts/flow_capability_stats.py +314 -0
  127. package/engine/scripts/flow_case.py +194 -0
  128. package/engine/scripts/flow_claim.py +497 -0
  129. package/engine/scripts/flow_config.py +1024 -0
  130. package/engine/scripts/flow_failure.py +588 -0
  131. package/engine/scripts/flow_learn.py +105 -0
  132. package/engine/scripts/flow_ledger.py +235 -0
  133. package/engine/scripts/flow_policy.py +238 -0
  134. package/engine/scripts/flow_preflight.py +339 -0
  135. package/engine/scripts/flow_registry.py +341 -0
  136. package/engine/scripts/flow_retry.py +304 -0
  137. package/engine/scripts/flow_return_to_ops.py +191 -0
  138. package/engine/scripts/flow_source.py +315 -0
  139. package/engine/scripts/flow_sources/__init__.py +53 -0
  140. package/engine/scripts/flow_sources/cases_json.py +106 -0
  141. package/engine/scripts/flow_sources/common.py +428 -0
  142. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  143. package/engine/scripts/flow_stale.py +185 -0
  144. package/engine/scripts/flow_status_reconcile.py +344 -0
  145. package/engine/scripts/flow_triage.py +849 -0
  146. package/engine/scripts/flow_write_verify.py +496 -0
  147. package/engine/scripts/fulfillment.js +586 -0
  148. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  149. package/engine/scripts/gen-preset-rows.mjs +259 -0
  150. package/engine/scripts/init-ml.sh +2275 -0
  151. package/engine/scripts/install-internal-role.sh +190 -0
  152. package/engine/scripts/install-license.mjs +641 -0
  153. package/engine/scripts/install-one-shot.mjs +665 -0
  154. package/engine/scripts/integration_contract.py +220 -0
  155. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  156. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  157. package/engine/scripts/invoice-mapping.example.json +40 -0
  158. package/engine/scripts/invoice-table.sh +194 -0
  159. package/engine/scripts/invoice_carrier_notice.py +279 -0
  160. package/engine/scripts/invoice_fail_closeout.py +297 -0
  161. package/engine/scripts/invoice_lip_runner.py +1508 -0
  162. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  163. package/engine/scripts/invoice_ready_audit.py +191 -0
  164. package/engine/scripts/invoice_runner.py +675 -0
  165. package/engine/scripts/invoice_stageb.py +516 -0
  166. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  167. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  168. package/engine/scripts/lease.py +377 -0
  169. package/engine/scripts/lib/agent-install.mjs +649 -0
  170. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  171. package/engine/scripts/lib/argent_datadir.py +262 -0
  172. package/engine/scripts/lib/argent_filelock.py +507 -0
  173. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  174. package/engine/scripts/lib/cdp_ports.py +197 -0
  175. package/engine/scripts/lib/customer-env.mjs +102 -0
  176. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  177. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  178. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  179. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  180. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  181. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  182. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  183. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  184. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  185. package/engine/scripts/lib/patch-compose.mjs +241 -0
  186. package/engine/scripts/lib/patch-target.mjs +35 -0
  187. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  188. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  189. package/engine/scripts/lib/preset-compat.mjs +127 -0
  190. package/engine/scripts/lib/preset-install.mjs +575 -0
  191. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  192. package/engine/scripts/lib/preset-sync.mjs +59 -0
  193. package/engine/scripts/lib/profile-retire.mjs +97 -0
  194. package/engine/scripts/lib/python-runtime.mjs +190 -0
  195. package/engine/scripts/lib/registry.mjs +72 -0
  196. package/engine/scripts/lib/repo-root.sh +56 -0
  197. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  198. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  199. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  200. package/engine/scripts/lib/settings-panels.mjs +816 -0
  201. package/engine/scripts/lib/stdio_utf8.py +86 -0
  202. package/engine/scripts/lib/wake-auth.mjs +392 -0
  203. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  204. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  205. package/engine/scripts/license-e2e-drill.mjs +426 -0
  206. package/engine/scripts/license-gate.mjs +245 -0
  207. package/engine/scripts/lip_backlog.py +171 -0
  208. package/engine/scripts/lip_table.py +185 -0
  209. package/engine/scripts/local-usage.mjs +214 -0
  210. package/engine/scripts/migrate-providers.mjs +127 -0
  211. package/engine/scripts/migrate-skills.sh +65 -0
  212. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  213. package/engine/scripts/ml-store-ids.json +26 -0
  214. package/engine/scripts/ml_artifact_attribution.py +333 -0
  215. package/engine/scripts/ml_capacity_probe.py +1158 -0
  216. package/engine/scripts/ml_hub_status.py +1556 -0
  217. package/engine/scripts/ml_schema_check.py +115 -0
  218. package/engine/scripts/ml_store_ids.py +139 -0
  219. package/engine/scripts/npm-publish-argent.mjs +499 -0
  220. package/engine/scripts/npm-publish-argent.sh +20 -0
  221. package/engine/scripts/orchestrator.py +13811 -0
  222. package/engine/scripts/pack-plugins.mjs +140 -0
  223. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  224. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  225. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  226. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  227. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  228. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  229. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  230. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  231. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  232. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  233. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  234. package/engine/scripts/plan-role-rename.mjs +125 -0
  235. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  236. package/engine/scripts/plugin-composition.json +15 -0
  237. package/engine/scripts/poc-mcp-host.mjs +455 -0
  238. package/engine/scripts/poll-fulfillment.sh +293 -0
  239. package/engine/scripts/poll-invoice.sh +74 -0
  240. package/engine/scripts/profile-doctor.mjs +686 -0
  241. package/engine/scripts/provider-select.sh +70 -0
  242. package/engine/scripts/provider-switch.sh +146 -0
  243. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  244. package/engine/scripts/python-runtime.sh +78 -0
  245. package/engine/scripts/query_runner.py +310 -0
  246. package/engine/scripts/release-argent.sh +574 -0
  247. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  248. package/engine/scripts/run-tests.sh +256 -0
  249. package/engine/scripts/self-sign-license.mjs +179 -0
  250. package/engine/scripts/session-health-scan.mjs +63 -0
  251. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  252. package/engine/scripts/setup.sh +22 -0
  253. package/engine/scripts/skill-install.mjs +629 -0
  254. package/engine/scripts/sku_translate.py +148 -0
  255. package/engine/scripts/sync-plugins.sh +90 -0
  256. package/engine/scripts/sync-presets.sh +27 -0
  257. package/engine/scripts/sync-scripts.sh +445 -0
  258. package/engine/scripts/telemetry.py +873 -0
  259. package/engine/scripts/ui-copy-count.mjs +220 -0
  260. package/engine/scripts/usage-anchor.sh +136 -0
  261. package/engine/scripts/usage_report.py +531 -0
  262. package/engine/scripts/validate-preset-schema.mjs +208 -0
  263. package/engine/scripts/vertical-init.mjs +323 -0
  264. package/engine/scripts/vertical-lint.mjs +482 -0
  265. package/engine/scripts/wenyao-browser.sh +73 -0
  266. package/engine/scripts/wenyao-fixture-export.py +116 -0
  267. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  268. package/engine/scripts/wenyao-login.sh +34 -0
  269. package/engine/scripts/wenyao-template-map.json +292 -0
  270. package/engine/scripts/wenyao_attach.py +131 -0
  271. package/engine/scripts/wenyao_download_template.py +144 -0
  272. package/engine/scripts/wenyao_fill_template.py +568 -0
  273. package/engine/scripts/wenyao_login.py +145 -0
  274. package/engine/scripts/wenyao_portal.py +633 -0
  275. package/engine/scripts/wenyao_recon.py +230 -0
  276. package/engine/scripts/wenyao_run.py +503 -0
  277. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  278. package/engine/scripts/wenyao_skill_contract.py +156 -0
  279. package/engine/scripts/wenyao_template_fetch.py +175 -0
  280. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  281. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  282. package/engine/scripts/ziniao-keepalive.sh +216 -0
  283. package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
  284. package/engine/scripts/ziniao-windows.sh +153 -0
  285. package/engine/version.mjs +1 -0
  286. package/engine/verticals/README.md +103 -0
  287. package/engine/verticals/_engine-words.baseline.txt +237 -0
  288. package/engine/verticals/_engine-words.json +19 -0
  289. package/engine/verticals/mercadolibre/README.md +43 -0
  290. package/engine/verticals/mercadolibre/config.json +709 -0
  291. package/engine/verticals/mercadolibre/flows.json +706 -0
  292. package/engine/verticals/mercadolibre/manifest.json +538 -0
  293. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  294. package/engine/verticals/mercadolibre/preflight.py +143 -0
  295. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  296. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  297. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  298. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  299. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  300. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  301. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  302. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  303. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  304. package/package.json +21 -4
  305. package/README.md +0 -3
@@ -0,0 +1,932 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deliver-customer —— **第三件事:往客户机 / 远端克隆投递**(2026-10-08)
4
+ *
5
+ * ══════════════════════════════════════════════════════════════════════════
6
+ * 一、为什么有它:三个投递口各管一件事,⛔ 不许互相顶替
7
+ * ══════════════════════════════════════════════════════════════════════════
8
+ * | # | 投递口 | 目标 | 面 | 本脚本与它的关系 |
9
+ * |---|---|---|---|---|
10
+ * | 一 | `scripts/sync-scripts.sh` | **本机**安装克隆 `~/.argent-dsh` | 读**同一份**面清单 | 面清单唯一真源 `scripts/delivery-faces.txt`(⛔ 不另写第二份) |
11
+ * | 二 | `scripts/release-argent.sh` | **下载服务器** `whyshu.com/dl` | `install-*.mjs/ps1/sh`(`--full` 再带 skills/plugins) | **无关**:目标机与面都不同,本脚本⛔ 不读它、不改它 |
12
+ * | 三 | **本脚本** | **客户机 / 远端克隆**(rsync over ssh) | 同上(默认六面) | 面清单共用;语义各自独立 |
13
+ * 客户机**没有仓库** ⇒ `sync-scripts.sh` 在客户机上必然 exit 2(它是"本机克隆"专用);本脚本才是
14
+ * "把仓库这一批改动送到客户机"的那一件。
15
+ *
16
+ * ══════════════════════════════════════════════════════════════════════════
17
+ * 二、它把 2026-10-08 当天靠人工纪律做的七件事,变成一次可复跑的投递
18
+ * ══════════════════════════════════════════════════════════════════════════
19
+ * ① **面缺失**:面清单此前散在脚本里 ⇒ `deploy/` 漂移一整批(`deploy/customer/roles.spec.json`
20
+ * 会被 `check-role-mapping.mjs` 第 ⑥ 项按 `REPO/deploy/…` **真读**)。现在面清单是**唯一真源**
21
+ * 文件,本脚本与 `sync-scripts.sh` 读**同一份**;面为空 ⇒ exit 3(绝不"没面也算成功")。
22
+ * ② **带垃圾**:naive rsync 会把开发树 `__pycache__/*.pyc`(本机 python 3.14 的字节码)推给跑 3.11 的
23
+ * 生产机。排除表 = `__pycache__` / `*.pyc` / `.DS_Store` / `*.bak-*`(`--exclude` 可追加),
24
+ * 并且**同时**作用于"文件清单"与 rsync 的 `--exclude`(双保险)。
25
+ * ③ **权限漂移**:`rsync -a` 会把开发树 umask(实测 600/711)带进生产 ⇒ 投递后按 **git 索引模式**
26
+ * (`100644→644`、`100755→755`)归一,并核"模式不符 = 0"(不符 ⇒ exit 1 并点名)。
27
+ * ④ **硬链接陷阱**:rsync/编辑器换 inode ⇒ `~/.dsh/profiles/<p>/node_modules/argent-*` 仍是旧字节 ⇒
28
+ * "重启即生效"是假的且**无报错**。投递面含 `plugins/` 或 `presets/` 时**自动**核 profile 副本
29
+ * (同 inode / nlink≥2 / md5)并自动 `ln -f` 修复(`--no-profile` 可关);**新增文件同样落 profile**
30
+ * (漏了就是插件 import 崩、服务起不来)。
31
+ * ⑤ **静默失败**:喂 stdin 时若给 `ssh` 加 `-n`,stdin 被顶成 `/dev/null` ⇒ 落成**空文件**。
32
+ * 本脚本**全部**远端动作都走 `ssh <target> bash -s -- …` + stdin 喂脚本,且**绝不加 `-n`**;
33
+ * 并且**每次投递都做双端 sha256 逐文件核对**(含新增/缺失),任一不符 ⇒ exit 1 并点名。
34
+ * ⑥ **回滚耦合**:例:日志移出 `/tmp` 后,只回滚读 `/tmp` 的旧唤醒脚本 ⇒ 四条流程全哑。
35
+ * `--coupled <组>` 声明耦合回滚组,回滚输出里**明确写出**"这几件必须一起回滚"。
36
+ * ⑦ **备份先行**:投递前在客户机打面 tar(`600` + 时间戳 tag + sha256 + `MANIFEST.txt`),
37
+ * 落 `<backup-dir>/<tag>/`,并**生成可执行的回滚脚本**(同目录 `rollback.sh`,700)——
38
+ * 它会把面解包回投递前、删掉本次新增文件、恢复模式、复核 sha、重链 profile。
39
+ *
40
+ * ══════════════════════════════════════════════════════════════════════════
41
+ * 三、用法与退出码
42
+ * ══════════════════════════════════════════════════════════════════════════
43
+ * ```
44
+ * node scripts/deliver-customer.mjs --target <user@host> [选项] # 默认 dry-run(零写入)
45
+ * node scripts/deliver-customer.mjs --target <user@host> --apply # 真投递
46
+ * ```
47
+ * | 选项 | 说明 |
48
+ * |---|---|
49
+ * | `--target <user@host>` | 投递目标(必填;也可用环境变量 `ARGENT_CUSTOMER_TARGET`)。⛔ 脚本里**不写死**任何客户机名 |
50
+ * | `--face <名>` | 投递面(可重复);缺省读唯一真源 `scripts/delivery-faces.txt` |
51
+ * | `--exclude <glob>` | 追加排除(可重复);缺省表:`__pycache__` `*.pyc` `.DS_Store` `*.bak-*` |
52
+ * | `--source <dir>` | 源树(缺省 = 本脚本所在的仓库) |
53
+ * | `--remote-dir <path>` | 客户机落点(缺省 `~/.argent-dsh`;`~/` 会按客户机 `$HOME` 展开) |
54
+ * | `--backup-dir <path>` | 备份根(缺省 `~/.dsh/argent/backup`) |
55
+ * | `--profile <名>` | profile 名(缺省 `web`;面含 plugins/presets 时用它核副本) |
56
+ * | `--profile-root <path>` | profile 根(缺省 `~/.dsh/profiles`) |
57
+ * | `--presets-root <path>` | preset 副本根(缺省 `<profile-root>/../.agent-presets`) |
58
+ * | `--no-profile` | 关掉 profile 副本核对/修复(⛔ 只在明确知道不需要时用) |
59
+ * | `--coupled <说明>` | 声明一个耦合回滚组(可重复)——回滚输出里会点名"必须一起回滚" |
60
+ * | `--self-check` | 投递后跑**仓库既有**的连续性判据(客户机侧只读;异常 ⇒ exit 1 并提示回滚) |
61
+ * | `--apply` | 真执行(缺省 dry-run:只做**只读**远端探测 + 打印计划,**零写入**) |
62
+ * | `-h, --help` | 打印本段 |
63
+ *
64
+ * 退出码:`0` 成功 | `1` 投递或核对失败(sha/模式/profile/自检不符,**逐条点名**)|
65
+ * `2` 用法错(参数/目标缺失)| `3` 被前置条件挡住(目标不可达、面清单为空、源树非 git 仓…)
66
+ *
67
+ * ══════════════════════════════════════════════════════════════════════════
68
+ * 四、纪律(都是踩过的坑,改动本文件前先读)
69
+ * ══════════════════════════════════════════════════════════════════════════
70
+ * · **⛔ 不用 `scp`**:一律 rsync over ssh(`-az --no-owner --no-group --files-from=…`)。
71
+ * · **⛔ 管道喂 stdin 时绝不加 `ssh -n`**:`-n` 会把 stdin 顶成 `/dev/null` ⇒ 远端脚本变成空脚本、
72
+ * 文件落成空文件,而且**看起来成功**(实测:`role-gateway-exemptions.json` 首次就是空文件,
73
+ * 靠双端 sha 才当场拦下)。本文件只在 `sshRun()` 一处拼 ssh 参数,那里有显式注释。
74
+ * · **⛔ 不用 `--delete`**:远端可能有带外资产;本次投递只覆盖"清单里的文件"。
75
+ * · **文件与模式都取自 git 索引**(`git ls-files -z -s`)⇒ 清单里不可能出现未跟踪的构建垃圾
76
+ * (`__pycache__`/`.DS_Store`),模式也不需要"猜"。
77
+ * · **落盘是 rsync 的默认语义**(临时文件 + rename),本脚本不额外做 `tmp + mv`。
78
+ * · **本脚本不重启任何东西**:`plugins/`(host)与 plist 改动必须由**用户手动**重启才生效;
79
+ * 投递器/驱动器改动不必重启(`flow-trigger*` 每轮从磁盘重读)⇒ 回滚输出里写明这条边界。
80
+ * · **只读探测**:dry-run 只跑 `printf`/`stat`/`shasum`(不改任何字节、不建目录、不写备份)。
81
+ */
82
+
83
+ import { createHash } from 'node:crypto'
84
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
85
+ import { tmpdir } from 'node:os'
86
+ import { dirname, join, resolve } from 'node:path'
87
+ import { spawnSync } from 'node:child_process'
88
+ import { fileURLToPath } from 'node:url'
89
+ import { isDirectRun } from './lib/is-direct-run.mjs'
90
+
91
+ const HERE = dirname(fileURLToPath(import.meta.url))
92
+ const SELF_REPO = resolve(HERE, '..')
93
+
94
+ /** 退出码(与文件头一致;⛔ 语义固定,不许"复用"成别的东西)。 */
95
+ export const EXIT = { OK: 0, FAIL: 1, USAGE: 2, BLOCKED: 3 }
96
+
97
+ /** 排除表(2026-10-08 的"带垃圾"教训):目录名 / basename glob。`--exclude` 追加。 */
98
+ export const DEFAULT_EXCLUDES = ['__pycache__', '*.pyc', '.DS_Store', '*.bak-*']
99
+
100
+ /** 面清单唯一真源(与 `scripts/sync-scripts.sh` 共用同一份)。 */
101
+ export const FACES_REL = 'scripts/delivery-faces.txt'
102
+
103
+ /** 需要核 profile 副本的面(面清单里出现它们 ⇒ 自动核;见文件头 ④)。 */
104
+ export const PROFILE_FACES = ['plugins', 'presets']
105
+
106
+ /** 远端文件清单哨兵(heredoc 结束标记;与路径校验配合,避免清单被"内容里恰好有一行 EOF"截断)。 */
107
+ const SENTINEL = '__ARGENT_DELIVER_LIST__'
108
+
109
+ class UsageError extends Error {}
110
+ class Blocked extends Error {}
111
+ class DeliveryFailure extends Error {}
112
+
113
+ // ══════════════════════════════════════════════════════════════════════════
114
+ // 输出
115
+ // ══════════════════════════════════════════════════════════════════════════
116
+ const say = (s = '') => console.log(s)
117
+ const ok = (m) => say(` ✅ ${m}`)
118
+ const warn = (m) => say(` ⚠️ ${m}`)
119
+ const bad = (m) => say(` ❌ ${m}`)
120
+ const info = (m) => say(` · ${m}`)
121
+
122
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
123
+ const sha256File = (p) => sha256(readFileSync(p))
124
+
125
+ /**
126
+ * 工作树文件的 **git blob sha1**(`sha1("blob <len>\0" + content)`)。
127
+ * 用途:与 `git ls-files -s` 的 blob sha 比对 ⇒ 判"工作树是否与索引一致"。
128
+ * ⚠️ 不能拿 sha256 去比索引里的 sha1(算法不同 ⇒ 恒"不一致",那是假警告)。
129
+ */
130
+ const gitBlobSha1 = (p) => {
131
+ const buf = readFileSync(p)
132
+ return createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex')
133
+ }
134
+
135
+ /** bash 单引号安全引用(路径里可能有空格/中文/`$`)。 */
136
+ const shq = (s) => `'${String(s).split("'").join(`'\\''`)}'`
137
+
138
+ // ══════════════════════════════════════════════════════════════════════════
139
+ // 一、参数与面清单(纯函数,测试可直接 import)
140
+ // ══════════════════════════════════════════════════════════════════════════
141
+
142
+ /**
143
+ * 读面清单(**唯一真源**)。
144
+ * 格式与 `sync-scripts.sh#read_faces` **逐字同语义**:一行一个面,`#` 起注释,空行忽略。
145
+ * @returns {{faces: string[], error: string|null}}
146
+ */
147
+ export function readFaces(repoRoot) {
148
+ const p = join(repoRoot, FACES_REL)
149
+ if (!existsSync(p)) return { faces: [], error: `面清单不存在:${FACES_REL}(唯一真源;与 scripts/sync-scripts.sh 共用同一份)` }
150
+ const faces = []
151
+ for (const raw of readFileSync(p, 'utf8').split('\n')) {
152
+ const line = raw.replace(/#.*$/, '').trim()
153
+ if (!line) continue
154
+ if (!faces.includes(line)) faces.push(line)
155
+ }
156
+ if (!faces.length) return { faces: [], error: `面清单为空:${FACES_REL}(空清单必然投递不全 ⇒ 拒绝执行)` }
157
+ return { faces, error: null }
158
+ }
159
+
160
+ /** 一个相对路径是否命中排除表:路径的任一段等于目录名,或 basename 命中 glob。 */
161
+ export function matchesExclude(rel, patterns) {
162
+ const segs = String(rel).split('/')
163
+ const base = segs[segs.length - 1]
164
+ const globRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\?]/g, '\\$&').split('*').join('[^/]*')}$`)
165
+ for (const pat of patterns) {
166
+ if (!pat) continue
167
+ if (!pat.includes('*') && !pat.includes('?')) {
168
+ if (segs.includes(pat)) return pat // 目录名(`__pycache__`)
169
+ if (base === pat) return pat // 文件名(`.DS_Store`)
170
+ continue
171
+ }
172
+ if (globRe(pat).test(base)) return pat // basename glob(`*.pyc` / `*.bak-*`)
173
+ }
174
+ return null
175
+ }
176
+
177
+ /** `git ls-files -s` 的 mode → 目标权限位。 */
178
+ export function modeOf(gitMode) {
179
+ if (gitMode === '100644') return '644'
180
+ if (gitMode === '100755') return '755'
181
+ return null // 120000(symlink)/160000(submodule) 等:不归一,只提示
182
+ }
183
+
184
+ function parseArgs(argv) {
185
+ const o = {
186
+ target: process.env.ARGENT_CUSTOMER_TARGET || '',
187
+ source: SELF_REPO, remoteDir: '~/.argent-dsh', backupDir: '~/.dsh/argent/backup',
188
+ profile: 'web', profileRoot: '~/.dsh/profiles', presetsRoot: '', profileCheck: true,
189
+ apply: false, selfCheck: false, faces: [], excludes: [], coupled: [],
190
+ }
191
+ for (let i = 0; i < argv.length; i++) {
192
+ const a = argv[i]
193
+ const val = () => { const v = argv[++i]; if (v === undefined || v.startsWith('--')) throw new UsageError(`选项缺取值:${a}`); return v }
194
+ switch (a) {
195
+ case '--target': o.target = val(); break
196
+ case '--face': o.faces.push(val()); break
197
+ case '--exclude': o.excludes.push(val()); break
198
+ case '--source': o.source = resolve(val()); break
199
+ case '--remote-dir': o.remoteDir = val(); break
200
+ case '--backup-dir': o.backupDir = val(); break
201
+ case '--profile': o.profile = val(); break
202
+ case '--profile-root': o.profileRoot = val(); break
203
+ case '--presets-root': o.presetsRoot = val(); break
204
+ case '--coupled': o.coupled.push(val()); break
205
+ case '--no-profile': o.profileCheck = false; break
206
+ case '--apply': o.apply = true; break
207
+ case '--self-check': o.selfCheck = true; break
208
+ default: throw new UsageError(`未知参数:${a}(用 --help 看用法)`)
209
+ }
210
+ }
211
+ if (!o.target) throw new UsageError('缺 --target <user@host>(或设 ARGENT_CUSTOMER_TARGET)——本脚本不写死客户机名')
212
+ return o
213
+ }
214
+
215
+ // ══════════════════════════════════════════════════════════════════════════
216
+ // 二、投递集(文件 + 模式 = **git 索引**)
217
+ // ══════════════════════════════════════════════════════════════════════════
218
+
219
+ function git(repo, args) {
220
+ const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 })
221
+ return { code: r.status, out: (r.stdout || Buffer.alloc(0)).toString('utf8'), err: (r.stderr || Buffer.alloc(0)).toString('utf8') }
222
+ }
223
+
224
+ /**
225
+ * 投递集 = `git ls-files -s -- <faces>` ∩ ¬排除表。
226
+ * 为什么用 git 索引:① 它是"这批文件该是什么模式"的**唯一真源**(权限归一的判据);
227
+ * ② 未跟踪的构建垃圾(`__pycache__`/`.DS_Store`)**天然不在**清单里 —— 但仍显式过一遍排除表,
228
+ * 因为仓库里**真有**被跟踪的 `.bak-*` 备份件(某个发票运行器的历史备份,`git ls-files | grep '\.bak-'` 可查)那类东西。
229
+ * @returns {{files: {rel:string,mode:string,sha:string}[], excluded:{rel:string,pat:string}[],
230
+ * untracked:string[], odd Modes:{rel:string,mode:string}[], error:string|null}}
231
+ */
232
+ export function collectDeliverySet(repo, faces, excludes) {
233
+ if (!existsSync(join(repo, '.git'))) {
234
+ return { error: `源树不是 git 仓库(缺 .git):${repo} —— 投递集与权限都取自 git 索引,没有它无法归一权限` }
235
+ }
236
+ const ls = git(repo, ['ls-files', '-z', '-s', '--', ...faces])
237
+ if (ls.code !== 0) return { error: `git ls-files 失败(exit ${ls.code}):${ls.err.trim().slice(0, 300)}` }
238
+ const files = [], excluded = [], oddModes = []
239
+ for (const rec of ls.out.split('\0')) {
240
+ if (!rec) continue
241
+ const m = /^(\d{6}) ([0-9a-f]+) (\d)\t([\s\S]*)$/.exec(rec)
242
+ if (!m) return { error: `git ls-files 输出无法解析(协议变化?):${rec.slice(0, 120)}` }
243
+ const [, gitMode, blob, , rel] = m
244
+ const pat = matchesExclude(rel, excludes)
245
+ if (pat) { excluded.push({ rel, pat }); continue }
246
+ const mode = modeOf(gitMode)
247
+ if (!mode) { oddModes.push({ rel, mode: gitMode }); continue } // symlink/submodule:不归一、不核对
248
+ files.push({ rel, mode, sha: blob })
249
+ }
250
+ const un = git(repo, ['ls-files', '-z', '--others', '--exclude-standard', '--', ...faces])
251
+ const untracked = un.code === 0 ? un.out.split('\0').filter(Boolean) : []
252
+ return { files, excluded, untracked, oddModes, error: null }
253
+ }
254
+
255
+ function assertSafePaths(paths) {
256
+ const hit = paths.filter((p) => /[\n\r\t]/.test(p) || p === SENTINEL)
257
+ if (hit.length) throw new Blocked(`路径含换行/制表符(或与哨兵同名),投递协议无法安全表达:${hit[0]}`)
258
+ }
259
+
260
+ // ══════════════════════════════════════════════════════════════════════════
261
+ // 三、远端执行(**唯一**拼 ssh 的地方)
262
+ // ══════════════════════════════════════════════════════════════════════════
263
+
264
+ /**
265
+ * 远端执行一段 bash(脚本走 **stdin**,参数走 argv)。
266
+ *
267
+ * ⛔ **绝不给 ssh 加 `-n`**:`-n` 把 stdin 重定向到 `/dev/null`,于是这里喂进去的脚本变成**空脚本**,
268
+ * 远端"成功退出"却什么都没做(或把文件落成空文件)—— 2026-10-08 实测踩到,靠双端 sha 才拦下。
269
+ * 本函数是全脚本**唯一**拼 ssh argv 的地方,纪律集中在这里守。
270
+ */
271
+ export function sshRun(target, script, args = [], { timeoutMs = 180000 } = {}) {
272
+ // 排障口子:`ARGENT_DELIVER_DUMP=1` 把**远端脚本原文**打到 stderr。
273
+ // 为什么值得留:远端脚本走 stdin(本地不落盘)⇒ 出问题时要能看见"客户机到底收到了什么"
274
+ // (空脚本 / 被截断 / 变量没展开 —— 这三类都只能靠看原文定位)。
275
+ if (process.env.ARGENT_DELIVER_DUMP) process.stderr.write(`\n──── 远端脚本(target=${target})────\n${script}\n──── 脚本结束 ────\n`)
276
+ const argv = [target, 'bash', '-s', '--', ...args] // ⛔ 这里不许出现 '-n'
277
+ const r = spawnSync('ssh', argv, {
278
+ input: script, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: timeoutMs,
279
+ })
280
+ if (r.error) return { code: 255, out: '', err: `ssh 执行失败:${r.error.message}`, timeout: r.error.code === 'ETIMEDOUT' }
281
+ return { code: r.status === null ? 255 : r.status, out: r.stdout || '', err: r.stderr || '' }
282
+ }
283
+
284
+ /** 远端脚本公共前导:平台自适应的 stat/md5 + sha(客户机是 macOS,Linux 走 fallback)。 */
285
+ const REMOTE_PREAMBLE = `set -uo pipefail
286
+ export LC_ALL=C
287
+ case "$(uname -s)" in
288
+ Darwin)
289
+ st_mode() { stat -f '%Lp' "$1"; }; st_ino() { stat -f '%i' "$1"; }
290
+ st_nlink() { stat -f '%l' "$1"; }; md5_of() { md5 -q "$1"; } ;;
291
+ *)
292
+ st_mode() { stat -c '%a' "$1"; }; st_ino() { stat -c '%i' "$1"; }
293
+ st_nlink() { stat -c '%h' "$1"; }; md5_of() { md5sum "$1" | cut -d' ' -f1; } ;;
294
+ esac
295
+ sha_of() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- "$1" | cut -d' ' -f1; else sha256sum -- "$1" | cut -d' ' -f1; fi }
296
+ `
297
+
298
+ /** 远端只读探测:HOME / 平台 / 落点 / 台账(可选)+ 每个投递文件的 sha 与模式。 */
299
+ function probeScript(root, files, ledgerProbe) {
300
+ return `${REMOTE_PREAMBLE}ROOT=${shq(root)}
301
+ printf 'REMOTE_HOME\\t%s\\n' "$HOME"
302
+ printf 'REMOTE_UNAME\\t%s\\n' "$(uname -s)"
303
+ printf 'REMOTE_ROOT\\t%s\\n' "$([ -d "$ROOT" ] && printf 1 || printf 0)"
304
+ ${ledgerProbe}
305
+ while IFS= read -r rel; do
306
+ [ -n "$rel" ] || continue
307
+ p="$ROOT/$rel"
308
+ if [ -f "$p" ]; then printf 'PRE\\t%s\\t%s\\t%s\\n' "$rel" "$(sha_of "$p")" "$(st_mode "$p")"
309
+ else printf 'PRE\\t%s\\t-\\t-\\n' "$rel"; fi
310
+ done <<'${SENTINEL}'
311
+ ${files.join('\n')}
312
+ ${SENTINEL}
313
+ `
314
+ }
315
+
316
+ /** 远端台账探测(`--self-check` 用;**只读**,走仓库自己的判据)。 */
317
+ const LEDGER_PROBE = `led=""
318
+ if command -v node >/dev/null 2>&1 && [ -f "$ROOT/core/lib/argent-datadir.mjs" ]; then
319
+ led="$(ARGENT_ROOT="$ROOT" node -e "import(process.env.ARGENT_ROOT + '/core/lib/argent-datadir.mjs').then(m => process.stdout.write(String(m.resolveDataDir().dir))).catch(() => {})" 2>/dev/null)"
320
+ fi
321
+ if [ -n "$led" ] && [ -f "$led/flow-ledger.jsonl" ]; then
322
+ printf 'LEDGER_SHA\\t%s\\n' "$(sha_of "$led/flow-ledger.jsonl")"
323
+ else
324
+ printf 'LEDGER_SHA\\t-\\n'
325
+ fi
326
+ PY=""
327
+ for cand in "$HOME/.dsh/argent/venv/bin/python3" "$(command -v python3 || true)"; do
328
+ [ -n "$cand" ] && [ -x "$cand" ] && { PY="$cand"; break; }
329
+ done
330
+ if [ -n "$PY" ] && [ -f "$ROOT/scripts/flow_ledger.py" ] && [ -n "$led" ]; then
331
+ printf 'LEDGER_PLANNED\\t%s\\n' "$("$PY" "$ROOT/scripts/flow_ledger.py" --ledger "$led/flow-ledger.jsonl" --status --json 2>/dev/null | "$PY" -c 'import json,sys
332
+ try:
333
+ d = json.load(sys.stdin)
334
+ print(len([1 for e in (d.get("last") or {}).values() if e.get("event") == "planned"]))
335
+ except Exception:
336
+ print("-")' 2>/dev/null)"
337
+ else
338
+ printf 'LEDGER_PLANNED\\t-\\n'
339
+ fi
340
+ `
341
+
342
+ function parseTsv(out) {
343
+ const rows = []
344
+ for (const line of String(out).split('\n')) {
345
+ if (!line) continue
346
+ const f = line.split('\t')
347
+ rows.push(f)
348
+ }
349
+ return rows
350
+ }
351
+
352
+ // ══════════════════════════════════════════════════════════════════════════
353
+ // 四、主流程
354
+ // ══════════════════════════════════════════════════════════════════════════
355
+
356
+ function printHelp() {
357
+ const src = readFileSync(fileURLToPath(import.meta.url), 'utf8')
358
+ // 取文件头那段块注释(**从 `/**` 起**:别把 shebang 也打出来)
359
+ const header = src.slice(src.indexOf('/**')).split('*/')[0].replace(/^\/\*\*/, '')
360
+ .split('\n').map((l) => l.replace(/^ \* ?/, '')).join('\n')
361
+ say(header.trim())
362
+ }
363
+
364
+ function main() {
365
+ const argv = process.argv.slice(2)
366
+ if (argv.includes('-h') || argv.includes('--help')) { printHelp(); return EXIT.OK }
367
+ const opt = parseArgs(argv)
368
+ const excludes = [...DEFAULT_EXCLUDES, ...opt.excludes]
369
+
370
+ say('▸ 客户机投递(第三件事:往客户机 / 远端克隆投递)')
371
+ say(` 模式:${opt.apply ? '**真执行**(--apply)' : 'dry-run(默认;真执行需 --apply)'}`)
372
+ say(` 目标:${opt.target} 落点:${opt.remoteDir}`)
373
+ say(` 源树:${opt.source}`)
374
+
375
+ // ── ① 面清单(唯一真源)────────────────────────────────────────────────
376
+ let faces = opt.faces
377
+ if (!faces.length) {
378
+ const r = readFaces(opt.source)
379
+ if (r.error) throw new Blocked(r.error)
380
+ faces = r.faces
381
+ say(` 面清单:${faces.join(' ')}(来自 ${FACES_REL} —— 与 sync-scripts.sh **同一份**真源)`)
382
+ } else {
383
+ say(` 面清单:${faces.join(' ')}(--face 覆盖;真源仍是 ${FACES_REL})`)
384
+ }
385
+ say(` 排除表:${excludes.join(' ')}${opt.excludes.length ? `(含 --exclude 追加 ${opt.excludes.length} 条)` : ''}`)
386
+ const missingFace = faces.filter((f) => !existsSync(join(opt.source, f)))
387
+ if (missingFace.length) throw new Blocked(`面清单里的目录在源树里不存在:${missingFace.join(' ')}(源树 ${opt.source})`)
388
+
389
+ // ── ② 投递集(git 索引 = 文件 + 模式)──────────────────────────────────
390
+ const set = collectDeliverySet(opt.source, faces, excludes)
391
+ if (set.error) throw new Blocked(set.error)
392
+ if (!set.files.length) throw new Blocked(`投递集为空(面:${faces.join(' ')})—— 空集投递必然"看起来成功",拒绝执行`)
393
+ assertSafePaths(set.files.map((f) => f.rel))
394
+ const localSha = new Map(set.files.map((f) => [f.rel, sha256File(join(opt.source, f.rel))]))
395
+ const mismatchBlob = set.files.filter((f) => gitBlobSha1(join(opt.source, f.rel)) !== f.sha)
396
+ info(`投递集:${set.files.length} 个文件(git 索引模式)|排除命中 ${set.excluded.length} 个|未跟踪(不投)${set.untracked.length} 个`)
397
+ if (set.excluded.length) {
398
+ const sample = set.excluded.slice(0, 3).map((e) => `${e.rel}(命中 ${e.pat})`).join('、')
399
+ info(`排除命中(**不投**):${sample}${set.excluded.length > 3 ? ` …另有 ${set.excluded.length - 3} 个` : ''}`)
400
+ }
401
+ if (set.untracked.length) {
402
+ // naive `rsync -a` 会把这些一起推过去 —— 正是"带垃圾"的形态;这里只报告
403
+ const sample = set.untracked.slice(0, 3).join('、')
404
+ warn(`源树里有 ${set.untracked.length} 个**未跟踪**文件落在投递面内(不投):${sample}${set.untracked.length > 3 ? ' …' : ''}`)
405
+ }
406
+ if (set.oddModes.length) warn(`非 644/755 的索引模式 ${set.oddModes.length} 个(不归一、不核对):${set.oddModes.slice(0, 3).map((e) => `${e.rel}=${e.mode}`).join('、')}`)
407
+ if (mismatchBlob.length) warn(`本地文件与 git 索引 blob 不一致(工作树有未提交改动)${mismatchBlob.length} 个:${mismatchBlob.slice(0, 3).map((e) => e.rel).join('、')}`)
408
+ info(`双端核对口径:逐文件 sha256(${set.files.length} 个)+ 模式不符 = 0`)
409
+
410
+ // ── ③ 前置:目标可达 + 远端只读探测 ────────────────────────────────────
411
+ const home = remoteHome(opt.target)
412
+ const root = expandTilde(opt.remoteDir, home)
413
+ const backupRoot = expandTilde(opt.backupDir, home)
414
+ const pre = probeTarget(opt, root, set.files)
415
+ const preMap = pre.pre
416
+
417
+ // ── ④ 计划分类(新增/更新/一致)────────────────────────────────────────
418
+ const plan = { added: [], changed: [], same: [], missingMode: [] }
419
+ for (const f of set.files) {
420
+ const p = preMap.get(f.rel)
421
+ if (!p || p.sha === '-') plan.added.push(f.rel)
422
+ else if (p.sha !== localSha.get(f.rel)) plan.changed.push(f.rel)
423
+ else plan.same.push(f.rel)
424
+ }
425
+ info(`计划:新增 ${plan.added.length} | 更新 ${plan.changed.length} | 内容一致 ${plan.same.length}`)
426
+ for (const rel of plan.added.slice(0, 5)) info(` + ${rel}`)
427
+ for (const rel of plan.changed.slice(0, 5)) info(` ~ ${rel}`)
428
+
429
+ const needProfile = opt.profileCheck && faces.some((f) => PROFILE_FACES.includes(f))
430
+ const profilePlan = needProfile ? profileRows(opt, root, home, faces, set.files) : []
431
+ // profile 副本**只读**预检(apply 与 dry-run 都跑):dry-run 里它就是"这条会不会被修"的答案
432
+ // ⚠️ 预检只在 dry-run 跑:apply 模式下"源文件还没投递"是**预期的中间态**(rsync 之后才有真相),
433
+ // 而 apply 的 profile 修复步骤本身就会报告 漂移/修复/新增落地 的真实数字。
434
+ const profileProbe = (needProfile && !opt.apply) ? probeProfile(opt, root, profilePlan) : null
435
+ if (profileProbe) {
436
+ if (profileProbe.drifted.length) warn(`profile 副本漂移(内容/硬链接不符)${profileProbe.drifted.length} 个:${profileProbe.drifted.slice(0, 3).join('、')}`)
437
+ if (profileProbe.missing.length) warn(`profile 缺文件(新增文件必须落 profile)${profileProbe.missing.length} 个:${profileProbe.missing.slice(0, 3).join('、')}`)
438
+ if (profileProbe.skipped.length) info(`profile 副本跳过 ${profileProbe.skipped.length} 个(该 profile 未安装对应包)`)
439
+ if (profileProbe.bad.length) bad(`profile 副本异常 ${profileProbe.bad.length} 个:${profileProbe.bad.slice(0, 3).join('、')}`)
440
+ }
441
+
442
+ if (!opt.apply) return dryRun(opt, faces, set, plan, profilePlan, root, backupRoot, pre, profileProbe)
443
+
444
+ // ── ⑤ 备份(先备份,再投递)───────────────────────────────────────────
445
+ const tag = `${tagStamp()}-${faces.join('-')}`
446
+ const bak = backupRemote(opt, root, backupRoot, tag, faces, set, plan)
447
+
448
+ // ── ⑥ rsync(无 --delete;排除表双保险)────────────────────────────────
449
+ rsyncDeliver(opt, root, set, excludes)
450
+
451
+ // ── ⑦ 权限归一(按 git 索引模式)+ 复核 ────────────────────────────────
452
+ const modeBad = normalizeModes(opt, root, set.files)
453
+
454
+ // ── ⑧ 双端 sha256(逐文件;含新增/缺失)───────────────────────────────
455
+ const shaBad = verifyShas(opt, root, set.files, localSha)
456
+
457
+ // ── ⑨ profile 副本(面含 plugins/presets 时)───────────────────────────
458
+ const prof = needProfile ? applyProfile(opt, root, profilePlan) : null
459
+
460
+ // ── ⑩ 回滚命令(落远端 + 打印;声明耦合组)────────────────────────────
461
+ const rollback = writeRollback(opt, root, backupRoot, bak.tag, faces, set, plan, profilePlan, preMap)
462
+
463
+ // ── ⑪ 连续性自检(可选)───────────────────────────────────────────────
464
+ const selfRes = opt.selfCheck ? selfCheck(opt, root, pre) : null
465
+
466
+ // ── 结论 ──────────────────────────────────────────────────────────────
467
+ say('')
468
+ say(' ── 结论 ──')
469
+ ok(`投递完成:${set.files.length} 个文件(新增 ${plan.added.length} / 更新 ${plan.changed.length} / 一致 ${plan.same.length})`)
470
+ ok(`模式不符 = ${modeBad.length}(按 git 索引模式归一 644/755)`)
471
+ ok(`双端 sha256 不符 = ${shaBad.length}`)
472
+ if (prof) ok(`profile 副本:修复 ${prof.fixed} | 新增落地 ${prof.linked} | 跳过(未安装)${prof.skipped} | 仍不符 ${prof.stillBad.length}`)
473
+ else if (!opt.profileCheck) info('profile 副本核对:已用 --no-profile **关闭**(面里含 plugins/presets,但本次不核)')
474
+ else info('profile 副本核对:投递面不含 plugins/presets ⇒ 不适用(这是正常姿态)')
475
+ info(`备份:${bak.dir}(tar sha256 ${bak.tarSha.slice(0, 12)}…,MANIFEST.txt 已写)`)
476
+ say('')
477
+ printRollback(rollback, opt)
478
+ if (selfRes) printSelfCheck(selfRes)
479
+
480
+ const hardFail = modeBad.length > 0 || shaBad.length > 0 || (prof && prof.stillBad.length > 0) || (selfRes && !selfRes.ok)
481
+ if (hardFail) {
482
+ bad('投递后核对未全绿 ⇒ 走回滚(上面的命令可直接粘贴)')
483
+ throw new DeliveryFailure('投递或核对失败')
484
+ }
485
+ ok('全部核对通过(双端 sha / 模式 / profile / 回滚脚本已就位)')
486
+ say(' 提示:本脚本**不重启**任何东西 —— plugins/(host) 与 plist 改动需**用户手动**重启才生效;')
487
+ say(' flow-trigger* 每轮从磁盘重读 ⇒ 投递器/驱动器改动不必重启。')
488
+ return EXIT.OK
489
+ }
490
+
491
+ // ══════════════════════════════════════════════════════════════════════════
492
+ // 五、步骤实现
493
+ // ══════════════════════════════════════════════════════════════════════════
494
+
495
+ const tagStamp = () => {
496
+ const d = new Date()
497
+ const p = (n, w = 2) => String(n).padStart(w, '0')
498
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
499
+ }
500
+
501
+ const expandTilde = (p, home) => (p === '~' ? home : p.startsWith('~/') ? `${home}/${p.slice(2)}` : p)
502
+
503
+ /** 取远端 HOME(前置:目标可达;exit 3 挡住了"不可达")。 */
504
+ function remoteHome(target) {
505
+ const r = sshRun(target, 'printf \'%s\\n\' "$HOME"\n', [], { timeoutMs: 30000 })
506
+ if (r.code !== 0 || !r.out.trim()) {
507
+ const why = r.timeout ? '超时' : `exit ${r.code}`
508
+ throw new Blocked(`目标不可达(${target},${why}):${(r.err || r.out).trim().split('\n').slice(-1)[0] || '无输出'}`)
509
+ }
510
+ return r.out.trim().split('\n').pop().trim()
511
+ }
512
+
513
+ function probeTarget(opt, root, files, _unused) {
514
+ const script = probeScript(root, files.map((f) => f.rel), opt.selfCheck ? LEDGER_PROBE : '')
515
+ const r = sshRun(opt.target, script)
516
+ if (r.code !== 0) throw new Blocked(`远端只读探测失败(exit ${r.code}):${r.err.trim().split('\n').slice(-1)[0] || ''}`)
517
+ const rows = parseTsv(r.out)
518
+ const meta = {}; const pre = new Map()
519
+ for (const f of rows) {
520
+ if (f[0] === 'PRE') pre.set(f[1], { sha: f[2], mode: f[3] })
521
+ else if (f[0]) meta[f[0]] = f.slice(1).join('\t')
522
+ }
523
+ if (meta.REMOTE_ROOT !== '1') warn(`远端落点还不存在(${root})—— 将由 rsync 创建(首次投递形态)`)
524
+ info(`远端:${meta.REMOTE_UNAME || '?'} | HOME=${meta.REMOTE_HOME || '?'}`)
525
+ return { meta, pre }
526
+ }
527
+
528
+ /** dry-run:**零写入** —— 只打印将要执行的命令与分类,不跑 rsync、不 chmod、不建备份。 */
529
+ function dryRun(opt, faces, set, plan, profilePlan, root, backupRoot, pre, profileProbe) {
530
+ say('')
531
+ say(' ── dry-run 计划(**零写入**:不跑 rsync、不 chmod、不建备份目录)──')
532
+ const tag = `${tagStamp()}-${faces.join('-')}`
533
+ info(`备份将落:${backupRoot}/${tag}/(faces.tar.gz 600 + MANIFEST.txt)`)
534
+ const listRel = writeFilesFrom(set.files) // 本地临时清单(不在远端写任何东西)
535
+ info(`rsync 命令:${rsyncArgs(opt, root, listRel, [], true).join(' ')}`)
536
+ rmSync(dirname(listRel), { recursive: true, force: true })
537
+ info(`权限归一:按 git 索引模式 chmod(644×${set.files.filter((f) => f.mode === '644').length} / 755×${set.files.filter((f) => f.mode === '755').length})`)
538
+ info(`双端 sha256:逐文件核对 ${set.files.length} 个(含新增/缺失)`)
539
+ if (profilePlan.length) {
540
+ const p = profileProbe || { ok: 0, drifted: [], missing: [], skipped: [] }
541
+ info(`profile 副本:将核 ${profilePlan.length} 个(面含 ${faces.filter((f) => PROFILE_FACES.includes(f)).join('/')})`
542
+ + `|一致 ${p.ok} |待 ln -f 修复 ${p.drifted.length} |待落地(新增)${p.missing.length}`
543
+ + ` |待投递后核对 ${(p.pre || []).length} |跳过 ${p.skipped.length}`)
544
+ }
545
+ if (pre && pre.meta && pre.meta.LEDGER_SHA) info(`台账:sha ${String(pre.meta.LEDGER_SHA).slice(0, 12)}… | planned=${pre.meta.LEDGER_PLANNED}`)
546
+ say('')
547
+ say(' ── 回滚 ──')
548
+ info('dry-run 未投递 ⇒ 无需回滚(真执行时会生成并落远端 rollback.sh,控制台原样打印)')
549
+ if (opt.coupled.length) printCoupled(opt.coupled)
550
+ say('')
551
+ ok(`dry-run 结束:**零写入**(远端只做了 printf/stat/shasum 只读探测;新增 ${plan.added.length} / 更新 ${plan.changed.length} / 一致 ${plan.same.length})`)
552
+ return EXIT.OK
553
+ }
554
+
555
+ /** 本地临时 `--files-from` 清单(只列文件,不列目录 ⇒ `-a` 不会顺手推整棵树)。 */
556
+ function writeFilesFrom(files) {
557
+ const dir = mkdtempSync(join(tmpdir(), 'deliver-customer-'))
558
+ const p = join(dir, 'files-from.txt')
559
+ writeFileSync(p, files.map((f) => f.rel).join('\n') + '\n')
560
+ return p
561
+ }
562
+
563
+ function rsyncArgs(opt, root, listFile, excludes, dryRun) {
564
+ const a = ['-az', '--no-owner', '--no-group', `--files-from=${listFile}`]
565
+ if (dryRun) a.push('--dry-run')
566
+ for (const e of excludes) a.push(`--exclude=${e}`) // 双保险:清单生成有 bug 也不推垃圾
567
+ a.push('-e', 'ssh', `${opt.source}/`, `${opt.target}:${root}/`)
568
+ return a // ⛔ 永不出现 --delete
569
+ }
570
+
571
+ function rsyncDeliver(opt, root, set, excludes) {
572
+ const listRel = writeFilesFrom(set.files)
573
+ try {
574
+ const r = spawnSync('rsync', rsyncArgs(opt, root, listRel, excludes, false), { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 600000 })
575
+ if (r.error || r.status !== 0) {
576
+ throw new DeliveryFailure(`rsync 失败(exit ${r.status}):${String(r.stderr || r.error?.message || '').trim().split('\n').slice(-1)[0]}`)
577
+ }
578
+ ok(`rsync 投递完成(-az --no-owner --no-group --files-from,无 --delete)`)
579
+ } finally {
580
+ rmSync(dirname(listRel), { recursive: true, force: true })
581
+ }
582
+ }
583
+
584
+ /** 远端备份:面 tar(600)+ sha256 + MANIFEST.txt(含投递前逐文件 sha/模式/新增标记)。 */
585
+ function backupRemote(opt, root, backupRoot, tag, faces, set, plan) {
586
+ const newSet = new Set(plan.added)
587
+ const manifest = set.files.map((f) => `${f.rel}\t${newSet.has(f.rel) ? '(新增)' : (plan.changed.includes(f.rel) ? 'update' : 'same')}`).join('\n')
588
+ const script = `${REMOTE_PREAMBLE}ROOT=${shq(root)}
589
+ BAK=${shq(backupRoot)}
590
+ TAG=${shq(tag)}
591
+ mkdir -p "$BAK" || { printf 'ERR\\tmkdir\\n'; exit 3; }
592
+ # tag 唯一化:同一秒内两次投递(或两个运维同时投)不许互相覆盖备份
593
+ keep="$TAG"; n=1
594
+ while [ -e "$BAK/$TAG" ]; do TAG="$keep-$n"; n=$((n + 1)); done # ⚠️ 后缀加在**名字**上,不是路径上
595
+ mkdir -p "$BAK/$TAG" || { printf 'ERR\tmkdir\n'; exit 3; }
596
+ printf 'TAG\t%s\n' "$TAG"
597
+ faces=(); absent=()
598
+ ${faces.map((f) => `[ -e "$ROOT/${f}" ] && faces+=(${shq(f)}) || absent+=(${shq(f)})`).join('\n')}
599
+ # 首次投递形态:远端可能**一个面都还没有**(全新客户机)⇒ 这不是失败 —— 备份里没有 tar,
600
+ # 回滚 = 删掉本次新增的文件(rollback.sh 会跳过解包)。⛔ 不能因为"没得备份"就拒绝投递。
601
+ if [ "\${#faces[@]}" -gt 0 ]; then
602
+ tar -czf "$BAK/$TAG/faces.tar.gz" -C "$ROOT" "\${faces[@]}" || { printf 'ERR\\ttar\\n'; exit 1; }
603
+ chmod 600 "$BAK/$TAG/faces.tar.gz"
604
+ tar_sha="$(sha_of "$BAK/$TAG/faces.tar.gz")"
605
+ printf '%s\\n' "$tar_sha" > "$BAK/$TAG/faces.tar.gz.sha256"; chmod 600 "$BAK/$TAG/faces.tar.gz.sha256"
606
+ else
607
+ tar_sha='-'
608
+ fi
609
+ {
610
+ printf 'tag\\t%s\\n' "$TAG"
611
+ printf 'at\\t%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
612
+ printf 'root\\t%s\\n' "$ROOT"
613
+ # ⚠️ 末尾的 :- 是**必须**的:客户机默认 bash 是 3.2(macOS /bin/bash),set -u 下**空数组**的
614
+ # [*] 展开会报 unbound variable(本批实测踩到:首次投递时 faces / absent 都可能为空)
615
+ printf 'faces\\t%s\\n' "\${faces[*]:-}"
616
+ printf 'absent_faces\\t%s\\n' "\${absent[*]:-}"
617
+ printf 'tar_sha256\\t%s\\n' "$tar_sha"
618
+ printf 'coupled\\t%s\\n' ${shq(opt.coupled.join(' | ') || '(未声明)')}
619
+ printf -- '--- 投递前逐文件状态(rel<TAB>state;sha/模式见 rollback.sh)\\n'
620
+ cat <<'__ARGENT_MANIFEST__'
621
+ ${manifest}
622
+ __ARGENT_MANIFEST__
623
+ } > "$BAK/$TAG/MANIFEST.txt"
624
+ chmod 600 "$BAK/$TAG/MANIFEST.txt"
625
+ printf 'TAR\\t%s\\t%s\\n' "$tar_sha" "$BAK/$TAG/faces.tar.gz"
626
+ `
627
+ const r = sshRun(opt.target, script)
628
+ const rows = parseTsv(r.out)
629
+ const finalTag = (rows.find((x) => x[0] === 'TAG') || [])[1] || tag
630
+ const row = rows.find((x) => x[0] === 'TAR')
631
+ if (r.code !== 0 || !row) {
632
+ const err = parseTsv(r.out).find((x) => x[0] === 'ERR')
633
+ const why = (r.err || r.out).trim().split('\n').slice(-1)[0] || '无输出'
634
+ throw new DeliveryFailure(`备份失败(exit ${r.code}${err ? `,${err.slice(1).join(' ')}` : ''}):${why} —— ⛔ 不投递:先备份再投递是硬顺序`)
635
+ }
636
+ if (row[1] === '-') {
637
+ warn('远端**一个投递面都还不存在**(首次投递形态)⇒ 备份无 tar(回滚 = 删掉本次新增文件)')
638
+ return { dir: `${backupRoot}/${finalTag}`, tarSha: '-', tarPath: '-', tag: finalTag }
639
+ }
640
+ ok(`备份完成:${row[2]}(sha256 ${row[1].slice(0, 12)}…,600,MANIFEST.txt)`)
641
+ return { dir: `${backupRoot}/${finalTag}`, tarSha: row[1], tarPath: row[2], tag: finalTag }
642
+ }
643
+
644
+ /** 权限归一(按 git 索引模式)+ 复核"模式不符 = 0"。 */
645
+ function normalizeModes(opt, root, files) {
646
+ const chmod = (mode, list) => {
647
+ if (!list.length) return
648
+ const script = `ROOT=${shq(root)}
649
+ while IFS= read -r rel; do
650
+ [ -n "$rel" ] || continue
651
+ chmod ${mode} "$ROOT/$rel" 2>/dev/null || printf 'CHMOD_FAIL\\t%s\\n' "$rel"
652
+ done <<'${SENTINEL}'
653
+ ${list.join('\n')}
654
+ ${SENTINEL}
655
+ `
656
+ const r = sshRun(opt.target, script)
657
+ for (const row of parseTsv(r.out)) if (row[0] === 'CHMOD_FAIL') bad(`chmod 失败:${row[1]}`)
658
+ }
659
+ chmod('755', files.filter((f) => f.mode === '755').map((f) => f.rel))
660
+ chmod('644', files.filter((f) => f.mode === '644').map((f) => f.rel))
661
+ // 复核:远端实际模式 vs git 索引模式
662
+ const script = `${REMOTE_PREAMBLE}ROOT=${shq(root)}
663
+ while IFS= read -r rel; do
664
+ [ -n "$rel" ] || continue
665
+ p="$ROOT/$rel"
666
+ if [ -f "$p" ]; then printf 'MODE\\t%s\\t%s\\n' "$rel" "$(st_mode "$p")"
667
+ else printf 'MODE\\t%s\\t-\\n' "$rel"; fi
668
+ done <<'${SENTINEL}'
669
+ ${files.map((f) => f.rel).join('\n')}
670
+ ${SENTINEL}
671
+ `
672
+ const r = sshRun(opt.target, script)
673
+ const want = new Map(files.map((f) => [f.rel, f.mode]))
674
+ const mism = []
675
+ for (const row of parseTsv(r.out)) {
676
+ if (row[0] !== 'MODE') continue
677
+ if (want.get(row[1]) !== row[2]) mism.push(`${row[1]}(期望 ${want.get(row[1])} 实际 ${row[2]})`)
678
+ }
679
+ for (const m of mism.slice(0, 10)) bad(`模式不符:${m}`)
680
+ return mism
681
+ }
682
+
683
+ /** 双端 sha256:逐文件(含**新增/缺失**),任一不符即点名。 */
684
+ function verifyShas(opt, root, files, localSha) {
685
+ const script = `${REMOTE_PREAMBLE}ROOT=${shq(root)}
686
+ while IFS= read -r rel; do
687
+ [ -n "$rel" ] || continue
688
+ p="$ROOT/$rel"
689
+ if [ -f "$p" ]; then printf 'SHA\\t%s\\t%s\\n' "$rel" "$(sha_of "$p")"
690
+ else printf 'MISSING\\t%s\\t-\\n' "$rel"; fi
691
+ done <<'${SENTINEL}'
692
+ ${files.map((f) => f.rel).join('\n')}
693
+ ${SENTINEL}
694
+ `
695
+ const r = sshRun(opt.target, script)
696
+ if (r.code !== 0) throw new DeliveryFailure(`远端 sha 计算失败(exit ${r.code}):${r.err.trim().split('\n').slice(-1)[0] || ''}`)
697
+ const rows = parseTsv(r.out)
698
+ const seen = new Set()
699
+ const badList = []
700
+ for (const row of rows) {
701
+ if (row[0] === 'MISSING') { badList.push(`${row[1]}(远端缺失)`); continue }
702
+ if (row[0] !== 'SHA') continue
703
+ seen.add(row[1])
704
+ if (localSha.get(row[1]) !== row[2]) badList.push(`${row[1]}(本地 ${String(localSha.get(row[1])).slice(0, 12)}… ≠ 远端 ${String(row[2]).slice(0, 12)}…)`)
705
+ }
706
+ for (const f of files) if (!seen.has(f.rel)) badList.push(`${f.rel}(未核对到 —— 远端无输出)`)
707
+ for (const b of badList.slice(0, 20)) bad(`双端 sha 不符:${b}`)
708
+ if (badList.length > 20) bad(`…另有 ${badList.length - 20} 个未列出`)
709
+ return badList
710
+ }
711
+
712
+ /** 面含 plugins/presets 时的 profile 副本行(远端路径在 node 侧算好)。 */
713
+ function profileRows(opt, root, home, faces, files) {
714
+ const profRoot = expandTilde(opt.profileRoot, home)
715
+ const presetsRoot = opt.presetsRoot ? expandTilde(opt.presetsRoot, home) : `${dirname(profRoot)}/.agent-presets`
716
+ const rows = []
717
+ for (const f of files) {
718
+ const [face, ...rest] = f.rel.split('/')
719
+ if (face === 'plugins') {
720
+ const pkg = rest[0]
721
+ if (!pkg || rest.length < 2) continue
722
+ rows.push({ kind: 'PLUGIN', rel: f.rel, dst: `${profRoot}/${opt.profile}/node_modules/${rest.join('/')}`, guard: `${profRoot}/${opt.profile}/node_modules/${pkg}`, pkg })
723
+ } else if (face === 'presets') {
724
+ const id = rest[0]
725
+ if (!id || rest.length < 2) continue
726
+ rows.push({ kind: 'PRESET', rel: f.rel, dst: `${presetsRoot}/${rest.join('/')}`, guard: `${presetsRoot}/${id}`, pkg: id })
727
+ }
728
+ }
729
+ return rows
730
+ }
731
+
732
+ /** profile 副本核对/修复块(forward 与 rollback 共用:同 inode + nlink≥2 + md5,`ln -f` 修复)。 */
733
+ function profileScript(root, rows, apply) {
734
+ return `ROOT=${shq(root)}
735
+ APPLY=${apply ? 1 : 0}
736
+ while IFS=$'\\t' read -r kind rel dst guard; do
737
+ [ -n "$rel" ] || continue
738
+ src="$ROOT/$rel"
739
+ if [ ! -d "$guard" ]; then printf 'PROF_SKIP\\t%s\\t%s\\n' "$rel" "\${guard##*/} 未安装"; continue; fi
740
+ if [ ! -f "$src" ]; then printf 'PROF_PRE\\t%s\\t待投递后核对(源文件还没到客户机)\\n' "$rel"; continue; fi
741
+ if [ -f "$dst" ]; then
742
+ m1="$(md5_of "$src")"; m2="$(md5_of "$dst")"
743
+ i1="$(st_ino "$src")"; i2="$(st_ino "$dst")"; nl="$(st_nlink "$dst")"
744
+ if [ "$m1" = "$m2" ] && [ "$i1" = "$i2" ] && [ "$nl" -ge 2 ]; then printf 'PROF_OK\\t%s\\t-\\n' "$rel"; continue; fi
745
+ if [ "$APPLY" != "1" ]; then printf 'PROF_DRIFT\\t%s\\tmd5=%s inode=%s nlink=%s(将 ln -f)\\n' "$rel" "$([ "$m1" = "$m2" ] && printf same || printf diff)" "$([ "$i1" = "$i2" ] && printf same || printf diff)" "$nl"; continue; fi
746
+ mkdir -p "$(dirname "$dst")"
747
+ ln -f "$src" "$dst" 2>/dev/null || cp -p "$src" "$dst"
748
+ if [ "$(md5_of "$src")" = "$(md5_of "$dst")" ] && [ "$(st_ino "$src")" = "$(st_ino "$dst")" ] && [ "$(st_nlink "$dst")" -ge 2 ]; then
749
+ printf 'PROF_FIX\\t%s\\t-\\n' "$rel"
750
+ elif [ "$(md5_of "$src")" = "$(md5_of "$dst")" ]; then
751
+ printf 'PROF_WARN\\t%s\\tmd5 已一致但拿不到硬链接(跨设备/权限?)\\n' "$rel"
752
+ else
753
+ printf 'PROF_BAD\\t%s\\t修复后 md5 仍不一致\\n' "$rel"
754
+ fi
755
+ else
756
+ if [ "$APPLY" != "1" ]; then printf 'PROF_NEW\\t%s\\tprofile 缺该文件(将 ln -f 落地)\\n' "$rel"; continue; fi
757
+ mkdir -p "$(dirname "$dst")"
758
+ ln -f "$src" "$dst" 2>/dev/null || cp -p "$src" "$dst"
759
+ if [ -f "$dst" ] && [ "$(md5_of "$src")" = "$(md5_of "$dst")" ]; then printf 'PROF_LINK\\t%s\\t-\\n' "$rel"
760
+ else printf 'PROF_BAD\\t%s\\t新增文件落 profile 失败\\n' "$rel"; fi
761
+ fi
762
+ done <<'${SENTINEL}'
763
+ ${rows.map((r) => `${r.kind}\t${r.rel}\t${r.dst}\t${r.guard}`).join('\n')}
764
+ ${SENTINEL}
765
+ `
766
+ }
767
+
768
+ /** profile 副本**只读**预检(APPLY=0):dry-run 的答案,也是 apply 前的现状快照。 */
769
+ function probeProfile(opt, root, rows) {
770
+ if (!rows.length) return { ok: 0, drifted: [], missing: [], skipped: [], bad: [], pre: [] }
771
+ const r = sshRun(opt.target, `${REMOTE_PREAMBLE}${profileScript(root, rows, false)}`)
772
+ const out = { ok: 0, drifted: [], missing: [], skipped: [], bad: [], pre: [] }
773
+ for (const row of parseTsv(r.out)) {
774
+ if (row[0] === 'PROF_OK') out.ok += 1
775
+ else if (row[0] === 'PROF_PRE') out.pre.push(row[1])
776
+ else if (row[0] === 'PROF_DRIFT') out.drifted.push(`${row[1]}(${row[2]})`)
777
+ else if (row[0] === 'PROF_NEW') out.missing.push(row[1])
778
+ else if (row[0] === 'PROF_SKIP') out.skipped.push(row[1])
779
+ else if (row[0] === 'PROF_BAD') out.bad.push(`${row[1]}(${row[2]})`)
780
+ }
781
+ return out
782
+ }
783
+
784
+ function applyProfile(opt, root, rows) {
785
+ const r = sshRun(opt.target, `${REMOTE_PREAMBLE}${profileScript(root, rows, true)}`)
786
+ const out = { fixed: 0, linked: 0, skipped: 0, warned: 0, stillBad: [], drifted: 0 }
787
+ for (const row of parseTsv(r.out)) {
788
+ if (row[0] === 'PROF_OK') continue
789
+ if (row[0] === 'PROF_FIX') out.fixed += 1
790
+ else if (row[0] === 'PROF_LINK') out.linked += 1
791
+ else if (row[0] === 'PROF_SKIP') out.skipped += 1
792
+ else if (row[0] === 'PROF_WARN') { out.warned += 1; warn(`profile 硬链接未建立(内容已一致):${row[1]} —— ${row[2]}`) }
793
+ else if (row[0] === 'PROF_BAD') out.stillBad.push(`${row[1]}(${row[2]})`)
794
+ else if (row[0] === 'PROF_DRIFT') out.drifted += 1
795
+ }
796
+ for (const b of out.stillBad) bad(`profile 副本:${b}`)
797
+ return out
798
+ }
799
+
800
+ /** 生成回滚脚本(落远端 700 + 控制台可粘贴),内含:解包 → 删新增 → 恢复模式 → 复核 sha → 重链 profile。 */
801
+ function writeRollback(opt, root, backupRoot, tag, faces, set, plan, profileRowsAll, preMap) {
802
+ const newSet = new Set(plan.added)
803
+ const existed = set.files.filter((f) => !newSet.has(f.rel))
804
+ // ⚠️ 核对基准必须是**投递前**的 sha 与模式(`preMap`),不是投递后的新值 ——
805
+ // 回滚后内容 = 投递前内容;拿新 sha 去比会把"回滚成功"判成失败(本次实测踩到)。
806
+ const expected = existed.map((f) => `${f.rel}\t${(preMap.get(f.rel) || {}).sha || '-'}`).join('\n')
807
+ const dir = `${backupRoot}/${tag}`
808
+ const script = `${REMOTE_PREAMBLE}ROOT=${shq(root)}
809
+ BAK=${shq(dir)}
810
+ if [ -f "$BAK/faces.tar.gz" ]; then
811
+ tar -xzf "$BAK/faces.tar.gz" -C "$ROOT" || { printf 'ROLLBACK_FAIL\\ttar 解包失败\\n'; exit 1; }
812
+ else
813
+ printf 'ROLLBACK_INFO\\t无 tar(首次投递形态):只删本次新增文件\\n'
814
+ fi
815
+ while IFS= read -r rel; do
816
+ [ -n "$rel" ] || continue
817
+ rm -f "$ROOT/$rel"
818
+ done <<'${SENTINEL}'
819
+ ${plan.added.join('\n')}
820
+ ${SENTINEL}
821
+ while IFS=$'\\t' read -r mode rel; do
822
+ [ -n "$rel" ] || continue
823
+ [ -e "$ROOT/$rel" ] && chmod "$mode" "$ROOT/$rel"
824
+ done <<'${SENTINEL}'
825
+ ${existed.map((f) => `${(preMap.get(f.rel) || {}).mode || f.mode}\t${f.rel}`).join('\n')}
826
+ ${SENTINEL}
827
+ bad=0
828
+ while IFS=$'\\t' read -r rel want; do
829
+ [ -n "$rel" ] || continue
830
+ p="$ROOT/$rel"
831
+ if [ ! -f "$p" ]; then printf 'ROLLBACK_BAD\\t%s\\t缺文件\\n' "$rel"; bad=$((bad + 1)); continue; fi
832
+ got="$(sha_of "$p")"
833
+ [ "$got" = "$want" ] || { printf 'ROLLBACK_BAD\\t%s\\tsha 不符\\n' "$rel"; bad=$((bad + 1)); }
834
+ done <<'${SENTINEL}'
835
+ ${expected}
836
+ ${SENTINEL}
837
+ while IFS= read -r rel; do
838
+ [ -n "$rel" ] || continue
839
+ [ -e "$ROOT/$rel" ] && { printf 'ROLLBACK_BAD\\t%s\\t本次新增文件未被删除\\n' "$rel"; bad=$((bad + 1)); }
840
+ done <<'${SENTINEL}'
841
+ ${plan.added.join('\n')}
842
+ ${SENTINEL}
843
+ ${profileRowsAll.length ? profileScript(root, profileRowsAll, true) : ''}
844
+ if [ "$bad" -eq 0 ]; then printf 'ROLLBACK_OK\\t%s\\n' "$ROOT"; exit 0; fi
845
+ printf 'ROLLBACK_FAIL\\t%s 个文件未恢复到投递前\\n' "$bad"; exit 1
846
+ `
847
+ // 落远端(700),并把"怎么用"写进备份目录
848
+ const r = sshRun(opt.target, `BAK=${shq(dir)}
849
+ mkdir -p "$BAK"
850
+ cat > "$BAK/rollback.sh" <<'__ARGENT_ROLLBACK__'
851
+ ${script}
852
+ __ARGENT_ROLLBACK__
853
+ chmod 700 "$BAK/rollback.sh"
854
+ printf 'WROTE\\t%s\\n' "$BAK/rollback.sh"
855
+ `)
856
+ const wrote = parseTsv(r.out).find((x) => x[0] === 'WROTE')
857
+ if (r.code !== 0 || !wrote) throw new DeliveryFailure(`回滚脚本落盘失败(exit ${r.code}):${r.err.trim().split('\n').slice(-1)[0] || ''}`)
858
+ return { dir, tag, path: wrote[1], selfRun: `ssh ${opt.target} bash ${wrote[1]}`, tarSha: null }
859
+ }
860
+
861
+ function printRollback(rb, opt) {
862
+ say(' ── 回滚(可直接粘贴;脚本已落客户机,700)──')
863
+ say(` ssh ${opt.target} bash ${rb.path}`)
864
+ info(`备份目录:${rb.dir}(faces.tar.gz 600 + sha256 + MANIFEST.txt + rollback.sh)`)
865
+ info('回滚做什么:解包回投递前 → 删掉本次**新增**的文件 → 按投递前模式 chmod → 逐文件复核 sha → 重链 profile 副本')
866
+ if (opt.coupled.length) printCoupled(opt.coupled)
867
+ info('⛔ 本脚本**不重启**服务:若本次改动含 plugins/(host) 或 plist,回滚后需**用户手动**重启才生效')
868
+ }
869
+
870
+ function printCoupled(groups) {
871
+ say('')
872
+ say(' ⚠️ 耦合回滚组(**必须一起回滚**,只回一半会让流程变哑):')
873
+ for (const g of groups) say(` ⇄ ${g}`)
874
+ info('例:日志移出 /tmp 后只回滚"读 /tmp 的旧唤醒脚本" ⇒ 四条流程全哑 —— 必须连同 plist 一起回滚并重启')
875
+ }
876
+
877
+ const selfCheckLedgerLine = (pre) => (pre && pre.meta ? pre.meta : {})
878
+
879
+ /** 连续性自检:跑**仓库既有**判据(只读),异常 ⇒ 结论 + 提示回滚。 */
880
+ function selfCheck(opt, root, pre) {
881
+ say('')
882
+ say(' ── 连续性自检(客户机侧**只读**,跑仓库既有判据)──')
883
+ const script = `${REMOTE_PREAMBLE}ROOT=${shq(root)}
884
+ if command -v node >/dev/null 2>&1 && [ -f "$ROOT/scripts/check-production-home.mjs" ]; then
885
+ out="$(cd "$ROOT" && node scripts/check-production-home.mjs --role production 2>&1)"; rc=$?
886
+ printf 'PRODHOME\\t%s\\t%s\\n' "$rc" "$(printf '%s' "$out" | tail -1)"
887
+ else
888
+ printf 'PRODHOME\\t-\\tnode 或 check-production-home.mjs 不在位\\n'
889
+ fi
890
+ ${LEDGER_PROBE}`
891
+ const r = sshRun(opt.target, script)
892
+ const rows = parseTsv(r.out)
893
+ const get = (k) => (rows.find((x) => x[0] === k) || [])[1]
894
+ const rc = get('PRODHOME')
895
+ const prodLine = (rows.find((x) => x[0] === 'PRODHOME') || [])[2] || ''
896
+ const led = get('LEDGER_SHA')
897
+ const planned = get('LEDGER_PLANNED')
898
+ const preLed = selfCheckLedgerLine(pre).LEDGER_SHA
899
+ const prePlanned = selfCheckLedgerLine(pre).LEDGER_PLANNED
900
+ const dPlanned = (planned !== '-' && prePlanned !== undefined && prePlanned !== '-') ? String(Number(planned) - Number(prePlanned)) : '?'
901
+ const prodOk = rc === '0'
902
+ const unknown = rc === '-'
903
+ if (prodOk) ok(`生产归属(check-production-home --role production):${prodLine}`)
904
+ else if (unknown) warn(`生产归属:**未判定**(无法自检 —— ${prodLine});按"没有坏证据"处理,不算失败`)
905
+ else bad(`生产归属异常(exit ${rc}):${prodLine}`)
906
+ info(`台账 sha:${led === '-' || !led ? '不可读' : led.slice(0, 12) + '…'}${preLed && preLed !== '-' ? `(投递前 ${preLed.slice(0, 12)}… ${preLed === led ? '未变' : '**已变**'})` : ''}`)
907
+ info(`planned:${planned}|Δplanned=${dPlanned}${dPlanned !== '0' && dPlanned !== '?' ? '(投递窗口内台账有变动:生产在跑属正常,但若**本不该**触发流程请人工核)' : ''}`)
908
+ return { ok: prodOk || unknown, unknown, rc, prodLine, led, planned, dPlanned }
909
+ }
910
+
911
+ function printSelfCheck(s) {
912
+ say('')
913
+ const verdict = s.ok ? (s.unknown ? '⚠️ 未判定(没有坏证据,但也没能自证)' : '✅ 正常') : '❌ 异常 ⇒ 建议回滚(上面的命令可直接粘贴)'
914
+ say(` ── 自检结论:${verdict} ──`)
915
+ }
916
+
917
+ // ══════════════════════════════════════════════════════════════════════════
918
+ // 入口
919
+ // ══════════════════════════════════════════════════════════════════════════
920
+ function runCli() {
921
+ try {
922
+ return main()
923
+ } catch (e) {
924
+ if (e instanceof UsageError) { bad(e.message); return EXIT.USAGE }
925
+ if (e instanceof Blocked) { bad(e.message); say(' (前置条件不满足 ⇒ exit 3;未做任何投递)'); return EXIT.BLOCKED }
926
+ if (e instanceof DeliveryFailure) { bad(e.message); return EXIT.FAIL }
927
+ bad(`内部错误:${e && e.stack ? e.stack : e}`)
928
+ return EXIT.FAIL
929
+ }
930
+ }
931
+
932
+ if (isDirectRun(import.meta.url)) process.exit(runCli())