@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.
- package/MANIFEST.json +341 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +576 -0
- package/engine/scripts/billing-mode-daily.sh +13 -0
- package/engine/scripts/build-engine-package.mjs +126 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +378 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/collect-evidence.mjs +42 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +135 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +47 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +261 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/final-check.mjs +43 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +735 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +87 -0
- package/engine/scripts/flow-timers.sh +184 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1025 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2278 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +195 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent-datadir.sh +18 -0
- package/engine/scripts/lib/argent_datadir.py +285 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +295 -0
- package/engine/scripts/poll-invoice.sh +76 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +71 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/retire-inventory.mjs +40 -0
- package/engine/scripts/run-tests.sh +257 -0
- package/engine/scripts/s1-audit.mjs +85 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +137 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +74 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +35 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +217 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
- package/engine/scripts/ziniao-windows.sh +154 -0
- package/engine/skills/README.md +86 -0
- package/engine/skills/argent-dev/SKILL.md +41 -0
- package/engine/skills/internal.json +37 -0
- package/engine/skills/lktop-pm/SKILL.md +35 -0
- package/engine/skills/lktopapp-dev/SKILL.md +34 -0
- package/engine/skills/ml-config-guide/SKILL.md +50 -0
- package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
- package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
- package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
- package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
- package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
- package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
- package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
- package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
- package/engine/skills/ml-invoice/SKILL.md +506 -0
- package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
- package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
- package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
- package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
- package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
- package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
- package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
- package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
- package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
- package/engine/skills/pet-dev/SKILL.md +42 -0
- package/engine/skills/whyshu-pm/SKILL.md +35 -0
- package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +738 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +22 -4
- package/requirements.txt +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-scope-rewire.mjs —— profile「`@deepseek-ai` 软链农场」**改线器**(P1-2,2026-10-12)
|
|
4
|
+
*
|
|
5
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* 一、它解决什么(为什么必须有工具,而不是"手敲 ln -sf")
|
|
7
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
* dsh 的 profile 里,`node_modules/@deepseek-ai` 指向**某一个具体 node 前缀的全局 scope**
|
|
9
|
+
* (本机 = `/opt/homebrew/lib/node_modules/@deepseek-ai`)。换前缀 / 重装 dsh 后若只清旧 scope
|
|
10
|
+
* 而不重建这些链 ⇒ **宿主新版本 + profile peer 解析到旧版本**的混合版本 ⇒ 前端 boot 永久 pending、
|
|
11
|
+
* `dsh web` 卡在加载屏(服务端 200 全绿也救不了)。
|
|
12
|
+
* 这类"改 line 软链农场"的动作在本仓库**踩过四次**(AGENTS 有记)⇒ 必须:**默认 dry-run、
|
|
13
|
+
* 先备份、有回滚、幂等、写后校验**。手敲 `ln -sf` 四样一样都不占。
|
|
14
|
+
*
|
|
15
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
* 二、两种农场形态(实测本机两种**同时存在**,别再按"284 条链"想当然)
|
|
17
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
* | 形态 | 农场路径 | 真身 | 改线动作 |
|
|
19
|
+
* |---|---|---|---|
|
|
20
|
+
* | **整树链接**(`dir-link`) | `profiles/<p>/node_modules/@deepseek-ai` | **一个软链**指向整个 scope 目录 | 换掉这**一个**软链 |
|
|
21
|
+
* | **行式**(`per-pkg`) | `profiles/node_modules/@deepseek-ai` | 真目录 + **逐包软链** | 逐包改指 / 新增 / 清陈旧 |
|
|
22
|
+
*
|
|
23
|
+
* 本机实测(2026-10-12):`profiles/web/node_modules/@deepseek-ai` = **1 条**软链(穿过去可见 284 个包,
|
|
24
|
+
* 外界常把这 284 说成"284 条软链");`profiles/node_modules/@deepseek-ai` = **240 条**逐包软链,
|
|
25
|
+
* 其中 **6 条断链**(`dsh-code-runtime` / `dsh-agent-presets` / `cordis-plugin-hmr` / `dsh-settings-file` …,
|
|
26
|
+
* 这些包在 0.1.7 与 0.2 里**都不存在** ⇒ 改线时应**删除**而不是改指)。
|
|
27
|
+
*
|
|
28
|
+
* ## 归属与"唯一真源"
|
|
29
|
+
* · 形态探测(`locateScopeFarm` / `listFarmPackages` / `readPackageVersion`)**薄委托**给
|
|
30
|
+
* `scripts/lib/bootcheck-wiring.mjs` —— 那是 P1-0 建立的"目标 scope 布局"**唯一判据**,
|
|
31
|
+
* ⛔ 本文件**不再写第二份**(两套探测迟早漂移:门禁说 0.2 树可用、改线器却认不出它的布局)。
|
|
32
|
+
* · ⛔ 本文件**不 import** `scripts/lib/dsh-scope.mjs`(它在能力包 `pack.engine` 清单里,
|
|
33
|
+
* 改它 ⇒ 三个包 digest 漂移 ⇒ 必须提版/重盖章)。本工具**零 pack.engine 成员改动** ⇒ **零提版**。
|
|
34
|
+
* · 归属:`scripts/dsh-*.mjs` 在 kernel-purity 里已是 **L2(dsh 宿主运维档)** ⇒ 无需登记新条目。
|
|
35
|
+
* 依赖面刻意收窄到 **node 内建 + 同层 L1/L2 模块**,零第三方、零宿主 import。
|
|
36
|
+
*
|
|
37
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
* 三、用法(**默认 dry-run**;真写必须 `--apply --yes` 两条同时给)
|
|
39
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
* ```
|
|
41
|
+
* # 1) 只读预演(真机晋级前给用户看的计划;不改一个字节)
|
|
42
|
+
* node scripts/dsh-scope-rewire.mjs --profile web \
|
|
43
|
+
* --target-scope /tmp/dsh-preflight-0.2.0-rc.2/node_modules/@deepseek-ai
|
|
44
|
+
*
|
|
45
|
+
* # 2) 真写(晋级时、用户在场)
|
|
46
|
+
* node scripts/dsh-scope-rewire.mjs --profile web --target-scope <…/@deepseek-ai> --apply --yes
|
|
47
|
+
*
|
|
48
|
+
* # 3) 回滚(把计划里打印的"回滚命令"原样跑)
|
|
49
|
+
* node scripts/dsh-scope-rewire.mjs --rollback <备份目录> --apply --yes
|
|
50
|
+
* ```
|
|
51
|
+
* 入参:`--profile <name>`(默认 `web`)|`--all-profiles`|`--target-scope <…/@deepseek-ai>`(改线时必填)
|
|
52
|
+
* |`--dsh-home <path>`(默认真实 `~/.dsh`)|`--apply`(默认关)|`--yes`(与 `--apply` 同时才真写)
|
|
53
|
+
* |`--backup-dir <path>`(默认 `<数据目录>/backup/scope-rewire-<时间戳>`)|`--no-shared`(不改父级共享农场)
|
|
54
|
+
* |`--rollback <备份目录>`|`--json`。
|
|
55
|
+
*
|
|
56
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
* 四、判据(出口判据,全部机器可判;⛔ 不硬编码 284/289 这类数字)
|
|
58
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
* | # | 判据 | 通过条件 |
|
|
60
|
+
* |---|---|---|
|
|
61
|
+
* | V1 | **零断链** | 农场里没有任何"lstat 是软链 ∧ 目标不存在"的条目 |
|
|
62
|
+
* | V2 | 逐包命中目标 | 目标 scope 提供的**每一个**包,在农场里都可解析且 `realpath` 落在目标农场内 |
|
|
63
|
+
* | V3 | 无外指 | 农场里没有软链指向目标农场**之外** |
|
|
64
|
+
* | V4 | **集合一致** | 农场非点条目集合 **==** 目标 scope 包集合("与目标 scope 实际提供的包集合一致",不写死数字) |
|
|
65
|
+
* | V5 | 解析命中 | 从 **profile 目录** `require.resolve('@deepseek-ai/dsh-base')` ⇒ 落在目标农场内,且版本 == 目标 scope 的 `dsh-base` |
|
|
66
|
+
* 任一条不绿 ⇒ 执行失败 ⇒ **自动回滚到备份**并**非零退出**;回滚自身也要自证(V1 + 目标回到原 scope)。
|
|
67
|
+
*
|
|
68
|
+
* ## 安全红线(拒绝执行,而不是"尽力而为")
|
|
69
|
+
* · 目标 scope 三种布局都探不到探针包 / 包集合为空 ⇒ **拒绝**(dry-run 也拒绝:计划无从谈起)。
|
|
70
|
+
* · 农场里有**真目录/真文件**挡在目标包名上(`目录式`农场)⇒ **拒绝**并给手工出路
|
|
71
|
+
* (⛔ 不递归删、⛔ 不 rm -rf:删一个真目录 = 丢数据,不在本工具职责内)。
|
|
72
|
+
* · 任一农场/备份目录落在 `--dsh-home` **之外** ⇒ 拒绝(防"临时 home 演练写进真实 home")。
|
|
73
|
+
* · 已同源(集合与目标一致且逐条命中)⇒ **零动作、零写入**(连备份目录都不建),打印「已同源,跳过」。
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
import { createHash } from 'node:crypto'
|
|
77
|
+
import {
|
|
78
|
+
existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync,
|
|
79
|
+
realpathSync, symlinkSync, unlinkSync, writeFileSync,
|
|
80
|
+
} from 'node:fs'
|
|
81
|
+
import { homedir } from 'node:os'
|
|
82
|
+
import { dirname, join, resolve } from 'node:path'
|
|
83
|
+
import { createRequire } from 'node:module'
|
|
84
|
+
import { fileURLToPath } from 'node:url'
|
|
85
|
+
|
|
86
|
+
import { listFarmPackages, locateScopeFarm, readPackageVersion, SCOPE_PROBE_PKGS } from './lib/bootcheck-wiring.mjs'
|
|
87
|
+
import { resolveDataDir } from '../core/lib/argent-datadir.mjs'
|
|
88
|
+
|
|
89
|
+
export const TOOL = 'scripts/dsh-scope-rewire.mjs'
|
|
90
|
+
/** 备份格式版本(备份文件是**回滚的唯一依据**,格式变了必须能认出来)。 */
|
|
91
|
+
export const BACKUP_FORMAT = 1
|
|
92
|
+
/** 探测包:判 V5「解析命中」用(与 bootcheck 同一份清单,⛔ 不另写)。 */
|
|
93
|
+
export const PROBE_PKG = 'dsh-base'
|
|
94
|
+
|
|
95
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** lstat 类型(**不跟随**软链:这正是备份要记的"条目真身")。 */
|
|
98
|
+
export function lstatKind(p) {
|
|
99
|
+
try {
|
|
100
|
+
const s = lstatSync(p)
|
|
101
|
+
return s.isSymbolicLink() ? 'symlink' : s.isDirectory() ? 'dir' : 'file'
|
|
102
|
+
} catch { return 'missing' }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const realOrNull = (p) => { try { return realpathSync(p) } catch { return null } }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `p` 是否在 `root` 之内。
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ 有两种比法,**用错会假红/假绿**(本工具实测第一版就假红过):
|
|
111
|
+
* · `follow:false`(**词法**,默认)—— 农场路径必须用它:农场**本身就是一条软链**,
|
|
112
|
+
* 它的 realpath 天然在 dsh-home **之外**(指向全局 scope),拿 realpath 比会把
|
|
113
|
+
* `profiles/web/node_modules/@deepseek-ai` 判成"越界"⇒ 正确动作被拒。判的是"条目挂在哪"。
|
|
114
|
+
* · `follow:true`(**取最深的已存在祖先再 realpath**)—— 写目标(备份目录)用它:路径可能还不存在,
|
|
115
|
+
* 但要防"父级是软链、实际写到别处"(`~/.dsh/argent -> /elsewhere`)⇒ 判的是"字节落在哪"。
|
|
116
|
+
*/
|
|
117
|
+
export function isInside(p, root, { follow = false } = {}) {
|
|
118
|
+
const r = resolve(root)
|
|
119
|
+
const a = resolve(p)
|
|
120
|
+
if (!follow) return a === r || a.startsWith(r + '/')
|
|
121
|
+
let probe = a
|
|
122
|
+
while (probe !== dirname(probe) && !existsSync(probe)) probe = dirname(probe)
|
|
123
|
+
const pr = realOrNull(probe) || probe
|
|
124
|
+
const rr = realOrNull(r) || r
|
|
125
|
+
return pr === rr || pr.startsWith(rr + '/')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 取一个尚未被占用的目录名(`base`、`base-2`、`base-3`…)—— 备份**绝不覆盖**既有备份。 */
|
|
129
|
+
export function uniqueDir(base) {
|
|
130
|
+
if (!existsSync(base)) return base
|
|
131
|
+
for (let i = 2; i < 1000; i++) {
|
|
132
|
+
const c = `${base}-${i}`
|
|
133
|
+
if (!existsSync(c)) return c
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`备份目录名连续 1000 个都被占用:${base}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** shell 单引号安全引用(回滚命令要能**原样粘贴执行**)。 */
|
|
139
|
+
export function shq(s) {
|
|
140
|
+
return `'${String(s).replace(/'/g, "'\\''")}'`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
|
144
|
+
|
|
145
|
+
/** 输出通道:`--json` 时 stdout 只留 JSON(人读文案转 stderr)。 */
|
|
146
|
+
function makeOut(json) {
|
|
147
|
+
return {
|
|
148
|
+
say: (...a) => { if (!json) console.log(...a); else console.error(...a) },
|
|
149
|
+
warn: (...a) => console.error(...a),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 目标 scope 探测 ─────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 探测目标 scope(**只读**)。布局判据薄委托 `locateScopeFarm`(与 bootcheck 同一份)。
|
|
157
|
+
* @returns {{ok:boolean, scopeDir:string, farm:string|null, layout:string|null, probe:string|null,
|
|
158
|
+
* packages:string[], packageCount:number, dshVersion:string|null, dshBaseVersion:string|null, reason:string|null}}
|
|
159
|
+
*/
|
|
160
|
+
export function probeTargetScope(scopeDir) {
|
|
161
|
+
const base = {
|
|
162
|
+
ok: false, scopeDir, farm: null, layout: null, probe: null,
|
|
163
|
+
packages: [], packageCount: 0, dshVersion: null, dshBaseVersion: null, reason: null,
|
|
164
|
+
}
|
|
165
|
+
if (!scopeDir) return { ...base, reason: '未给 --target-scope' }
|
|
166
|
+
if (!existsSync(scopeDir)) return { ...base, reason: `目标 scope 不存在:${scopeDir}` }
|
|
167
|
+
const found = locateScopeFarm(scopeDir)
|
|
168
|
+
if (!found) {
|
|
169
|
+
return { ...base, reason: `目标 scope 里三种布局(平铺/前缀式/嵌套)都探不到 ${SCOPE_PROBE_PKGS.join(' / ')} 的 package.json ⇒ 不可用:${scopeDir}` }
|
|
170
|
+
}
|
|
171
|
+
const packages = listFarmPackages(found.farm)
|
|
172
|
+
if (packages.length === 0) return { ...base, farm: found.farm, layout: found.layout, reason: `目标 scope 农场为空(0 个包):${found.farm}` }
|
|
173
|
+
return {
|
|
174
|
+
...base,
|
|
175
|
+
ok: true,
|
|
176
|
+
farm: realOrNull(found.farm) || found.farm,
|
|
177
|
+
layout: found.layout,
|
|
178
|
+
probe: found.probe,
|
|
179
|
+
packages,
|
|
180
|
+
packageCount: packages.length,
|
|
181
|
+
dshVersion: readPackageVersion(join(found.farm, 'dsh')),
|
|
182
|
+
dshBaseVersion: readPackageVersion(join(found.farm, PROBE_PKG)),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── 农场快照(改线前 / 改线后 / 回滚后都用它,所以"逐条一致"可机器比对)────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 快照一个农场(**只读**,不跟随软链)。
|
|
190
|
+
* @returns {{farm:string, kind:'symlink'|'dir'|'missing', linkTarget:string|null, real:string|null,
|
|
191
|
+
* entries:Array<{name:string, kind:string, readlink:string|null, resolves:boolean, real:string|null, dot:boolean}>}}
|
|
192
|
+
*/
|
|
193
|
+
export function snapshotFarm(farmPath) {
|
|
194
|
+
const kind = lstatKind(farmPath)
|
|
195
|
+
const out = { farm: farmPath, kind, linkTarget: null, real: realOrNull(farmPath), entries: [] }
|
|
196
|
+
if (kind === 'missing') return out
|
|
197
|
+
if (kind === 'symlink') {
|
|
198
|
+
out.linkTarget = (() => { try { return readlinkSync(farmPath) } catch { return null } })()
|
|
199
|
+
out.entries = [{ name: '.', kind: 'symlink', readlink: out.linkTarget, resolves: existsSync(farmPath), real: out.real, dot: false }]
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
for (const name of readdirSync(farmPath).sort()) {
|
|
203
|
+
const p = join(farmPath, name)
|
|
204
|
+
const k = lstatKind(p)
|
|
205
|
+
const readlink = k === 'symlink' ? (() => { try { return readlinkSync(p) } catch { return null } })() : null
|
|
206
|
+
out.entries.push({ name, kind: k, readlink, resolves: existsSync(p), real: realOrNull(p), dot: name.startsWith('.') })
|
|
207
|
+
}
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 断链条目(lstat 软链 ∧ 目标不存在)—— 判据 V1 的输入。 */
|
|
212
|
+
export function brokenEntries(snap) {
|
|
213
|
+
if (snap.kind === 'symlink') return snap.entries.filter((e) => e.kind === 'symlink' && !e.resolves).map((e) => e.name === '.' ? `${snap.farm}(整树软链)` : e.name)
|
|
214
|
+
return snap.entries.filter((e) => e.kind === 'symlink' && !e.resolves).map((e) => e.name)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 备份条目里的断链数(回滚自证的**基准**:改线前既存的死链也必须原样回来)。 */
|
|
218
|
+
export function brokenCountOf(entries) {
|
|
219
|
+
return entries.filter((e) => e.kind === 'symlink' && e.resolves === false).length
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 现役目标分布(逐条 readlink 的**父目录**计数)—— dry-run 计划里给人看的"现在指向谁"。 */
|
|
223
|
+
export function targetDistribution(snap) {
|
|
224
|
+
const dist = new Map()
|
|
225
|
+
for (const e of snap.entries) {
|
|
226
|
+
if (e.kind !== 'symlink' || !e.readlink) continue
|
|
227
|
+
const parent = e.readlink.endsWith('/') ? e.readlink.slice(0, -1) : dirname(e.readlink)
|
|
228
|
+
dist.set(parent, (dist.get(parent) || 0) + 1)
|
|
229
|
+
}
|
|
230
|
+
return [...dist.entries()].map(([dir, count]) => ({ dir, count })).sort((a, b) => b.count - a.count)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── 改线计划(**纯数据,不落盘**)──────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 生成一个农场的改线计划(纯函数:只读快照 + 目标探测 ⇒ 动作清单)。
|
|
237
|
+
* @returns {{farm:string, kind:string, sameSource:boolean, actions:Array, blockers:string[],
|
|
238
|
+
* counts:{relink:number,create:number,remove:number,skip:number}, brokenBefore:string[],
|
|
239
|
+
* staleBefore:string[], dotKept:string[]}}
|
|
240
|
+
*/
|
|
241
|
+
export function planFarmRewire(snap, target) {
|
|
242
|
+
const actions = []
|
|
243
|
+
const blockers = []
|
|
244
|
+
const dotKept = snap.entries.filter((e) => e.dot).map((e) => e.name)
|
|
245
|
+
const targetSet = new Set(target.packages)
|
|
246
|
+
const targetReal = target.farm
|
|
247
|
+
|
|
248
|
+
// 形态①:整树软链 —— 动作 = 换掉这**一个**软链(同源则零动作)
|
|
249
|
+
if (snap.kind === 'symlink' || snap.kind === 'missing') {
|
|
250
|
+
const sameSource = snap.kind === 'symlink' && snap.real === targetReal
|
|
251
|
+
if (!sameSource) {
|
|
252
|
+
actions.push({ op: 'relink-scope', at: snap.farm, from: snap.linkTarget, to: targetReal })
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
farm: snap.farm, kind: snap.kind, sameSource, actions, blockers,
|
|
256
|
+
counts: { relink: actions.length, create: 0, remove: 0, skip: sameSource ? 1 : 0 },
|
|
257
|
+
brokenBefore: brokenEntries(snap), staleBefore: [], dotKept,
|
|
258
|
+
entryCount: snap.entries.length,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 形态②:真目录 + 逐包条目
|
|
263
|
+
const have = new Map(snap.entries.filter((e) => !e.dot).map((e) => [e.name, e]))
|
|
264
|
+
const stale = [] // 农场有、目标没有 ⇒ 该清(仅清**软链**)
|
|
265
|
+
for (const [name, e] of have) {
|
|
266
|
+
if (targetSet.has(name)) continue
|
|
267
|
+
if (e.kind === 'symlink') { stale.push(name); actions.push({ op: 'remove', entry: name, at: join(snap.farm, name), was: e.readlink }) }
|
|
268
|
+
else {
|
|
269
|
+
blockers.push(`${join(snap.farm, name)} 是真 ${e.kind === 'dir' ? '目录' : '文件'},且不在目标 scope 的包集合里 —— ⛔ 本工具不删真目录/真文件(那会丢数据);请人工确认后移走`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let relink = 0; let create = 0; let skip = 0
|
|
273
|
+
for (const pkg of target.packages) {
|
|
274
|
+
const e = have.get(pkg)
|
|
275
|
+
const to = join(targetReal, pkg)
|
|
276
|
+
if (!e) { create++; actions.push({ op: 'create', entry: pkg, at: join(snap.farm, pkg), to }); continue }
|
|
277
|
+
if (e.kind === 'symlink' && e.real === realOrNull(to)) { skip++; continue }
|
|
278
|
+
if (e.kind === 'symlink') { relink++; actions.push({ op: 'relink', entry: pkg, at: join(snap.farm, pkg), from: e.readlink, to }); continue }
|
|
279
|
+
blockers.push(`${join(snap.farm, pkg)} 是真 ${e.kind === 'dir' ? '目录' : '文件'}(**目录式**农场)—— ⛔ 本工具不删真目录(会丢数据);请人工确认后移走,或改用 --target-scope 之外的迁法`)
|
|
280
|
+
}
|
|
281
|
+
const sameSource = blockers.length === 0 && stale.length === 0 && relink === 0 && create === 0
|
|
282
|
+
return {
|
|
283
|
+
farm: snap.farm, kind: snap.kind, sameSource, actions, blockers,
|
|
284
|
+
counts: { relink, create, remove: stale.length, skip },
|
|
285
|
+
brokenBefore: brokenEntries(snap), staleBefore: stale, dotKept,
|
|
286
|
+
entryCount: snap.entries.length,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── 落盘:备份 / 改线 / 回滚 ────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/** 备份一个农场:写 JSON + **读回自证**(条数与逐条 `name/kind/readlink` 一致)。 */
|
|
293
|
+
function backupFarm(backupDir, slug, profileName, shared, snap, target) {
|
|
294
|
+
mkdirSync(backupDir, { recursive: true })
|
|
295
|
+
const file = join(backupDir, `${slug}.json`)
|
|
296
|
+
const doc = {
|
|
297
|
+
format: BACKUP_FORMAT,
|
|
298
|
+
takenAt: new Date().toISOString(),
|
|
299
|
+
profile: profileName,
|
|
300
|
+
shared: !!shared,
|
|
301
|
+
farm: snap.farm,
|
|
302
|
+
kind: snap.kind,
|
|
303
|
+
linkTarget: snap.linkTarget,
|
|
304
|
+
entryCount: snap.entries.length,
|
|
305
|
+
targetScope: target.farm,
|
|
306
|
+
targetDshVersion: target.dshVersion,
|
|
307
|
+
entries: snap.entries.map((e) => ({ name: e.name, kind: e.kind, readlink: e.readlink, resolves: e.resolves })),
|
|
308
|
+
}
|
|
309
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`
|
|
310
|
+
writeFileSync(file, text)
|
|
311
|
+
// —— 读回自证(⛔ 不信任"写成功")——
|
|
312
|
+
const readBack = JSON.parse(readFileSync(file, 'utf8'))
|
|
313
|
+
const same = readBack.entryCount === doc.entryCount
|
|
314
|
+
&& readBack.entries.length === doc.entries.length
|
|
315
|
+
&& doc.entries.every((e, i) => {
|
|
316
|
+
const b = readBack.entries[i]
|
|
317
|
+
return b.name === e.name && b.kind === e.kind && b.readlink === e.readlink
|
|
318
|
+
})
|
|
319
|
+
if (!same) throw new Error(`备份读回不一致:${file}(写入 ${doc.entryCount} 条,读回 ${readBack.entryCount} 条)`)
|
|
320
|
+
return { file, doc, sha256: sha256(text), entryCount: doc.entryCount }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 落盘改线计划(只做计划里的动作;真实 fs)。 */
|
|
324
|
+
export function applyFarmRewire(plan) {
|
|
325
|
+
const done = []
|
|
326
|
+
if (plan.kind === 'symlink' || plan.kind === 'missing') {
|
|
327
|
+
for (const a of plan.actions) {
|
|
328
|
+
if (a.op !== 'relink-scope') continue
|
|
329
|
+
if (lstatKind(a.at) === 'symlink') unlinkSync(a.at)
|
|
330
|
+
else if (existsSync(a.at)) throw new Error(`拒绝覆盖非软链条目:${a.at}(这是真目录/真文件)`)
|
|
331
|
+
mkdirSync(dirname(a.at), { recursive: true })
|
|
332
|
+
symlinkSync(a.to, a.at)
|
|
333
|
+
done.push(a)
|
|
334
|
+
}
|
|
335
|
+
return done
|
|
336
|
+
}
|
|
337
|
+
if (!existsSync(plan.farm)) mkdirSync(plan.farm, { recursive: true })
|
|
338
|
+
for (const a of plan.actions) {
|
|
339
|
+
if (a.op === 'remove') {
|
|
340
|
+
if (lstatKind(a.at) === 'symlink') { unlinkSync(a.at); done.push(a) }
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (a.op === 'relink' || a.op === 'create') {
|
|
344
|
+
const k = lstatKind(a.at)
|
|
345
|
+
if (k === 'symlink') unlinkSync(a.at)
|
|
346
|
+
else if (k !== 'missing') throw new Error(`拒绝覆盖非软链条目:${a.at}(这是真 ${k})`)
|
|
347
|
+
symlinkSync(a.to, a.at)
|
|
348
|
+
done.push(a)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return done
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 从备份恢复一个农场(**逐条按记录的 readlink 字面恢复**,不重新推导)。 */
|
|
355
|
+
export function rollbackFarm(backupDoc) {
|
|
356
|
+
const { farm, kind, linkTarget, entries } = backupDoc
|
|
357
|
+
const done = []
|
|
358
|
+
if (kind === 'missing') return done
|
|
359
|
+
if (kind === 'symlink') {
|
|
360
|
+
const k = lstatKind(farm)
|
|
361
|
+
if (k === 'symlink') unlinkSync(farm)
|
|
362
|
+
else if (k !== 'missing') throw new Error(`回滚拒绝覆盖非软链条目:${farm}(这是真 ${k})`)
|
|
363
|
+
mkdirSync(dirname(farm), { recursive: true })
|
|
364
|
+
symlinkSync(linkTarget, farm)
|
|
365
|
+
return [{ op: 'restore-scope-link', at: farm, to: linkTarget }]
|
|
366
|
+
}
|
|
367
|
+
// 真目录农场:先清掉**我们自己造出来的**软链条目,再按记录重建
|
|
368
|
+
if (!existsSync(farm)) mkdirSync(farm, { recursive: true })
|
|
369
|
+
for (const name of readdirSync(farm)) {
|
|
370
|
+
const p = join(farm, name)
|
|
371
|
+
const k = lstatKind(p)
|
|
372
|
+
if (k === 'symlink') { unlinkSync(p); done.push({ op: 'clear', at: p }) }
|
|
373
|
+
}
|
|
374
|
+
for (const e of entries) {
|
|
375
|
+
if (e.name === '.' || e.kind !== 'symlink') continue // 真目录/真文件本工具从不删 ⇒ 仍在原地
|
|
376
|
+
symlinkSync(e.readlink, join(farm, e.name))
|
|
377
|
+
done.push({ op: 'restore', at: join(farm, e.name), to: e.readlink })
|
|
378
|
+
}
|
|
379
|
+
return done
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── 校验(判据 V1–V5)──────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
/** 快照 ↔ 备份**逐条一致**(回滚自证;含 lstat 类型与 readlink 字面)。 */
|
|
385
|
+
export function entriesIdentical(snap, backupEntries) {
|
|
386
|
+
const a = snap.entries.map((e) => ({ name: e.name, kind: e.kind, readlink: e.readlink }))
|
|
387
|
+
const b = backupEntries.map((e) => ({ name: e.name, kind: e.kind, readlink: e.readlink }))
|
|
388
|
+
if (a.length !== b.length) return { ok: false, why: `条数不同:回滚后 ${a.length} / 备份 ${b.length}` }
|
|
389
|
+
for (let i = 0; i < a.length; i++) {
|
|
390
|
+
if (a[i].name !== b[i].name || a[i].kind !== b[i].kind || a[i].readlink !== b[i].readlink) {
|
|
391
|
+
return { ok: false, why: `第 ${i + 1} 条不同:回滚后 ${JSON.stringify(a[i])} / 备份 ${JSON.stringify(b[i])}` }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, why: `逐条一致(${a.length}/${b.length})` }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 农场级判据 V1–V4(V5 是 profile 级的,见 {@link verifyProfileResolution})。
|
|
399
|
+
* @returns {{ok:boolean, checks:Array<{id:string, ok:boolean, detail:string}>}}
|
|
400
|
+
*/
|
|
401
|
+
export function verifyFarm(farmPath, target) {
|
|
402
|
+
const snap = snapshotFarm(farmPath)
|
|
403
|
+
const checks = []
|
|
404
|
+
const push = (id, ok, detail) => checks.push({ id, ok, detail })
|
|
405
|
+
|
|
406
|
+
const broken = brokenEntries(snap)
|
|
407
|
+
push('V1', broken.length === 0, broken.length === 0 ? '零断链' : `${broken.length} 条断链:${broken.slice(0, 8).join(', ')}`)
|
|
408
|
+
|
|
409
|
+
const dotEntries = snap.entries.filter((e) => e.dot)
|
|
410
|
+
const list = snap.kind === 'symlink' ? [] : snap.entries.filter((e) => !e.dot)
|
|
411
|
+
// ⚠️ **整树软链**农场的"条目集合"不是那条软链自己(只有 1 个 `.`),而是**透过它可见的包集合**
|
|
412
|
+
// —— 本工具第一版在这里判错过(把 `['.']` 与目标包集合比 ⇒ 正确动作被自己的校验打红)。
|
|
413
|
+
const names = snap.kind === 'symlink' ? listFarmPackages(farmPath) : list.map((e) => e.name)
|
|
414
|
+
const targetSet = new Set(target.packages)
|
|
415
|
+
|
|
416
|
+
// V2:目标 scope 的每一个包,在农场里都可解析且 realpath 落在目标农场内
|
|
417
|
+
const missPkg = []
|
|
418
|
+
for (const pkg of target.packages) {
|
|
419
|
+
const p = join(farmPath, pkg)
|
|
420
|
+
if (!existsSync(p)) { missPkg.push(pkg); continue }
|
|
421
|
+
const r = realOrNull(p)
|
|
422
|
+
if (!(r === target.farm || (r || '').startsWith(target.farm + '/'))) missPkg.push(`${pkg}(⇒${r})`)
|
|
423
|
+
}
|
|
424
|
+
push('V2', missPkg.length === 0, missPkg.length === 0
|
|
425
|
+
? `目标 scope 的 ${target.packages.length} 个包逐个可解析且落在目标农场内`
|
|
426
|
+
: `${missPkg.length}/${target.packages.length} 个包没落到目标农场:${missPkg.slice(0, 8).join(', ')}`)
|
|
427
|
+
|
|
428
|
+
// V3:无外指软链
|
|
429
|
+
const outside = []
|
|
430
|
+
for (const e of list) {
|
|
431
|
+
if (e.kind !== 'symlink' || !e.readlink) continue
|
|
432
|
+
const r = realOrNull(join(farmPath, e.name))
|
|
433
|
+
if (!(r === target.farm || (r || '').startsWith(target.farm + '/'))) outside.push(`${e.name} ⇒ ${r}`)
|
|
434
|
+
}
|
|
435
|
+
push('V3', outside.length === 0, outside.length === 0 ? '无指向目标农场之外的软链' : `${outside.length} 条外指:${outside.slice(0, 8).join(', ')}`)
|
|
436
|
+
|
|
437
|
+
// V4:集合一致(非点条目 == 目标包集合)—— ⛔ 用判据,不硬编码包数
|
|
438
|
+
const onlyFarm = names.filter((n) => !targetSet.has(n))
|
|
439
|
+
const onlyTarget = target.packages.filter((n) => !names.includes(n))
|
|
440
|
+
const setOk = onlyFarm.length === 0 && onlyTarget.length === 0
|
|
441
|
+
push('V4', setOk, setOk
|
|
442
|
+
? `农场条目集合 == 目标 scope 包集合(${names.length} 个${dotEntries.length ? `;另有 ${dotEntries.length} 个点条目原样保留` : ''})`
|
|
443
|
+
: `集合不一致 ⇒ 农场多 ${onlyFarm.length} 个(${onlyFarm.slice(0, 6).join(', ')})、少 ${onlyTarget.length} 个(${onlyTarget.slice(0, 6).join(', ')})`)
|
|
444
|
+
|
|
445
|
+
return { ok: checks.every((c) => c.ok), checks, snap }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* profile 级判据 V5:从**该 profile 目录**解析 `@deepseek-ai/dsh-base` ⇒ 必须落在目标农场内,
|
|
450
|
+
* 且版本 == 目标 scope 的那份(这就是审计 §C.2 A.2 的判据②)。
|
|
451
|
+
*/
|
|
452
|
+
export function verifyProfileResolution(profileDir, target) {
|
|
453
|
+
let resolved = null
|
|
454
|
+
let why = ''
|
|
455
|
+
try {
|
|
456
|
+
const req = createRequire(join(profileDir, '__scope_rewire_probe__.cjs'))
|
|
457
|
+
resolved = realOrNull(req.resolve(`@deepseek-ai/${PROBE_PKG}`))
|
|
458
|
+
} catch (e) {
|
|
459
|
+
why = `解析失败:${e && e.code ? e.code : e && e.message}`
|
|
460
|
+
}
|
|
461
|
+
if (!resolved) return { ok: false, resolved: null, version: null, why: why || '解析不到' }
|
|
462
|
+
const inTarget = resolved === target.farm || resolved.startsWith(target.farm + '/')
|
|
463
|
+
const version = readPackageVersion(dirname(dirname(resolved))) || readPackageVersion(join(target.farm, PROBE_PKG))
|
|
464
|
+
const verOk = !target.dshBaseVersion || version === target.dshBaseVersion
|
|
465
|
+
return {
|
|
466
|
+
ok: inTarget && verOk,
|
|
467
|
+
resolved,
|
|
468
|
+
version,
|
|
469
|
+
why: inTarget
|
|
470
|
+
? `@deepseek-ai/${PROBE_PKG} ⇒ ${resolved}(在目标 scope 内;版本 ${version}${verOk ? ' == 目标' : ` ≠ 目标 ${target.dshBaseVersion}`})`
|
|
471
|
+
: `@deepseek-ai/${PROBE_PKG} ⇒ ${resolved},**不在**目标 scope ${target.farm} 内 ⇒ 农场未接好`,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── 农场发现 ────────────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
/** 列 `<dshHome>/profiles` 下的 profile 目录名(**只读**)。 */
|
|
478
|
+
export function listProfiles(dshHome) {
|
|
479
|
+
const pdir = join(dshHome, 'profiles')
|
|
480
|
+
if (!existsSync(pdir)) return []
|
|
481
|
+
return readdirSync(pdir).filter((n) => {
|
|
482
|
+
if (n === 'node_modules' || n.startsWith('.')) return false
|
|
483
|
+
try { return lstatSync(join(pdir, n)).isDirectory() } catch { return false }
|
|
484
|
+
}).sort()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 发现要改线的农场。**共享农场**(`profiles/node_modules/@deepseek-ai`)默认一起改:
|
|
489
|
+
* 没有自有农场的 profile(headless/tui…)沿 node 解析向上走到它,只改自有农场等于没改。
|
|
490
|
+
*/
|
|
491
|
+
export function discoverFarms({ dshHome, profile, allProfiles, includeShared = true }) {
|
|
492
|
+
const farms = []
|
|
493
|
+
const profiles = allProfiles ? listProfiles(dshHome) : [profile]
|
|
494
|
+
const seen = new Set()
|
|
495
|
+
const add = (f) => { if (!f.farm || seen.has(f.farm)) return; seen.add(f.farm); farms.push(f) }
|
|
496
|
+
for (const p of profiles) {
|
|
497
|
+
add({ profile: p, farm: join(dshHome, 'profiles', p, 'node_modules', '@deepseek-ai'), shared: false })
|
|
498
|
+
}
|
|
499
|
+
const sharedFarm = join(dshHome, 'profiles', 'node_modules', '@deepseek-ai')
|
|
500
|
+
if (includeShared && (existsSync(sharedFarm) || lstatKind(sharedFarm) === 'symlink')) {
|
|
501
|
+
add({ profile: allProfiles ? '(all)' : profiles[0], farm: sharedFarm, shared: true })
|
|
502
|
+
}
|
|
503
|
+
// 只留"农场真的存在"的(不存在的自有农场:报"走共享农场",不建)
|
|
504
|
+
return farms.map((f) => ({ ...f, exists: existsSync(f.farm) || lstatKind(f.farm) === 'symlink' }))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── 命令行 ──────────────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
export function parseArgs(argv) {
|
|
510
|
+
const has = (f) => argv.includes(f)
|
|
511
|
+
const val = (f) => { const i = argv.indexOf(f); return i > -1 ? argv[i + 1] ?? null : null }
|
|
512
|
+
const o = {
|
|
513
|
+
profile: val('--profile') || 'web',
|
|
514
|
+
allProfiles: has('--all-profiles'),
|
|
515
|
+
targetScope: val('--target-scope'),
|
|
516
|
+
dshHome: val('--dsh-home'),
|
|
517
|
+
apply: has('--apply'),
|
|
518
|
+
yes: has('--yes'),
|
|
519
|
+
backupDir: val('--backup-dir'),
|
|
520
|
+
includeShared: !has('--no-shared'),
|
|
521
|
+
rollback: val('--rollback'),
|
|
522
|
+
json: has('--json'),
|
|
523
|
+
help: has('--help') || has('-h'),
|
|
524
|
+
}
|
|
525
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-')
|
|
526
|
+
o.timestamp = stamp
|
|
527
|
+
return o
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const USAGE = `用法:
|
|
531
|
+
node ${TOOL} --profile web --target-scope <…/@deepseek-ai> # dry-run(默认,只打印计划)
|
|
532
|
+
node ${TOOL} --profile web --target-scope <…> --apply --yes # 真写(缺 --yes ⇒ 拒绝)
|
|
533
|
+
node ${TOOL} --all-profiles --target-scope <…> [--apply --yes]
|
|
534
|
+
node ${TOOL} --rollback <备份目录> [--apply --yes]
|
|
535
|
+
选项:--dsh-home <path>(默认 ~/.dsh)|--backup-dir <path>|--no-shared|--json`
|
|
536
|
+
|
|
537
|
+
/** 主流程(可被测试 import 调用;返回 {exitCode, report})。 */
|
|
538
|
+
export function run(argv = process.argv.slice(2), { cwd = process.cwd() } = {}) {
|
|
539
|
+
const opts = parseArgs(argv)
|
|
540
|
+
const out = makeOut(opts.json)
|
|
541
|
+
const report = { tool: TOOL, mode: null, ok: false, refusals: [], warnings: [] }
|
|
542
|
+
const refuse = (msg, code = 2) => {
|
|
543
|
+
report.refusals.push(msg)
|
|
544
|
+
report.ok = false
|
|
545
|
+
out.warn(` ✗ 拒绝执行:${msg}`)
|
|
546
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
547
|
+
return { exitCode: code, report }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (opts.help) { console.log(USAGE); return { exitCode: 0, report: { ...report, ok: true, help: true } } }
|
|
551
|
+
|
|
552
|
+
// ── dsh-home + 数据目录(⛔ 延迟求值:`--dsh-home` 解析**之后**才算路径)──────
|
|
553
|
+
const dshHome = opts.dshHome ? resolve(opts.dshHome) : join(homedir(), '.dsh')
|
|
554
|
+
report.dshHome = dshHome
|
|
555
|
+
if (!existsSync(dshHome)) return refuse(`--dsh-home 不存在:${dshHome}`)
|
|
556
|
+
|
|
557
|
+
const env = { ...process.env }
|
|
558
|
+
if (opts.dshHome) env.DSH_HOME = dshHome // 让备份默认落点跟着**隔离 home** 走(内核唯一判据)
|
|
559
|
+
const dataDir = resolveDataDir({ env, home: dshHome }).dir
|
|
560
|
+
// ⚠️ 默认备份目录必须**唯一**:秒级时间戳在同一秒内跑两次会撞,而"⛔ 不覆盖既有备份"的守卫
|
|
561
|
+
// 会把第二次运行直接拒掉(实测踩到:回滚后立刻重跑改线 ⇒ exit 1)。⇒ 在**计划期**就取一个空位,
|
|
562
|
+
// 这样打印出来的回滚命令与实际落点始终一致。
|
|
563
|
+
const backupDir = opts.backupDir
|
|
564
|
+
? resolve(opts.backupDir)
|
|
565
|
+
: uniqueDir(join(dataDir, 'backup', `scope-rewire-${opts.timestamp}`))
|
|
566
|
+
report.dataDir = dataDir
|
|
567
|
+
report.backupDir = backupDir
|
|
568
|
+
|
|
569
|
+
// 隔离红线:`--dsh-home` 是隔离 home 时,备份**不得**落到它外面(取 realpath 比:防父级软链绕出去)
|
|
570
|
+
if (opts.dshHome && !isInside(backupDir, dshHome, { follow: true })) {
|
|
571
|
+
return refuse(`备份目录 ${backupDir} 落在 --dsh-home(${dshHome})之外 ⇒ 违反隔离;请显式给 --backup-dir,或清掉 ARGENT_DATA_DIR`)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ══ 回滚模式 ═════════════════════════════════════════════════════════════
|
|
575
|
+
if (opts.rollback) {
|
|
576
|
+
report.mode = 'rollback'
|
|
577
|
+
const dir = resolve(opts.rollback)
|
|
578
|
+
const manifestPath = join(dir, 'manifest.json')
|
|
579
|
+
if (!existsSync(manifestPath)) return refuse(`回滚需要 ${manifestPath}(本工具的备份目录里有它)`)
|
|
580
|
+
let manifest
|
|
581
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch (e) { return refuse(`manifest 解析失败:${e.message}`) }
|
|
582
|
+
if (manifest.format !== BACKUP_FORMAT) return refuse(`备份格式版本不符(清单 ${manifest.format} / 本工具 ${BACKUP_FORMAT})`)
|
|
583
|
+
report.rollback = { backupDir: dir, farms: manifest.farms }
|
|
584
|
+
out.say(`\n══ 回滚:${dir} ══`)
|
|
585
|
+
out.say(` 备份时间:${manifest.takenAt}|源 dsh-home:${manifest.dshHome}|改线目标:${manifest.targetScope || '(未知)'}`)
|
|
586
|
+
if (!opts.apply) {
|
|
587
|
+
out.say(' ⓘ dry-run(未给 --apply):下面只列将恢复的农场,一个字节都不写')
|
|
588
|
+
for (const f of manifest.farms) out.say(` · ${f.farm}(${f.entryCount} 条,${f.kind})`)
|
|
589
|
+
report.ok = true
|
|
590
|
+
report.planOnly = true
|
|
591
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
592
|
+
return { exitCode: 0, report }
|
|
593
|
+
}
|
|
594
|
+
if (!opts.yes) return refuse('回滚要真写 ⇒ 必须同时给 --apply 与 --yes(缺 --yes 一律不写)')
|
|
595
|
+
const results = []
|
|
596
|
+
for (const f of manifest.farms) {
|
|
597
|
+
const doc = JSON.parse(readFileSync(f.backupFile, 'utf8'))
|
|
598
|
+
out.say(`\n── 恢复 ${f.farm}`)
|
|
599
|
+
try { rollbackFarm(doc) } catch (e) { return refuse(`回滚失败(${f.farm}):${e.message}`, 1) }
|
|
600
|
+
const snap = snapshotFarm(f.farm)
|
|
601
|
+
const ident = entriesIdentical(snap, doc.entries)
|
|
602
|
+
const broken = brokenEntries(snap)
|
|
603
|
+
const brokenBefore = brokenCountOf(doc.entries)
|
|
604
|
+
const brokenOk = broken.length === brokenBefore
|
|
605
|
+
const ok = ident.ok && brokenOk
|
|
606
|
+
const note = brokenBefore === 0
|
|
607
|
+
? '0 断链 ✅'
|
|
608
|
+
: `断链数 ${broken.length} == 改线前既存的 ${brokenBefore} 条 ${brokenOk ? '✅(历史死链,回滚**原样**保留才是对的)' : '❌'}`
|
|
609
|
+
out.say(` ${ok ? '✅' : '❌'} 回滚自证:${ident.why}|${note}`)
|
|
610
|
+
results.push({ farm: f.farm, ok, identical: ident, broken, brokenBefore })
|
|
611
|
+
if (!ok) { report.ok = false; report.rollbackResults = results; return { exitCode: 1, report } }
|
|
612
|
+
}
|
|
613
|
+
report.ok = true
|
|
614
|
+
report.rollbackResults = results
|
|
615
|
+
out.say('\n✅ 回滚完成且自证通过(**逐条回到备份** + 断链数与改线前一致)')
|
|
616
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
617
|
+
return { exitCode: 0, report }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ══ 改线模式 ═════════════════════════════════════════════════════════════
|
|
621
|
+
report.mode = opts.apply ? 'apply' : 'dry-run'
|
|
622
|
+
if (!opts.targetScope) return refuse('改线必须给 --target-scope <…/@deepseek-ai>(⛔ 本工具不自动探测目标 scope)')
|
|
623
|
+
const target = probeTargetScope(resolve(opts.targetScope))
|
|
624
|
+
report.target = target
|
|
625
|
+
if (!target.ok) return refuse(target.reason)
|
|
626
|
+
if (!existsSync(join(dshHome, 'profiles'))) return refuse(`${dshHome} 下没有 profiles/ 目录 ⇒ 不是 dsh home?`)
|
|
627
|
+
|
|
628
|
+
const farms = discoverFarms({ dshHome, profile: opts.profile, allProfiles: opts.allProfiles, includeShared: opts.includeShared })
|
|
629
|
+
if (farms.length === 0) return refuse('没有发现任何农场(profiles/*/node_modules/@deepseek-ai)')
|
|
630
|
+
for (const f of farms) {
|
|
631
|
+
if (!isInside(f.farm, dshHome)) return refuse(`农场 ${f.farm} 落在 --dsh-home(${dshHome})之外 ⇒ 拒绝`)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
out.say('\n══ dsh scope 改线计划 ══')
|
|
635
|
+
out.say(` 模式:${opts.apply ? 'apply(真写)' : 'dry-run(只打印,不写)'}`)
|
|
636
|
+
out.say(` dsh-home:${dshHome}${opts.dshHome ? '' : '(真实 home)'}`)
|
|
637
|
+
out.say(` 目标 scope:${target.farm}`)
|
|
638
|
+
out.say(` · 布局=${target.layout}|探针=${target.probe}|包数=${target.packageCount}|dsh=${target.dshVersion}|dsh-base=${target.dshBaseVersion}`)
|
|
639
|
+
|
|
640
|
+
const plans = []
|
|
641
|
+
for (const f of farms) {
|
|
642
|
+
const snap = snapshotFarm(f.farm)
|
|
643
|
+
const plan = planFarmRewire(snap, target)
|
|
644
|
+
plan.profile = f.profile
|
|
645
|
+
plan.shared = f.shared
|
|
646
|
+
plan.exists = f.exists
|
|
647
|
+
plan.dist = targetDistribution(snap)
|
|
648
|
+
plan.brokenBefore = brokenEntries(snap)
|
|
649
|
+
plans.push(plan)
|
|
650
|
+
out.say(`\n── 农场(${f.shared ? '父级共享' : `profile=${f.profile}`}):${f.farm}`)
|
|
651
|
+
if (!f.exists) { out.say(' ⓘ 不存在 ⇒ 该 profile 走父级共享农场,本农场不新建'); continue }
|
|
652
|
+
out.say(` 形态:${plan.kind === 'symlink' ? '整树软链(dir-link)' : plan.kind === 'dir' ? '逐包软链(per-pkg)' : '缺失'}`)
|
|
653
|
+
out.say(` 现有链接数:${snap.entries.length}`)
|
|
654
|
+
if (plan.dist.length) {
|
|
655
|
+
out.say(` 现有目标分布(逐条 readlink 的父目录计数):`)
|
|
656
|
+
for (const d of plan.dist) out.say(` · ${d.dir} ×${d.count}`)
|
|
657
|
+
}
|
|
658
|
+
out.say(` 断链数:${plan.brokenBefore.length}${plan.brokenBefore.length ? ` ⇒ ${plan.brokenBefore.slice(0, 8).join(', ')}${plan.brokenBefore.length > 8 ? ' …' : ''}` : ''}`)
|
|
659
|
+
out.say(` 将改指 ${plan.counts.relink} 条 / 新建 ${plan.counts.create} 条 / 删除 ${plan.counts.remove} 条 / 已同源跳过 ${plan.counts.skip} 条`)
|
|
660
|
+
if (plan.dotKept.length) out.say(` 点条目原样保留(${plan.dotKept.length}):${plan.dotKept.slice(0, 6).join(', ')}`)
|
|
661
|
+
if (plan.sameSource) out.say(' ✅ 已同源,跳过(零动作、零写入)')
|
|
662
|
+
for (const b of plan.blockers) out.say(` ⛔ 阻断:${b}`)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const blockers = plans.flatMap((p) => p.blockers)
|
|
666
|
+
const writeFarms = plans.filter((p) => p.exists && !p.sameSource)
|
|
667
|
+
const totalActions = writeFarms.reduce((n, p) => n + p.actions.length, 0)
|
|
668
|
+
|
|
669
|
+
report.plans = plans.map((p) => ({
|
|
670
|
+
profile: p.profile, shared: p.shared, farm: p.farm, kind: p.kind, sameSource: p.sameSource,
|
|
671
|
+
entries: p.entries ?? undefined, counts: p.counts, brokenBefore: p.brokenBefore,
|
|
672
|
+
dist: p.dist, blockers: p.blockers, actions: p.actions,
|
|
673
|
+
entryCount: p.entryCount,
|
|
674
|
+
}))
|
|
675
|
+
report.totalActions = totalActions
|
|
676
|
+
|
|
677
|
+
const rollbackCommand = (dir) => `node ${TOOL} --dsh-home ${shq(dshHome)} --rollback ${shq(dir)} --apply --yes`
|
|
678
|
+
if (totalActions === 0) {
|
|
679
|
+
out.say('\n✅ 全部农场已同源 ⇒ **零动作、零写入**(不建备份目录)')
|
|
680
|
+
report.rollbackCommand = null
|
|
681
|
+
report.rollbackNote = '零动作 ⇒ 无需回滚'
|
|
682
|
+
} else {
|
|
683
|
+
out.say(`\n 备份目录:${backupDir}`)
|
|
684
|
+
out.say(` 回滚命令(原文,出事后原样粘贴执行):`)
|
|
685
|
+
out.say(` ${rollbackCommand(backupDir)}`)
|
|
686
|
+
report.rollbackCommand = rollbackCommand(backupDir)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (blockers.length) {
|
|
690
|
+
report.ok = false
|
|
691
|
+
out.warn(`\n ✗ 拒绝执行:有 ${blockers.length} 条阻断(真目录/真文件挡在目标包名上)—— 见上面逐条`)
|
|
692
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
693
|
+
return { exitCode: 2, report }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!opts.apply) {
|
|
697
|
+
out.say('\n ⓘ dry-run 结束:一个字节都没写。真写请加 --apply --yes')
|
|
698
|
+
report.ok = true
|
|
699
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
700
|
+
return { exitCode: 0, report }
|
|
701
|
+
}
|
|
702
|
+
if (!opts.yes) {
|
|
703
|
+
return refuse('--apply 必须与 --yes 同时给(本工具默认不写;缺 --yes 一律不写)')
|
|
704
|
+
}
|
|
705
|
+
if (totalActions === 0) {
|
|
706
|
+
report.ok = true
|
|
707
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
708
|
+
return { exitCode: 0, report }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── 真写:① 备份(读回自证)→ ② 改线 → ③ 校验 → 任一步失败 ⇒ 自动回滚 ──────
|
|
712
|
+
out.say('\n══ 执行(备份 → 改线 → 校验)══')
|
|
713
|
+
const manifest = {
|
|
714
|
+
format: BACKUP_FORMAT, takenAt: new Date().toISOString(), dshHome, backupDir,
|
|
715
|
+
targetScope: target.farm, targetDshVersion: target.dshVersion, targetPackages: target.packageCount,
|
|
716
|
+
rollbackCommand: rollbackCommand(backupDir), farms: [],
|
|
717
|
+
}
|
|
718
|
+
const doRollback = (reason) => {
|
|
719
|
+
out.warn(`\n ✗ ${reason} ⇒ 自动回滚`)
|
|
720
|
+
for (const f of manifest.farms) {
|
|
721
|
+
try {
|
|
722
|
+
const doc = JSON.parse(readFileSync(f.backupFile, 'utf8'))
|
|
723
|
+
rollbackFarm(doc)
|
|
724
|
+
const snap = snapshotFarm(f.farm)
|
|
725
|
+
const ident = entriesIdentical(snap, doc.entries)
|
|
726
|
+
const broken = brokenEntries(snap)
|
|
727
|
+
const brokenBefore = brokenCountOf(doc.entries)
|
|
728
|
+
const bOk = broken.length === brokenBefore
|
|
729
|
+
out.warn(` · ${f.farm}:${ident.ok ? '逐条一致 ✅' : `❌ ${ident.why}`}|断链数 ${broken.length} vs 改线前 ${brokenBefore}:${bOk ? '✅' : '❌'}`)
|
|
730
|
+
} catch (e) { out.warn(` · ${f.farm}:回滚异常 ${e.message}`) }
|
|
731
|
+
}
|
|
732
|
+
report.ok = false
|
|
733
|
+
report.rollbackDone = true
|
|
734
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
735
|
+
return { exitCode: 1, report }
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const verifyAll = () => {
|
|
739
|
+
const perFarm = []
|
|
740
|
+
const perProfile = []
|
|
741
|
+
for (const p of writeFarms) {
|
|
742
|
+
const v = verifyFarm(p.farm, target)
|
|
743
|
+
perFarm.push({ farm: p.farm, ok: v.ok, checks: v.checks })
|
|
744
|
+
}
|
|
745
|
+
for (const prof of (opts.allProfiles ? listProfiles(dshHome) : [opts.profile])) {
|
|
746
|
+
const dir = join(dshHome, 'profiles', prof)
|
|
747
|
+
if (!existsSync(dir)) continue
|
|
748
|
+
const v = verifyProfileResolution(dir, target)
|
|
749
|
+
perProfile.push({ profile: prof, ok: v.ok, why: v.why })
|
|
750
|
+
}
|
|
751
|
+
return { perFarm, perProfile, ok: perFarm.every((f) => f.ok) && perProfile.every((f) => f.ok) }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!opts.backupDir && existsSync(backupDir)) return refuse(`备份目录已存在(${backupDir})⇒ 换个时间戳或显式 --backup-dir(⛔ 不覆盖既有备份)`, 1)
|
|
755
|
+
mkdirSync(backupDir, { recursive: true })
|
|
756
|
+
try {
|
|
757
|
+
for (const p of writeFarms) {
|
|
758
|
+
const snap = snapshotFarm(p.farm)
|
|
759
|
+
const slug = (p.shared ? 'shared' : `profile-${p.profile}`).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
760
|
+
const b = backupFarm(backupDir, slug, p.profile, p.shared, snap, target)
|
|
761
|
+
out.say(` ✅ 备份 ${p.farm} ⇒ ${b.file}(${b.entryCount} 条,读回自证一致,sha256=${b.sha256.slice(0, 16)}…)`)
|
|
762
|
+
manifest.farms.push({ profile: p.profile, shared: p.shared, farm: p.farm, kind: snap.kind, entryCount: b.entryCount, backupFile: b.file, sha256: b.sha256 })
|
|
763
|
+
}
|
|
764
|
+
writeFileSync(join(backupDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
765
|
+
out.say(` ✅ 备份清单 ⇒ ${join(backupDir, 'manifest.json')}`)
|
|
766
|
+
} catch (e) {
|
|
767
|
+
return doRollback(`备份阶段失败:${e.message}`)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
for (const p of writeFarms) {
|
|
772
|
+
const done = applyFarmRewire(p)
|
|
773
|
+
out.say(` ✅ 改线 ${p.farm}(${done.length} 个动作)`)
|
|
774
|
+
}
|
|
775
|
+
} catch (e) {
|
|
776
|
+
return doRollback(`改线阶段失败:${e.message}`)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const verdict = verifyAll()
|
|
780
|
+
report.verification = verdict
|
|
781
|
+
out.say('\n══ 写后校验 ══')
|
|
782
|
+
for (const f of verdict.perFarm) {
|
|
783
|
+
for (const c of f.checks) out.say(` ${c.ok ? '✅' : '❌'} ${c.id} ${f.farm}\n ${c.detail}`)
|
|
784
|
+
}
|
|
785
|
+
for (const p of verdict.perProfile) out.say(` ${p.ok ? '✅' : '❌'} V5 profile=${p.profile}:${p.why}`)
|
|
786
|
+
if (!verdict.ok) return doRollback('写后校验未全绿')
|
|
787
|
+
|
|
788
|
+
report.ok = true
|
|
789
|
+
report.manifest = manifest
|
|
790
|
+
out.say('\n✅ 改线完成:判据 V1–V5 全绿。回滚命令见上(已写进备份清单)。')
|
|
791
|
+
out.say(` 回滚:${rollbackCommand(backupDir)}`)
|
|
792
|
+
if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
793
|
+
return { exitCode: 0, report }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ── 直接执行 ────────────────────────────────────────────────────────────────
|
|
797
|
+
const DIRECT = (() => {
|
|
798
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
|
|
799
|
+
})()
|
|
800
|
+
if (DIRECT) {
|
|
801
|
+
const { exitCode } = run()
|
|
802
|
+
process.exit(exitCode)
|
|
803
|
+
}
|