@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,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性激活码 —— **客户机那一半**(方案《许可自助签发与续期》§8-③ 路径 (a) 的落地;2026-10-04)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它(一句话)
|
|
5
|
+
* 客户机是 **BYO + 不登录**(没有账号 JWT)⇒ **它自己调不了签发端点**。一次性激活码让"持有这台机器的人"
|
|
6
|
+
* 用**网页/手机端登录后的身份**换到一份**绑定这台机器设备码**的许可 ⇒ 把"每加一个客户/每次续期都要我们人工"
|
|
7
|
+
* 降为"客户自助 + 我们只做账务"。协议、状态机与错误码表见方案 §9(服务端契约);本文件 = **客户端的全部实现**。
|
|
8
|
+
*
|
|
9
|
+
* ## 纪律(三条,都可判)
|
|
10
|
+
* | # | 纪律 | 落点 |
|
|
11
|
+
* |---|---|---|
|
|
12
|
+
* | ① | **判定点不在本文件**:码与权益归**服务端**判;产物能不能落盘归 `scripts/install-license.mjs` 判(验签/设备比对/0600/备份/读回/幂等/拒绝倒退) | 本文件只做"翻译 + 前置断言 + 转交",**不自己写许可文件** |
|
|
13
|
+
* | ② | **明文码零泄漏**:对外只出现 `codePrefix`(前 8 位,供人工核对);stdout/stderr/审计 JSONL 里**搜不到**明文码 | 回显由白名单字段构造(响应体**不透传**)+ 服务端给的人话文本先过 `redactActivationCode()` 再落地 + 审计只落 `codePrefix` |
|
|
14
|
+
* | ③ | **默认 dry-run(A3)**:不带 `--apply` ⇒ **零网络请求 + 零落盘**(连审计都不写);真兑换要显式确权 | `dryRun` 分支在任何 `fetch`/临时文件/子进程**之前**返回 |
|
|
15
|
+
*
|
|
16
|
+
* ## A1 / A2
|
|
17
|
+
* · A1:`0` 兑换并落盘成功(含"与现役逐字节一致 ⇒ 未改动")|`2` 用法/环境(码形态非法、**未配置端点**、
|
|
18
|
+
* `--server` 非 TLS 且非回环、设备码形状非法、读不到本机设备码)|`1` 兑换被拒(服务端错误码映射、
|
|
19
|
+
* 网络不可达/超时、产物验签不过/设备不匹配/倒退被拒 —— 后三者由 `install-license.mjs` 判)。
|
|
20
|
+
* · A2:`ok/empty/code/reason` 恒在;`$? == json.code`;`--json` 时 stdout **只有一份 JSON**;
|
|
21
|
+
* 回显**实际生效值**(`server`/`endpoint`/`codePrefix`/`request.device`/`request.target`)——**绝不含明文码**。
|
|
22
|
+
*
|
|
23
|
+
* ## 与设备绑定/v2 的关系(§3.6 是唯一权威,本文件只引用)
|
|
24
|
+
* · 上传的是 **64hex 设备码**(指纹摘要;**唯一实现在 `license-device.mjs`** —— 本文件只搬运,不碰指纹来源);
|
|
25
|
+
* **绝不**上传主机名/MAC/IP/安装路径(D3)。
|
|
26
|
+
* · 兑换产物必须是 **v2(`device` 进签名)**;验收 = `signatureVersion:2 ∧ deviceSigned:true ∧ binding:match`
|
|
27
|
+
* (这三个值**全部**来自 `install-license.mjs` 的验签/比对结论,本文件不重算)。
|
|
28
|
+
* · 读不到本机设备码 ⇒ **不静默降级成 unbound**:显式 `--no-device` 才走 unbound(与 `install-license` 同一条纪律)。
|
|
29
|
+
*
|
|
30
|
+
* ## ⛔ 不做
|
|
31
|
+
* · **不**把账号 JWT 落到客户机(本文件里没有任何令牌概念:兑换只凭一次性码);
|
|
32
|
+
* · **不**在 dry-run 里"先探一下"(那会有网络副作用、还可能占用一次性码)—— dry-run 只打印将要做什么;
|
|
33
|
+
* · **不**自己实现验签/落盘/备份(那样就有第二份判据,日后必然漂移)。
|
|
34
|
+
*/
|
|
35
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
36
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
37
|
+
import { tmpdir } from 'node:os'
|
|
38
|
+
import { join, resolve } from 'node:path'
|
|
39
|
+
import { spawnSync } from 'node:child_process'
|
|
40
|
+
import { resolveDataDir } from './argent-datadir.mjs'
|
|
41
|
+
import { signaturePayloadOf } from './license-file.mjs'
|
|
42
|
+
import { currentDevice } from './license-device.mjs'
|
|
43
|
+
|
|
44
|
+
// ── 码的形态(**服务端生成、客户端只归一化与校验**;契约见方案 §9.1)──────────────
|
|
45
|
+
/** Crockford base32:去掉 I/L/O/U(易混),归一化时 I,L→1、O→0。 */
|
|
46
|
+
export const ACTIVATION_CODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
47
|
+
/** 有效字符数(20 × 5 bit = **100 bit** 熵)。 */
|
|
48
|
+
export const ACTIVATION_CODE_LENGTH = 20
|
|
49
|
+
/** 展示分组长度(`XXXXX-XXXXX-XXXXX-XXXXX`,仅展示/人抄;判定一律按归一化后的 20 位)。 */
|
|
50
|
+
export const ACTIVATION_CODE_GROUP = 5
|
|
51
|
+
/** 归一化后的形态(判据只有这一条)。 */
|
|
52
|
+
export const ACTIVATION_CODE_RE = /^[0-9A-HJKMNP-TV-Z]{20}$/
|
|
53
|
+
/** 人工核对用的短前缀长度(**唯一允许出现在输出/审计里的码片段**)。 */
|
|
54
|
+
export const ACTIVATION_CODE_PREFIX_LENGTH = 8
|
|
55
|
+
/** 建议 TTL(服务端强制;这里只用于回显/文案 —— **客户端不判期**,码的有效性归服务端判)。 */
|
|
56
|
+
export const ACTIVATION_CODE_TTL_MINUTES = 15
|
|
57
|
+
/** 同码同机的**幂等复用窗口**(网络超时后重试 ⇒ 服务端返回同一份许可,而不是"码已被用掉")。 */
|
|
58
|
+
export const ACTIVATION_REUSE_WINDOW_HOURS = 24
|
|
59
|
+
/** 状态机(与服务端 `ActivationCode.state` 逐字一致;`redeeming` 只在 CAS 的中间态出现)。 */
|
|
60
|
+
export const ACTIVATION_CODE_STATES = ['issued', 'redeeming', 'redeemed', 'expired', 'revoked']
|
|
61
|
+
/** 码的意图(续期/重绑**复用同一端点**,见方案 §9.5)。 */
|
|
62
|
+
export const ACTIVATION_INTENTS = ['activate', 'renew', 'rebind']
|
|
63
|
+
|
|
64
|
+
// ── 端点(`--server` 只给 **base URL**;路径相对它解析 ⇒ 支持反代下的路径前缀)──
|
|
65
|
+
export const REDEEM_PATH = 'api/argent/license/redeem'
|
|
66
|
+
export const CODE_STATUS_PATH = 'api/argent/license/activation-code/'
|
|
67
|
+
export const DEFAULT_TIMEOUT_MS = 15000
|
|
68
|
+
/** 客户端审计(`argent audit --source license-activation` 可读)。 */
|
|
69
|
+
export const ACTIVATION_AUDIT = 'license-activation/audit.jsonl'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 归一化一次性码:去空白/分隔符 → 大写 → Crockford 别名(I,L→1;O→0)→ 形状校验。
|
|
73
|
+
* @returns {{ok:boolean, code:string, error:string}}
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeActivationCode(raw) {
|
|
76
|
+
const s = String(raw == null ? '' : raw).trim()
|
|
77
|
+
if (!s) return { ok: false, code: '', error: '激活码是空的' }
|
|
78
|
+
const compact = s.replace(/[\s\-_·]/g, '').toUpperCase()
|
|
79
|
+
const mapped = compact.replace(/[IL]/g, '1').replace(/O/g, '0')
|
|
80
|
+
if (mapped.length !== ACTIVATION_CODE_LENGTH) {
|
|
81
|
+
return { ok: false, code: '', error: `激活码应为 ${ACTIVATION_CODE_LENGTH} 位有效字符(不区分大小写,连字符可有可无),实得 ${mapped.length} 位` }
|
|
82
|
+
}
|
|
83
|
+
if (!ACTIVATION_CODE_RE.test(mapped)) {
|
|
84
|
+
return { ok: false, code: '', error: `激活码含非法字符(字符集 = Crockford base32:${ACTIVATION_CODE_ALPHABET};没有 I/L/O/U)` }
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, code: mapped, error: '' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 人工核对的短前缀(**唯一允许出现在输出/审计里的码片段**)。 */
|
|
90
|
+
export function activationCodePrefix(code) {
|
|
91
|
+
return String(code || '').slice(0, ACTIVATION_CODE_PREFIX_LENGTH)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 展示形态(`XXXXX-XXXXX-XXXXX-XXXXX`;只用于"请核对这串"的人话,**不用于判定**)。 */
|
|
95
|
+
export function displayActivationCode(code) {
|
|
96
|
+
const s = String(code || '')
|
|
97
|
+
const g = ACTIVATION_CODE_GROUP
|
|
98
|
+
return s.length === ACTIVATION_CODE_LENGTH ? s.match(new RegExp(`.{1,${g}}`, 'g')).join('-') : s
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 是否回环地址(唯一允许明文 `http://` 的场合:本机 http 桩/内网自测)。 */
|
|
102
|
+
export function isLoopbackHost(hostname) {
|
|
103
|
+
const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
104
|
+
return h === 'localhost' || h === '::1' || h === '0:0:0:0:0:0:0:1' || /^127\./.test(h)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** base URL → 端点(保留路径前缀:`https://h/p` ⇒ `https://h/p/api/argent/license/redeem`)。 */
|
|
108
|
+
export function endpointOf(baseUrl, relPath = REDEEM_PATH) {
|
|
109
|
+
const b = String(baseUrl || '')
|
|
110
|
+
return new URL(relPath, b.endsWith('/') ? b : `${b}/`).toString()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 解析生效的端点(**A2:回显实际生效值与它的来源**)。
|
|
115
|
+
* 优先级:`--server` > `ARGENT_LICENSE_SERVER` > `WHYSHU_API_URL`(与 `scripts/lease.py` 等的既有约定一致)。
|
|
116
|
+
* ⛔ **不给任何默认值**:没配置 ⇒ `activation_server_unconfigured`(exit 2)——**绝不**静默指向某个线上地址。
|
|
117
|
+
* ⛔ 非回环地址**必须 https**(明文 http 直接拒,exit 2)。
|
|
118
|
+
* @returns {{ok:boolean, reason:string, error:string, baseUrl:string, source:string, endpoint:string, tls:boolean, loopback:boolean}}
|
|
119
|
+
*/
|
|
120
|
+
export function resolveActivationServer({ flag = null, env = {} } = {}) {
|
|
121
|
+
const candidates = [
|
|
122
|
+
{ source: 'flag', label: '--server', value: flag },
|
|
123
|
+
{ source: 'env:ARGENT_LICENSE_SERVER', label: 'ARGENT_LICENSE_SERVER', value: env.ARGENT_LICENSE_SERVER },
|
|
124
|
+
{ source: 'env:WHYSHU_API_URL', label: 'WHYSHU_API_URL', value: env.WHYSHU_API_URL },
|
|
125
|
+
]
|
|
126
|
+
const miss = { ok: false, baseUrl: '', source: '', endpoint: '', tls: false, loopback: false }
|
|
127
|
+
const hit = candidates.find((c) => String(c.value == null ? '' : c.value).trim())
|
|
128
|
+
if (!hit) {
|
|
129
|
+
return {
|
|
130
|
+
...miss, reason: 'activation_server_unconfigured',
|
|
131
|
+
error: '没有配置签发服务端点:请用 `--server https://whyshu.com`(**只给 base URL** —— 端点路径由客户端拼,'
|
|
132
|
+
+ '⛔ 别写成 `https://whyshu.com/api/argent`:那会拼成 `…/api/argent/api/argent/license/redeem` ⇒ 404),或设置环境变量 '
|
|
133
|
+
+ '`ARGENT_LICENSE_SERVER`(也可沿用 `WHYSHU_API_URL`)——⛔ 不提供任何默认地址'
|
|
134
|
+
+ '(避免"以为在跟本机演练、其实打到了线上")',
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let u = null
|
|
138
|
+
try { u = new URL(String(hit.value).trim()) } catch {
|
|
139
|
+
return { ...miss, reason: 'usage_error', source: hit.source, error: `${hit.label} 不是合法 URL:${JSON.stringify(String(hit.value).trim())}` }
|
|
140
|
+
}
|
|
141
|
+
if (!['http:', 'https:'].includes(u.protocol)) {
|
|
142
|
+
return { ...miss, reason: 'usage_error', source: hit.source, error: `${hit.label} 只认 http/https,实得 ${u.protocol}` }
|
|
143
|
+
}
|
|
144
|
+
const loopback = isLoopbackHost(u.hostname)
|
|
145
|
+
if (u.protocol === 'http:' && !loopback) {
|
|
146
|
+
return {
|
|
147
|
+
...miss, reason: 'usage_error', source: hit.source,
|
|
148
|
+
error: `拒绝明文传输:${hit.label}=${u.origin} 是 **http** 且不是回环地址 ⇒ 一次性码会以明文过网。`
|
|
149
|
+
+ '请用 https(只有 127.0.0.1/localhost/::1 允许明文 http,供本机演练)',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const baseUrl = u.toString().replace(/\/+$/, '')
|
|
153
|
+
return {
|
|
154
|
+
ok: true, reason: 'ok', error: '', baseUrl, source: hit.source,
|
|
155
|
+
endpoint: endpointOf(baseUrl), tls: u.protocol === 'https:', loopback,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* **服务端错误码 → 客户端 reason/A1 退出码**(唯一映射表;方案 §9.3 的错误码表与它逐条一致)。
|
|
161
|
+
* 这些短码**不是客户端新发明的判据**,而是服务端词表在客户端的**同名映射**(服务端的 `no_entitlement`
|
|
162
|
+
* 就是客户端的 `no_entitlement`);客户端自己产生的码一律 `activation_` 前缀 ⇒ 一眼分得开。
|
|
163
|
+
*/
|
|
164
|
+
export const SERVER_ERROR_MAP = {
|
|
165
|
+
// ① 码的问题(客户:重新生成一个码)
|
|
166
|
+
code_unknown: { exit: 1, category: 'code', hint: '码不存在(抄错,或不是这台服务器的码)⇒ 在网页/手机端**重新生成**一个' },
|
|
167
|
+
code_expired: { exit: 1, category: 'code', hint: `码已过期(TTL ${ACTIVATION_CODE_TTL_MINUTES} 分钟)⇒ 重新生成一个,生成后**尽快**兑换` },
|
|
168
|
+
code_already_redeemed: { exit: 1, category: 'code', hint: '码已被用掉(且超出同机幂等复用窗口)⇒ 重新生成一个;若这台机器其实收到过许可,先 `argent license status`' },
|
|
169
|
+
code_device_locked: { exit: 1, category: 'code', hint: '这个码已经绑定到**另一台机器**(码被转发/双花)⇒ 在那台机器上兑换,或重新生成一个' },
|
|
170
|
+
code_revoked: { exit: 1, category: 'code', hint: '码被运营撤销 ⇒ 联系运营确认' },
|
|
171
|
+
code_locked: { exit: 1, category: 'code', hint: '该码失败尝试超限已锁定 ⇒ 重新生成一个(并核对该机器的设备码)' },
|
|
172
|
+
// ② 账号/权益/席位(客户:找商务/运营)
|
|
173
|
+
no_entitlement: { exit: 1, category: 'entitlement', hint: '该账号没有任何有效许可 ⇒ 先购买/开通,再生成码' },
|
|
174
|
+
no_license: { exit: 1, category: 'entitlement', hint: '该账号没有许可记录 ⇒ 开通' },
|
|
175
|
+
license_revoked: { exit: 1, category: 'entitlement', hint: '该账号的许可已被吊销 ⇒ 联系运营' },
|
|
176
|
+
capability_not_owned: { exit: 1, category: 'entitlement', hint: '请求的能力超出账号权益(签发**不扩权**)⇒ 以账号权益为准' },
|
|
177
|
+
seat_limit_reached: { exit: 1, category: 'entitlement', hint: '席位已满(新设备会超 `seats`)⇒ 解绑旧设备或加席位' },
|
|
178
|
+
device_bound_elsewhere: { exit: 1, category: 'entitlement', hint: '同账号同席位已被另一台设备占用 ⇒ 走重绑或人工解绑' },
|
|
179
|
+
rebind_exhausted: { exit: 1, category: 'entitlement', hint: '换绑次数已用尽(服务端权威计数 3 次)⇒ 人工重绑:运营解绑旧设备码后重签' },
|
|
180
|
+
rebind_window_expired: { exit: 1, category: 'entitlement', hint: '首次发现不匹配已超过 7 天宽限 ⇒ 人工重绑' },
|
|
181
|
+
payment_required: { exit: 1, category: 'entitlement', hint: '未核账不放行(姿态 A)⇒ 走人工核账' },
|
|
182
|
+
// ③ 请求/服务端环境(服务端 §2.2-D 的既有码)
|
|
183
|
+
bad_request: { exit: 1, category: 'client', hint: '服务端认为请求不合法(字段缺失/类型错)⇒ 客户端与服务端契约版本不一致:先核对两边版本,别反复重试' },
|
|
184
|
+
signing_secret_missing: { exit: 1, category: 'server', hint: '服务端未配置 `LICENSE_SIGNING_SECRET`(⛔ 不回退默认串)⇒ 运营处置后重试(**同一个码**重试安全)' },
|
|
185
|
+
// ④ 限流 / 设备
|
|
186
|
+
rate_limited: { exit: 1, category: 'throttle', hint: '触发限流 ⇒ 退避后重试(**同一个码**重试是安全的:同码同机走幂等复用)' },
|
|
187
|
+
bad_device: { exit: 1, category: 'device', hint: '服务端拒绝了设备码(形状/算法不认识)⇒ 重跑 `argent license request`,别手抄' },
|
|
188
|
+
// ⑤ 灰度开关(方案 §9.8):灰度未开或已回滚 ⇒ 明确说"用人工入口",别让客户反复重试
|
|
189
|
+
activation_disabled: { exit: 1, category: 'server', hint: '服务端**灰度未开启/已回滚**(`ACTIVATION_ENABLED=0`)⇒ 改走人工签发入口:让运营按 `argent license request` 的设备码签一份,再 `argent license install --file <许可>`' },
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 客户端自己产生的码(`activation_` 前缀 = 与"服务端词表"一眼可分)。 */
|
|
193
|
+
export const CLIENT_ERROR_MAP = {
|
|
194
|
+
activation_server_unconfigured: { exit: 2, category: 'config', hint: '配置端点:`--server https://whyshu.com`(**只给 base URL**:别写成 `…/api/argent`,那会多一层前缀 ⇒ 404)或 `ARGENT_LICENSE_SERVER`(也可用 `WHYSHU_API_URL`)' },
|
|
195
|
+
activation_unauthorized: { exit: 1, category: 'server', hint: '兑换端点返回 401:该端点**不应要求账号令牌**(客户机不登录)⇒ 服务端路由/部署配错了' },
|
|
196
|
+
activation_server_error: { exit: 1, category: 'server', hint: '服务端 5xx(含 `signing_secret_missing`)⇒ 运营处置;稍后可用**同一个码**重试(幂等复用)' },
|
|
197
|
+
activation_bad_response: { exit: 1, category: 'server', hint: '服务端响应不可解析或缺字段 ⇒ **不要**重试到成功:先核对服务端版本/契约' },
|
|
198
|
+
activation_server_unreachable: { exit: 1, category: 'network', hint: '连不上端点(DNS/连接失败)⇒ 查网络与 `--server`;同一个码重试是安全的' },
|
|
199
|
+
activation_timeout: { exit: 1, category: 'network', hint: '请求超时。**码可能已被消费**:用**同一个码**重试会命中"同码同机幂等复用";仍不行 ⇒ 运营用 `GET /license/activation-code/:prefix` 查状态' },
|
|
200
|
+
activation_denied: { exit: 1, category: 'server', hint: '服务端拒绝但 reason 不在本客户端词表里 ⇒ 看 `serverReason` 字段与服务端日志(客户端不把未知串当判据)' },
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 全部激活相关短码(契约登记用;两边合并)。 */
|
|
204
|
+
export const ACTIVATION_REASONS = { ...SERVER_ERROR_MAP, ...CLIENT_ERROR_MAP }
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 服务端应答 → `{reason, exit, hint}`(**未知 reason 一律收敛成 `activation_denied`**,
|
|
208
|
+
* 绝不把服务端返回的任意字符串当判据 —— A2:`reason` 只能是词表里的短码)。
|
|
209
|
+
*/
|
|
210
|
+
export function mapServerOutcome({ httpStatus = 0, reason = '' } = {}) {
|
|
211
|
+
const raw = String(reason || '').trim()
|
|
212
|
+
// `serverReason` 会被回显 ⇒ 只可能是"短码形态"(≤64 且 [a-z0-9_]);否则一律当"不认识"(不给任意串留回显位)
|
|
213
|
+
const r = /^[a-z][a-z0-9_]{0,63}$/.test(raw) ? raw : ''
|
|
214
|
+
if (SERVER_ERROR_MAP[r]) return { reason: r, exit: SERVER_ERROR_MAP[r].exit, hint: SERVER_ERROR_MAP[r].hint, serverReason: r, known: true }
|
|
215
|
+
const st = Number(httpStatus) || 0
|
|
216
|
+
if (st === 401) return { ...CLIENT_ERROR_MAP.activation_unauthorized, reason: 'activation_unauthorized', serverReason: r || null, known: false }
|
|
217
|
+
if (st >= 500) return { ...CLIENT_ERROR_MAP.activation_server_error, reason: 'activation_server_error', serverReason: r || null, known: false }
|
|
218
|
+
if (st === 429) return { ...SERVER_ERROR_MAP.rate_limited, reason: 'rate_limited', serverReason: r || null, known: true }
|
|
219
|
+
if (st === 404) {
|
|
220
|
+
// **与"码不存在"区分开**:服务端真正的 `code_unknown` 也走 404,但它**带 reason** ⇒ 上面就命中了。
|
|
221
|
+
// 走到这里 = 404 且响应体不是我们的错误信封(Fastify 的 `Route … not found` 就是这一形态)
|
|
222
|
+
// ⇒ 十有八九是 `--server` 把**端点路径**当成了 base(多一层前缀)。2026-10-04 客户机试点踩到过。
|
|
223
|
+
return {
|
|
224
|
+
...CLIENT_ERROR_MAP.activation_denied, reason: 'activation_denied', serverReason: r || null, known: false,
|
|
225
|
+
hint: '端点返回 404 且响应体不是本服务的错误信封(多是 Fastify 的 `Route … not found`)⇒ 先怀疑 `--server`:'
|
|
226
|
+
+ '它只给 **base URL**(正确值 `https://whyshu.com`);写成 `https://whyshu.com/api/argent` 会拼成'
|
|
227
|
+
+ ' `https://whyshu.com/api/argent/api/argent/license/redeem` ⇒ 404',
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { ...CLIENT_ERROR_MAP.activation_denied, reason: 'activation_denied', serverReason: r || null, known: false }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 响应里 `licenseFileSha256` 的口径 = `sha256(canonicalJson(载荷))`(与方案 §6.2/§9.3 的字节锚点同源)。 */
|
|
234
|
+
export function payloadSha256(licenseFile) {
|
|
235
|
+
const p = signaturePayloadOf(licenseFile)
|
|
236
|
+
if (!p) return null
|
|
237
|
+
return createHash('sha256').update(p.payload).digest('hex')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* **把明文码从任意文本里抹掉**(客户端最后一道"零泄漏"防线)。
|
|
242
|
+
*
|
|
243
|
+
* 为什么需要它:客户端会**转述**服务端的人话字段(`error`/`hint`)—— 若服务端(或因配置错误/被改)
|
|
244
|
+
* 把明文码塞进那些字段,客户端就会把它打到客户终端与日志里。这里按"码的任意 ≥12 位连续片段"
|
|
245
|
+
* 替换(前缀只有 8 位 ⇒ 不会误伤 `codePrefix`)⇒ **无论服务端怎么写,明文码都出不去**。
|
|
246
|
+
*/
|
|
247
|
+
export function redactActivationCode(text, code) {
|
|
248
|
+
const out0 = String(text == null ? '' : text)
|
|
249
|
+
const full = String(code || '')
|
|
250
|
+
if (full.length < 12) return out0
|
|
251
|
+
let out = out0
|
|
252
|
+
// **从最长匹配到最短**(20 → 12):只按 12 位窗口替换会把"尾巴"(≤8 位)留在文本里 ——
|
|
253
|
+
// 虽然仍满足"没有 ≥12 位连续片段"的不变量,但没必要留残渣。从长到短保证整段被吃掉。
|
|
254
|
+
for (let len = full.length; len >= 12; len--) {
|
|
255
|
+
for (let i = 0; i + len <= full.length; i++) out = out.split(full.slice(i, i + len)).join('‹码已隐去›')
|
|
256
|
+
}
|
|
257
|
+
return out
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 服务端回显的前缀:**只在不扩大泄漏面时**才采信(必须是我们自己前缀的子串,且 ≤8 位)。 */
|
|
261
|
+
export function acceptCodePrefix(value, own) {
|
|
262
|
+
const v = String(value == null ? '' : value).trim()
|
|
263
|
+
return v && v.length <= ACTIVATION_CODE_PREFIX_LENGTH && String(own).startsWith(v) ? v : own
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 服务端回显的摘要:只认 64hex(否则丢掉 —— 免得把服务端塞进来的任意串当"摘要"落进审计)。 */
|
|
267
|
+
function acceptSha(value) {
|
|
268
|
+
const v = String(value == null ? '' : value).trim().toLowerCase()
|
|
269
|
+
return /^[0-9a-f]{64}$/.test(v) ? v : null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 审计路径(`argent audit --source license-activation`)。 */
|
|
273
|
+
export function activationAuditPath(dataDir) {
|
|
274
|
+
return join(String(dataDir), ACTIVATION_AUDIT)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 追一行审计(**只有 apply 才调用**;dry-run 一行都不落 —— A3 零副作用)。 */
|
|
278
|
+
export function appendActivationAudit(dataDir, record) {
|
|
279
|
+
const dir = join(String(dataDir), 'license-activation')
|
|
280
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
281
|
+
const p = join(dir, 'audit.jsonl')
|
|
282
|
+
appendFileSync(p, `${JSON.stringify(record)}\n`, { mode: 0o600 })
|
|
283
|
+
try { chmodSync(p, 0o600) } catch { /* 权限拿不到:审计仍已落盘 */ }
|
|
284
|
+
return p
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 失败信封(A2)。 */
|
|
288
|
+
const failJson = (code, reason, error, extra = {}) => ({
|
|
289
|
+
json: { ok: false, empty: false, code, reason, error, subcommand: 'activate', ...extra },
|
|
290
|
+
human: `❌ ${reason}:${error}`,
|
|
291
|
+
stderr: '',
|
|
292
|
+
exit: code,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
export const ACTIVATE_VALUE_FLAGS = ['--code', '--code-file', '--server', '--device-code', '--secret-file', '--instance', '--intent']
|
|
296
|
+
export const ACTIVATE_BOOL_FLAGS = ['--no-device', '--apply', '--yes', '--confirm', '--dry-run', '--allow-regression', '--help', '-h']
|
|
297
|
+
|
|
298
|
+
export const ACTIVATE_USAGE = [
|
|
299
|
+
'用法:argent license activate --code <一次性激活码> [--server https://whyshu.com] [--apply] [--allow-regression] [--json]',
|
|
300
|
+
'',
|
|
301
|
+
'端点(`--server` 只给 **base URL** —— 路径由客户端拼,见 core/lib/license-activate.mjs#REDEEM_PATH):',
|
|
302
|
+
' · 正确值:`--server https://whyshu.com` ⇒ 实际请求 `https://whyshu.com/api/argent/license/redeem`;',
|
|
303
|
+
' · ⛔ 别写成 `--server https://whyshu.com/api/argent`:base URL 的**路径前缀是保留的**(反代下有用)⇒',
|
|
304
|
+
' 会拼成 `https://whyshu.com/api/argent/api/argent/license/redeem` 而 **404**(多写一段 = 多一层前缀);',
|
|
305
|
+
' · 也可用 `ARGENT_LICENSE_SERVER`(兜底 `WHYSHU_API_URL`);⛔ 没有默认地址(不静默指向线上)。',
|
|
306
|
+
'',
|
|
307
|
+
'两步话术(先取设备码,再兑换):',
|
|
308
|
+
' ① argent license request --json # 打印本机设备码(64hex),交给网页/手机端',
|
|
309
|
+
' ② 在网页/手机端**登录后**生成一次性激活码(TTL 15 分钟、一次性、绑定这台机器的设备码)',
|
|
310
|
+
' ③ argent license activate --code <码> # 先 dry-run:只打印"将请求哪个端点、将写哪个文件"',
|
|
311
|
+
' ④ argent license activate --code <码> --apply # 确认无误再真兑换(服务端签发 → 落盘)',
|
|
312
|
+
'',
|
|
313
|
+
'安全:',
|
|
314
|
+
' · 建议**先 dry-run 再 --apply**:dry-run 不发任何网络请求、不写任何文件,连审计都不落;',
|
|
315
|
+
' · 明文码**不会**出现在任何输出里(只回显前 8 位 codePrefix 供人工核对);共享机器上建议用 --code-file;',
|
|
316
|
+
' · 端点必须 https(只有 127.0.0.1/localhost/::1 允许明文 http,供本机演练);',
|
|
317
|
+
' · 落盘复用 `scripts/install-license.mjs` 的既有判据:验签 + 设备比对 + 0600 + 覆盖前备份 + 写后读回。',
|
|
318
|
+
'',
|
|
319
|
+
'倒退放行(A4 守卫的逃生阀 `--allow-regression`):',
|
|
320
|
+
' · 兑换回来的许可若比现役**更早到期**(stale)或能力/业务线/席位被**收窄**(narrowed)⇒ 默认拒绝',
|
|
321
|
+
' (`regression_refused`,exit 1,**不落盘**;判定在 scripts/install-license.mjs,本层只透传);',
|
|
322
|
+
' · 确实要装(纠错/吊销/换绑)⇒ 显式加 `--allow-regression`:**放行这一次**,并在回显/告警里如实标注;',
|
|
323
|
+
' · ⚠️ 它只是"签字确认",不是"修复":⛔ 不要把它当成续期流程的常规参数 —— 正确做法是',
|
|
324
|
+
' 签发前先跑 `argent license status --json` 抄下现役 `expiresAt`/`capabilities`,确保新许可 **≥** 现役。',
|
|
325
|
+
].join('\n')
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* `argent license activate` 的 verb 入口(**异步**:真兑换要发 HTTP;CLI 顶层已 `await`)。
|
|
329
|
+
* @param {string[]} argv 子命令参数(**不含** `--json`:那是 CLI 层开关,由调用方经 `deps.asJson` 告知)
|
|
330
|
+
* @param {object} deps `{repo, env, dataDir, spawn, fetch, deviceRead, requestId, asJson}`
|
|
331
|
+
*/
|
|
332
|
+
export async function activateVerb(argv = [], deps = {}) {
|
|
333
|
+
const env = deps.env || process.env
|
|
334
|
+
const repo = deps.repo || resolve(process.cwd())
|
|
335
|
+
const spawn = deps.spawn || spawnSync
|
|
336
|
+
/** `--json`:stdout 只留合同字段(human 被藏起来)⇒ 少数**必须被看见**的标注要走 stderr。
|
|
337
|
+
* 由调用方(`runLicenseVerb`)告知,因为 `--json` 在那一层就被滤掉了。 */
|
|
338
|
+
const asJson = deps.asJson === true
|
|
339
|
+
|
|
340
|
+
// ── 参数(未知参数一律拒绝 ⇒ 2:不静默吃掉别人的参数)──────────────────────
|
|
341
|
+
const values = {}
|
|
342
|
+
const bools = new Set()
|
|
343
|
+
for (let i = 0; i < argv.length; i++) {
|
|
344
|
+
const a = argv[i]
|
|
345
|
+
if (a === '--json') continue
|
|
346
|
+
if (ACTIVATE_BOOL_FLAGS.includes(a)) { bools.add(a); continue }
|
|
347
|
+
if (ACTIVATE_VALUE_FLAGS.includes(a)) {
|
|
348
|
+
const v = argv[i + 1]
|
|
349
|
+
if (v === undefined || String(v).startsWith('--')) {
|
|
350
|
+
return failJson(2, 'usage_error', `参数 ${a} 缺值(用法:argent license activate --code <码> [--apply])`)
|
|
351
|
+
}
|
|
352
|
+
values[a] = v; i++; continue
|
|
353
|
+
}
|
|
354
|
+
return failJson(2, 'usage_error', `未知参数:${a}(用法见 \`argent license activate --help\`)`)
|
|
355
|
+
}
|
|
356
|
+
if (bools.has('--help') || bools.has('-h')) {
|
|
357
|
+
return { json: { ok: true, empty: false, code: 0, reason: null, subcommand: 'activate', usage: ACTIVATE_USAGE }, human: ACTIVATE_USAGE, stderr: '', exit: 0 }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ① 明文码:来源二选一(`--code` 便于手抄;`--code-file` 不进 shell 历史/进程列表)
|
|
361
|
+
if (values['--code'] !== undefined && values['--code-file'] !== undefined) {
|
|
362
|
+
return failJson(2, 'usage_error', '`--code` 与 `--code-file` 只能给一个(避免"到底用了哪个"说不清)')
|
|
363
|
+
}
|
|
364
|
+
const rawCode = values['--code'] !== undefined ? String(values['--code'])
|
|
365
|
+
: (values['--code-file'] !== undefined ? readCodeFile(values['--code-file']) : null)
|
|
366
|
+
if (rawCode === null) {
|
|
367
|
+
return failJson(2, 'usage_error', '缺必填参数:`--code <一次性激活码>`(或 `--code-file <文件>`)——用法见 `argent license activate --help`')
|
|
368
|
+
}
|
|
369
|
+
if (rawCode && typeof rawCode === 'object' && rawCode.__error) return failJson(2, 'usage_error', rawCode.__error)
|
|
370
|
+
const norm = normalizeActivationCode(rawCode)
|
|
371
|
+
if (!norm.ok) {
|
|
372
|
+
// ⛔ 错误消息里**不回显用户输入**(它可能是"抄错一位的真码");只报位数/字符集
|
|
373
|
+
return failJson(2, 'usage_error', `${norm.error}(先在网页/手机端复制;共享机器上建议 --code-file;⛔ 不要把码贴进工单/聊天)`)
|
|
374
|
+
}
|
|
375
|
+
const code = norm.code
|
|
376
|
+
const codePrefix = activationCodePrefix(code)
|
|
377
|
+
|
|
378
|
+
// ② 端点(未配置 ⇒ 2,**不得**静默假装成功;非回环 http ⇒ 拒绝明文)
|
|
379
|
+
const server = resolveActivationServer({ flag: values['--server'], env })
|
|
380
|
+
if (!server.ok) return failJson(2, server.reason, server.error, { server: { source: server.source || null } })
|
|
381
|
+
|
|
382
|
+
// ③ 设备码(默认取本机;读不到 ⇒ 显式 `--no-device` 才走 unbound —— **不静默降级**)
|
|
383
|
+
const noDevice = bools.has('--no-device')
|
|
384
|
+
const explicitCode = values['--device-code'] !== undefined ? String(values['--device-code']).trim().toLowerCase() : null
|
|
385
|
+
if (noDevice && explicitCode) return failJson(2, 'usage_error', '`--no-device` 与 `--device-code` 互斥(要么绑这台机器,要么显式不绑)')
|
|
386
|
+
const readOpts = deps.deviceRead ? { ...deps.deviceRead } : {}
|
|
387
|
+
let device = null
|
|
388
|
+
let deviceSource = 'none'
|
|
389
|
+
if (noDevice) {
|
|
390
|
+
deviceSource = 'none'
|
|
391
|
+
} else if (explicitCode) {
|
|
392
|
+
if (!/^[0-9a-f]{64}$/.test(explicitCode)) {
|
|
393
|
+
return failJson(2, 'usage_error', '--device-code 必须是 64 位十六进制的**设备码**(`argent license request` 打印的那串):'
|
|
394
|
+
+ '⛔ 不要给主机名/MAC/IP(D3:指纹只认硬件 UUID 一项)')
|
|
395
|
+
}
|
|
396
|
+
device = { code: explicitCode, algo: currentDevice(readOpts).algo, issuedAt: new Date().toISOString() }
|
|
397
|
+
deviceSource = 'flag'
|
|
398
|
+
} else {
|
|
399
|
+
const dev = currentDevice(readOpts)
|
|
400
|
+
if (!dev.ok) {
|
|
401
|
+
return failJson(2, dev.reason === 'device_unsupported' ? 'device_unsupported' : 'device_read_failed',
|
|
402
|
+
`${dev.error} ⇒ 要么显式给 \`--device-code <64hex>\`(在能读到指纹的机器上跑 \`argent license request\`),`
|
|
403
|
+
+ '要么显式 `--no-device`(**不静默降级成 unbound**:那会把"绑不上"悄悄变成"没绑定")',
|
|
404
|
+
{ device: { supported: false, reason: dev.reason } })
|
|
405
|
+
}
|
|
406
|
+
device = { code: dev.code, algo: dev.algo, issuedAt: dev.issuedAt }
|
|
407
|
+
deviceSource = 'local'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ④ 目标与密钥来源(回显"将写哪个文件")
|
|
411
|
+
const dataDir = deps.dataDir || resolveDataDir({ env }).dir
|
|
412
|
+
const targetLicense = join(dataDir, 'license.json')
|
|
413
|
+
const targetSecret = join(dataDir, 'license.secret')
|
|
414
|
+
const auditFile = activationAuditPath(dataDir)
|
|
415
|
+
const explicitSecretPath = values['--secret-file'] ? resolve(String(values['--secret-file'])) : null
|
|
416
|
+
if (explicitSecretPath && !existsSync(explicitSecretPath)) {
|
|
417
|
+
return failJson(2, 'usage_error', `--secret-file 指向的密钥文件不存在:${explicitSecretPath}`)
|
|
418
|
+
}
|
|
419
|
+
const explicitSecret = Boolean(explicitSecretPath) || Boolean(String(env.ARGENT_LICENSE_SECRET || '').trim())
|
|
420
|
+
// 密钥来源优先级:显式(--secret-file / 环境变量)> 本机已装 license.secret > 服务端随兑换返回
|
|
421
|
+
const secretSource = explicitSecret ? 'explicit' : (existsSync(targetSecret) ? 'local' : 'server')
|
|
422
|
+
const acceptSecret = secretSource === 'server'
|
|
423
|
+
|
|
424
|
+
const confirm = ['--apply', '--yes', '--confirm'].some((f) => bools.has(f))
|
|
425
|
+
const dryRun = !confirm || bools.has('--dry-run') // A3:默认 dry-run;`--dry-run` 恒优先
|
|
426
|
+
// A4 守卫(`regression_refused`)的**逃生阀**:本层**只透传**,判定仍在 scripts/install-license.mjs
|
|
427
|
+
// (不复制判据 —— 否则"什么算倒退"会有第二份定义)。默认 false:倒退必须被显式签字确认。
|
|
428
|
+
const allowRegression = bools.has('--allow-regression')
|
|
429
|
+
const requestId = deps.requestId || randomUUID()
|
|
430
|
+
const timeoutMs = resolveTimeoutMs(env)
|
|
431
|
+
const instanceHint = values['--instance'] ? String(values['--instance']).trim() : null
|
|
432
|
+
if (instanceHint && !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceHint)) {
|
|
433
|
+
return failJson(2, 'usage_error', '--instance 只接受 `^[A-Za-z0-9._:-]{1,128}$`(它只是**提示串**:不参与任何判定)')
|
|
434
|
+
}
|
|
435
|
+
const intent = values['--intent'] ? String(values['--intent']) : 'activate'
|
|
436
|
+
if (!ACTIVATION_INTENTS.includes(intent)) {
|
|
437
|
+
return failJson(2, 'usage_error', `--intent 只认 ${ACTIVATION_INTENTS.join('|')}(续期/重绑复用同一端点,见方案 §9.5)`)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 生效值回显(A2):**白名单字段**,不放任何响应体的原始内容
|
|
441
|
+
const request = {
|
|
442
|
+
activation: {
|
|
443
|
+
codePrefix, codeLength: ACTIVATION_CODE_LENGTH, requestId,
|
|
444
|
+
ttlMinutes: ACTIVATION_CODE_TTL_MINUTES, reuseWindowHours: ACTIVATION_REUSE_WINDOW_HOURS,
|
|
445
|
+
// ⛔ 这里**不放**展示形态:`codePrefix`(8 位)已够人工核对,多给一位就多一分泄漏面
|
|
446
|
+
displayNote: `展示形态 ${ACTIVATION_CODE_GROUP} 位一组(${'X'.repeat(ACTIVATION_CODE_GROUP)}-…)只用于人读;输出里只回显前缀`,
|
|
447
|
+
},
|
|
448
|
+
device: device
|
|
449
|
+
? { requested: true, source: deviceSource, code: device.code, algo: device.algo, issuedAt: device.issuedAt }
|
|
450
|
+
: { requested: false, source: 'none', code: null, algo: null, issuedAt: null },
|
|
451
|
+
secret: {
|
|
452
|
+
source: secretSource, acceptServerSecret: acceptSecret, target: targetSecret,
|
|
453
|
+
explicitPath: explicitSecretPath,
|
|
454
|
+
},
|
|
455
|
+
target: { dataDir, licenseFile: targetLicense, secretFile: targetSecret, auditFile },
|
|
456
|
+
instanceHint, intent, confirmFlagsUsed: confirm, timeoutMs, allowRegression,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── A3:dry-run —— 在任何 fetch/临时文件/子进程**之前**返回(零网络 + 零落盘)──
|
|
460
|
+
if (dryRun) {
|
|
461
|
+
const json = {
|
|
462
|
+
ok: true, empty: false, code: 0, reason: null,
|
|
463
|
+
subcommand: 'activate', dryRun: true, applied: false,
|
|
464
|
+
server: { baseUrl: server.baseUrl, source: server.source, endpoint: server.endpoint, tls: server.tls, loopback: server.loopback },
|
|
465
|
+
endpoint: server.endpoint, codePrefix,
|
|
466
|
+
request,
|
|
467
|
+
audit: { attempted: false, file: auditFile, written: false, note: 'dry-run **不落**审计(A3 零副作用)' },
|
|
468
|
+
would: {
|
|
469
|
+
request: `POST ${server.endpoint}`,
|
|
470
|
+
writes: [
|
|
471
|
+
`${targetLicense}(0600,覆盖前备份;判据在 scripts/install-license.mjs)`,
|
|
472
|
+
...(secretSource === 'server' ? [`${targetSecret}(0600,来自兑换响应;本机当前没有密钥)`] : []),
|
|
473
|
+
],
|
|
474
|
+
network: '**零请求**(dry-run 不探测、也不占用一次性码)',
|
|
475
|
+
},
|
|
476
|
+
next: [
|
|
477
|
+
'确认无误后重跑并加 `--apply`(真兑换;同一个码只会被消费一次)',
|
|
478
|
+
'还没有设备码 ⇒ 先跑 `argent license request --json`,把 64hex 设备码交给网页/手机端生成码',
|
|
479
|
+
...(allowRegression
|
|
480
|
+
? ['⚠️ 本次带了 `--allow-regression`:若兑换回来的许可**比现役更早到期/被收窄**,落盘将被放行一次'
|
|
481
|
+
+ '(默认是被 `regression_refused` 拒掉的)—— 请先 `argent license status --json` 核对现役到期值']
|
|
482
|
+
: ['若被 `regression_refused` 拒掉(新许可比现役更早到期/被收窄)⇒ ⛔ 先核对是不是拿错了权益,'
|
|
483
|
+
+ '确需倒退才加 `--allow-regression`']),
|
|
484
|
+
],
|
|
485
|
+
judgedBy: 'core/lib/license-activate.mjs(dry-run:只打印计划,不判定)',
|
|
486
|
+
}
|
|
487
|
+
const human = [
|
|
488
|
+
'🔎 [dry-run] 许可激活(一次性激活码兑换)—— 本次**不发任何网络请求、不写任何文件**',
|
|
489
|
+
` 端点:POST ${server.endpoint}(来源 ${server.source}${server.tls ? '' : ';明文 http 仅限回环'})`,
|
|
490
|
+
` 激活码:前缀 ${codePrefix}…(${ACTIVATION_CODE_LENGTH} 位;**明文码不会出现在任何输出里**)`,
|
|
491
|
+
` 设备:${device ? `${deviceSource} ${String(device.code).slice(0, 12)}…(64hex 设备码)` : '**不声明设备**(unbound:服务端会签一份没有 device 的许可)'}`,
|
|
492
|
+
` 将写入:${targetLicense}(0600,覆盖前备份)`
|
|
493
|
+
+ (secretSource === 'server'
|
|
494
|
+
? ` + ${targetSecret}(0600,来自兑换响应)`
|
|
495
|
+
: `(密钥:沿用${secretSource === 'explicit' ? '显式来源' : '本机已有 license.secret'})`),
|
|
496
|
+
` 审计:${auditFile}(dry-run 不落)`,
|
|
497
|
+
...(allowRegression
|
|
498
|
+
? [' ⚠️ 已带 `--allow-regression`:**允许放行一次倒退**(新许可比现役更早到期/被收窄时,默认会被 `regression_refused` 拒掉)']
|
|
499
|
+
: []),
|
|
500
|
+
'',
|
|
501
|
+
' 下一步:',
|
|
502
|
+
' · 确认无误 ⇒ 重跑并加 `--apply`',
|
|
503
|
+
' · 还没有设备码 ⇒ `argent license request --json`,把它交给网页/手机端生成一次性码',
|
|
504
|
+
].join('\n')
|
|
505
|
+
return { json, human, stderr: '', exit: 0 }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── 真兑换(apply)────────────────────────────────────────────────────────
|
|
509
|
+
const fetchImpl = deps.fetch || globalThis.fetch
|
|
510
|
+
const finish = (json, human, exit) => {
|
|
511
|
+
const rec = auditRecord(json, { codePrefix, device, deviceSource, server, secretSource })
|
|
512
|
+
try {
|
|
513
|
+
const p = appendActivationAudit(dataDir, rec)
|
|
514
|
+
json.audit = { attempted: true, file: p, written: true, error: null }
|
|
515
|
+
} catch (e) {
|
|
516
|
+
json.audit = { attempted: true, file: auditFile, written: false, error: String((e && e.message) || e) }
|
|
517
|
+
json.warnings = [...(json.warnings || []), `审计写不下(${json.audit.error}):兑换本身已发生,请人工补记`]
|
|
518
|
+
}
|
|
519
|
+
// ⛔ 逃生阀**不得被静默使用**:`--json` 会把 human 藏掉(stdout 只留合同字段)⇒
|
|
520
|
+
// 放行一次倒退时把同一条标注**额外写 stderr**,让 CI/管道里的人也一定看得见。
|
|
521
|
+
const banner = asJson && json.regressionNote ? `${json.regressionNote}\n` : ''
|
|
522
|
+
return { json, human: json.audit.written ? human : `${human}\n ⚠️ 审计写不下:${json.audit.error}`, stderr: banner, exit }
|
|
523
|
+
}
|
|
524
|
+
/** 服务端文本的统一"去码"处理(闭包:需要本次的明文码才能抹)。 */
|
|
525
|
+
const redact = (t) => redactActivationCode(t, code)
|
|
526
|
+
const shell = () => ({
|
|
527
|
+
subcommand: 'activate', dryRun: false, applied: false,
|
|
528
|
+
server: { baseUrl: server.baseUrl, source: server.source, endpoint: server.endpoint, tls: server.tls, loopback: server.loopback },
|
|
529
|
+
endpoint: server.endpoint, codePrefix, request,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
if (typeof fetchImpl !== 'function') {
|
|
533
|
+
return failJson(1, 'activation_server_unreachable', '本运行时没有 fetch 实现(Node ≥18 才有)⇒ 无法兑换', shell())
|
|
534
|
+
}
|
|
535
|
+
const startedAt = new Date().toISOString()
|
|
536
|
+
const red = await postJson({
|
|
537
|
+
endpoint: server.endpoint,
|
|
538
|
+
timeoutMs,
|
|
539
|
+
fetchImpl,
|
|
540
|
+
body: {
|
|
541
|
+
code,
|
|
542
|
+
requestId,
|
|
543
|
+
device,
|
|
544
|
+
instanceHint,
|
|
545
|
+
acceptSecret,
|
|
546
|
+
intent,
|
|
547
|
+
client: { name: 'argent', version: clientVersionOf(repo) },
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
const redemption = { httpStatus: red.status, attempted: true, requestId, at: startedAt }
|
|
551
|
+
|
|
552
|
+
// 网络层失败(连接失败 / 超时)——**注意**:码可能已被消费,因此同一个码重试是安全的(幂等复用)
|
|
553
|
+
if (red.network) {
|
|
554
|
+
const reason = red.network === 'timeout' ? 'activation_timeout' : 'activation_server_unreachable'
|
|
555
|
+
const info = CLIENT_ERROR_MAP[reason]
|
|
556
|
+
const json = { ok: false, empty: false, code: info.exit, reason, error: red.error, hint: info.hint, ...shell(), redemption }
|
|
557
|
+
return finish(json, `❌ ${reason}:${red.error}\n 下一步:${info.hint}`, Number(info.exit))
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const body = red.body
|
|
561
|
+
const okBody = Boolean(body && typeof body === 'object' && body.ok === true)
|
|
562
|
+
|
|
563
|
+
// 服务端拒绝(含 2xx 但 ok!=true ⇒ **一律**按拒绝处理,绝不假装成功)
|
|
564
|
+
if (!okBody) {
|
|
565
|
+
const mapped = mapServerOutcome({ httpStatus: red.status, reason: body && body.reason })
|
|
566
|
+
// ⛔ 服务端给的文本**先抹掉明文码**再落地:客户端是"零泄漏"这条不变量的责任方(见 redactActivationCode)
|
|
567
|
+
const json = {
|
|
568
|
+
ok: false, empty: false, code: mapped.exit, reason: mapped.reason,
|
|
569
|
+
error: redact(pickText(body, ['error', 'message', 'hint'])) || `服务端返回 HTTP ${red.status}(响应不可解析或缺 ok:true)`,
|
|
570
|
+
hint: redact(pickText(body, ['hint'])) || mapped.hint,
|
|
571
|
+
serverReason: mapped.serverReason, ...shell(),
|
|
572
|
+
redemption: { ...redemption, codePrefix: acceptCodePrefix(pickText(body, ['codePrefix']), codePrefix), serverReason: mapped.serverReason },
|
|
573
|
+
}
|
|
574
|
+
return finish(json, redact(`❌ 兑换被拒(${mapped.reason}${mapped.serverReason && mapped.serverReason !== mapped.reason ? ` ← 服务端 ${mapped.serverReason}` : ''}|HTTP ${red.status})`
|
|
575
|
+
+ `\n ${json.error}\n 下一步:${json.hint}`), Number(mapped.exit))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 产物检查(**只查"契约要求的字段在不在"**;形状/验签判据不在这里复制 —— 交给 install-license)
|
|
579
|
+
const licenseFile = body.licenseFile
|
|
580
|
+
const serverSecret = typeof body.secret === 'string' ? body.secret.trim() : ''
|
|
581
|
+
const serverSha = acceptSha(body.licenseFileSha256)
|
|
582
|
+
const clientSha = licenseFile && typeof licenseFile === 'object' ? payloadSha256(licenseFile) : null
|
|
583
|
+
const missing = []
|
|
584
|
+
if (!licenseFile || typeof licenseFile !== 'object' || Array.isArray(licenseFile)) missing.push('licenseFile')
|
|
585
|
+
if (acceptSecret && !serverSecret) missing.push('secret(请求了 acceptSecret:true,响应却没给)')
|
|
586
|
+
if (missing.length) {
|
|
587
|
+
const json = {
|
|
588
|
+
ok: false, empty: false, code: 1, reason: 'activation_bad_response',
|
|
589
|
+
error: redact(`服务端响应缺字段:${missing.join('、')}`),
|
|
590
|
+
hint: CLIENT_ERROR_MAP.activation_bad_response.hint, ...shell(),
|
|
591
|
+
redemption: { ...redemption, seat: body.seat || null, serverSha, clientSha },
|
|
592
|
+
}
|
|
593
|
+
return finish(json, `❌ activation_bad_response:${json.error}\n 下一步:${json.hint}`, 1)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const warnings = []
|
|
597
|
+
if (serverSha && clientSha && serverSha !== clientSha) {
|
|
598
|
+
// 不据此拒绝(安全判据是 HMAC 验签,归 install-license);但必须如实标注 —— 便于与服务端审计行对账
|
|
599
|
+
warnings.push(`响应里的 licenseFileSha256 与本机复算不一致(服务端 ${String(serverSha).slice(0, 16)}… ≠ 本机 ${String(clientSha).slice(0, 16)}…)⇒ 请与服务端审计行核对`)
|
|
600
|
+
}
|
|
601
|
+
if (!acceptSecret && serverSecret) {
|
|
602
|
+
warnings.push('本机已有密钥(或用了 --secret-file)⇒ **忽略**响应里的 secret,不覆盖本机密钥')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 临时目录:把兑换产物交给 install-license(**唯一落盘判据**)读;无论成败都删掉
|
|
606
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'argent-activate-'))
|
|
607
|
+
try {
|
|
608
|
+
const tmpLicense = join(tmpDir, 'license.json')
|
|
609
|
+
writeFileSync(tmpLicense, `${JSON.stringify(licenseFile, null, 2)}\n`, { mode: 0o600 })
|
|
610
|
+
chmodSync(tmpLicense, 0o600)
|
|
611
|
+
const childArgs = ['--file', tmpLicense, '--json']
|
|
612
|
+
let tmpSecret = null
|
|
613
|
+
if (secretSource === 'server' && serverSecret) {
|
|
614
|
+
tmpSecret = join(tmpDir, 'license.secret')
|
|
615
|
+
writeFileSync(tmpSecret, `${serverSecret}\n`, { mode: 0o600 })
|
|
616
|
+
chmodSync(tmpSecret, 0o600)
|
|
617
|
+
}
|
|
618
|
+
if (secretSource === 'explicit' && explicitSecretPath) childArgs.push('--secret-file', explicitSecretPath)
|
|
619
|
+
else if (tmpSecret) childArgs.push('--secret-file', tmpSecret)
|
|
620
|
+
// A4 守卫的逃生阀:**逐字透传**给 install-license(判定只发生在那里:`regression_refused` 的判据不自建第二份)
|
|
621
|
+
if (allowRegression) childArgs.push('--allow-regression')
|
|
622
|
+
const script = join(repo, 'scripts', 'install-license.mjs')
|
|
623
|
+
const r = spawn(process.execPath, [script, ...childArgs], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, env })
|
|
624
|
+
const childStatus = r && r.status !== null && r.status !== undefined ? Number(r.status) : 1
|
|
625
|
+
const childOut = String((r && r.stdout) || '')
|
|
626
|
+
let inst = null
|
|
627
|
+
try { inst = JSON.parse(childOut.slice(childOut.indexOf('{'))) } catch { inst = null }
|
|
628
|
+
if (!inst) {
|
|
629
|
+
const json = {
|
|
630
|
+
ok: false, empty: false, code: 1, reason: 'bad_output',
|
|
631
|
+
error: `install-license 输出不可解析(exit=${childStatus}):${String((r && r.stderr) || childOut).trim().slice(0, 300)}`,
|
|
632
|
+
...shell(),
|
|
633
|
+
redemption: { ...redemption, seat: body.seat || null, serverSha, clientSha },
|
|
634
|
+
}
|
|
635
|
+
return finish(json, `❌ bad_output:${json.error}`, 1)
|
|
636
|
+
}
|
|
637
|
+
// 退出码逐字透传(install-license:0 已安装/幂等 | 1 验签/设备/倒退被拒 | 2 用法)
|
|
638
|
+
const exit = childStatus === 0 ? 0 : (childStatus === 1 ? 1 : 2)
|
|
639
|
+
// A4:本次是否**真的**放行了一次倒退(而不是"带了标志但没倒退")—— 只在真放行时才标注,
|
|
640
|
+
// 否则告警会变成噪音,运营就学会忽略它了。
|
|
641
|
+
const cmp = inst && inst.comparison && typeof inst.comparison === 'object' ? inst.comparison : null
|
|
642
|
+
const releasedRegression = exit === 0 && inst.allowRegression === true && Boolean(cmp)
|
|
643
|
+
&& (cmp.stale === true || cmp.narrowed === true)
|
|
644
|
+
const regressionWhy = releasedRegression
|
|
645
|
+
? [
|
|
646
|
+
cmp.stale === true ? `更早到期(现役 ${cmp.incumbentExpiresAt} → 新 ${cmp.incomingExpiresAt})` : '',
|
|
647
|
+
cmp.narrowed === true
|
|
648
|
+
? `能力/业务线/席位被收窄(缺 ${[...(cmp.missingCapabilities || []), ...(cmp.missingVerticals || [])].join(',') || '—'}`
|
|
649
|
+
+ `${cmp.seatsNarrowed ? `;席位被收窄` : ''})` : '',
|
|
650
|
+
].filter(Boolean).join(';')
|
|
651
|
+
: ''
|
|
652
|
+
const json = {
|
|
653
|
+
// 判据落点(install-license)的关键字段**显式**抄到顶层(不用 spread:两边都有 `device`/`target`,spread 会互相覆盖)
|
|
654
|
+
ok: exit === 0,
|
|
655
|
+
empty: false,
|
|
656
|
+
code: exit,
|
|
657
|
+
reason: exit === 0 ? null : (inst.reason || (exit === 1 ? 'guard_rejected' : 'usage_error')),
|
|
658
|
+
error: exit === 0 ? null : (inst.error || null),
|
|
659
|
+
...shell(),
|
|
660
|
+
applied: exit === 0,
|
|
661
|
+
redemption: {
|
|
662
|
+
...redemption, seat: body.seat || null, reused: body.reused === true,
|
|
663
|
+
state: typeof body.state === 'string' ? redact(body.state).slice(0, 32) : null,
|
|
664
|
+
serverSha, clientSha, codePrefix: acceptCodePrefix(pickText(body, ['codePrefix']), codePrefix),
|
|
665
|
+
},
|
|
666
|
+
// 判据落点的原始输出(**原样**透传,便于排障;不含明文码)
|
|
667
|
+
install: inst,
|
|
668
|
+
signatureVersion: inst.signatureVersion === 2 ? 2 : (inst.signatureVersion === 1 ? 1 : null),
|
|
669
|
+
deviceSigned: inst.deviceSigned === true,
|
|
670
|
+
unchanged: inst.unchanged === true,
|
|
671
|
+
// A4 逃生阀的**如实回显**:`allowRegression` = 本次是否带了标志(透传自 install-license);
|
|
672
|
+
// `regressionReleased` = 它是否**真的**放行了一次倒退(带了标志但没倒退 ⇒ false,不虚报)。
|
|
673
|
+
allowRegression: inst.allowRegression === true,
|
|
674
|
+
regressionReleased: releasedRegression,
|
|
675
|
+
// 显式标注(人话;`--json` 时同一句还会写 stderr)—— 逃生阀用一次要留一次字据
|
|
676
|
+
regressionNote: releasedRegression ? `⚠️ 本次放行了一次倒退(--allow-regression):${regressionWhy}` : null,
|
|
677
|
+
device: inst.device || null,
|
|
678
|
+
comparison: inst.comparison || null,
|
|
679
|
+
renewal: inst.renewal || null,
|
|
680
|
+
backups: inst.backups || [],
|
|
681
|
+
userId: inst.userId || '', seats: inst.seats, capabilities: inst.capabilities || [], verticals: inst.verticals || [],
|
|
682
|
+
expiresAt: inst.expiresAt || null, graceDays: inst.graceDays, licenseFile: inst.source, targetFile: inst.target,
|
|
683
|
+
warnings: [...warnings, ...((inst && inst.warnings) || [])],
|
|
684
|
+
judgedBy: 'scripts/install-license.mjs(验签/设备比对/0600/备份/读回/幂等/拒绝倒退)',
|
|
685
|
+
}
|
|
686
|
+
const human = exit === 0
|
|
687
|
+
? `✅ 已激活:一次性码(前缀 ${codePrefix}…)→ ${targetLicense}`
|
|
688
|
+
+ `\n 账号:${inst.userId} | 席位:${inst.seats} | 到期:${inst.expiresAt}`
|
|
689
|
+
+ `\n 签名版本:${inst.signatureVersion === 2 ? 'v2(device 已进签名)' : (inst.signatureVersion === 1 ? 'v1(device 未进签名)' : '判不出')}`
|
|
690
|
+
+ ` | 设备绑定:${inst.device ? `${inst.device.binding}(${inst.device.reason})` : '(未声明)'}`
|
|
691
|
+
+ (inst.unchanged ? '\n ♻️ 与现役许可逐字节一致 ⇒ 未重写、未备份(幂等)' : '')
|
|
692
|
+
+ (releasedRegression
|
|
693
|
+
? `\n ${json.regressionNote}`
|
|
694
|
+
+ '\n ↳ 这是**签字确认**而不是修复:现役有效期/能力已被这份许可覆盖,'
|
|
695
|
+
+ '下一步请核对权益(⛔ 续期流程不该常态化带这个标志)'
|
|
696
|
+
: '')
|
|
697
|
+
+ `\n 自检:argent license status --json(期望 signatureVersion:2 ∧ deviceSigned:true ∧ device.binding:match)`
|
|
698
|
+
: `❌ 兑换成功但**落盘被拒**(${json.reason}):${inst.error || ''}\n ⛔ 原许可一个字节未动(判据在 scripts/install-license.mjs)`
|
|
699
|
+
+ (json.reason === 'regression_refused'
|
|
700
|
+
? '\n ↳ 确需放行这次倒退(纠错/吊销/换绑)⇒ **重跑并加 `--allow-regression`**(不是 `--rebind`);'
|
|
701
|
+
+ '⛔ 但先核对是不是拿错了权益 —— 新许可比现役更早到期/被收窄时,续期会被**每次**打回'
|
|
702
|
+
: '')
|
|
703
|
+
return finish(json, human, exit)
|
|
704
|
+
} finally {
|
|
705
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* 临时目录清不掉不影响结论 */ }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* 超时(默认 15s):可用 `ARGENT_LICENSE_ACTIVATE_TIMEOUT_MS` 覆盖(慢网络/测试桩用),
|
|
711
|
+
* 夹在 [200, 120000] 之间 —— 下界防"设成 0 等于必超时",上界防"设成一天"把 CLI 挂死。
|
|
712
|
+
*/
|
|
713
|
+
export function resolveTimeoutMs(env = {}) {
|
|
714
|
+
const raw = Number(String((env && env.ARGENT_LICENSE_ACTIVATE_TIMEOUT_MS) || '').trim())
|
|
715
|
+
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_TIMEOUT_MS
|
|
716
|
+
return Math.min(120000, Math.max(200, Math.round(raw)))
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** 读 `--code-file`(0600 建议;**不回显内容**)。 */function readCodeFile(p) {
|
|
720
|
+
const path = resolve(String(p))
|
|
721
|
+
if (!existsSync(path)) return { __error: `--code-file 指向的文件不存在:${path}` }
|
|
722
|
+
try {
|
|
723
|
+
const t = readFileSync(path, 'utf8').trim()
|
|
724
|
+
if (!t) return { __error: `--code-file 是空文件:${path}` }
|
|
725
|
+
return t.split('\n')[0].trim() // 只取第一行(文件里可能带说明行)
|
|
726
|
+
} catch (e) {
|
|
727
|
+
return { __error: `--code-file 读不出来(${path}):${(e && e.message) || e}` }
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** 客户端版本(仅用于服务端审计/排障;读不到就不带该字段)。 */
|
|
732
|
+
function clientVersionOf(repo) {
|
|
733
|
+
try { return String(readFileSync(join(repo, 'VERSION'), 'utf8')).trim() || null } catch { return null }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** 从响应里挑一个**非空字符串**字段(白名单式取值:绝不把响应体透传出去)。 */
|
|
737
|
+
function pickText(body, keys) {
|
|
738
|
+
if (!body || typeof body !== 'object') return ''
|
|
739
|
+
for (const k of keys) {
|
|
740
|
+
const v = body[k]
|
|
741
|
+
if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 500)
|
|
742
|
+
}
|
|
743
|
+
return ''
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* POST JSON(唯一网络出口)。
|
|
748
|
+
* @returns {Promise<{status:number|null, body:object|null, text:string, network:string|null, error:string}>}
|
|
749
|
+
*/
|
|
750
|
+
async function postJson({ endpoint, body, timeoutMs, fetchImpl }) {
|
|
751
|
+
const ctl = new AbortController()
|
|
752
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs)
|
|
753
|
+
try {
|
|
754
|
+
const res = await fetchImpl(endpoint, {
|
|
755
|
+
method: 'POST',
|
|
756
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
757
|
+
body: JSON.stringify(body),
|
|
758
|
+
signal: ctl.signal,
|
|
759
|
+
})
|
|
760
|
+
const text = String(await res.text())
|
|
761
|
+
let parsed = null
|
|
762
|
+
try { parsed = JSON.parse(text) } catch { parsed = null }
|
|
763
|
+
return { status: Number(res.status), body: parsed, text, network: null, error: '' }
|
|
764
|
+
} catch (e) {
|
|
765
|
+
const msg = String((e && e.message) || e)
|
|
766
|
+
const timeout = (e && e.name === 'AbortError') || /abort/i.test(msg)
|
|
767
|
+
return {
|
|
768
|
+
status: null, body: null, text: '', network: timeout ? 'timeout' : 'unreachable',
|
|
769
|
+
error: timeout ? `请求超时(${timeoutMs}ms):${endpoint}` : `连不上 ${endpoint}:${msg}`,
|
|
770
|
+
}
|
|
771
|
+
} finally {
|
|
772
|
+
clearTimeout(timer)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* 审计行(**只落 codePrefix,绝不落明文码/密钥**;apply 的成败都留一行)。
|
|
778
|
+
* 字段口径对齐方案 §9.4"审计事件"(客户端侧):服务端另有一份(它才记 `ipHash`/`uaHash`)。
|
|
779
|
+
*/
|
|
780
|
+
function auditRecord(json, { codePrefix, device, deviceSource, server, secretSource }) {
|
|
781
|
+
return {
|
|
782
|
+
at: new Date().toISOString(),
|
|
783
|
+
verb: 'license.activate',
|
|
784
|
+
ok: json.ok === true,
|
|
785
|
+
code: json.code,
|
|
786
|
+
reason: json.reason || null,
|
|
787
|
+
subcommand: 'activate',
|
|
788
|
+
activation: {
|
|
789
|
+
codePrefix, codeLength: ACTIVATION_CODE_LENGTH,
|
|
790
|
+
requestId: (json.request && json.request.activation && json.request.activation.requestId) || null,
|
|
791
|
+
},
|
|
792
|
+
server: { host: safeHost(server.baseUrl), endpoint: server.endpoint, source: server.source, tls: server.tls },
|
|
793
|
+
device: { source: deviceSource, code: device ? device.code : null, algo: device ? device.algo : null },
|
|
794
|
+
secret: { source: secretSource },
|
|
795
|
+
redemption: json.redemption ? {
|
|
796
|
+
httpStatus: json.redemption.httpStatus ?? null,
|
|
797
|
+
reused: json.redemption.reused === true,
|
|
798
|
+
serverSha: json.redemption.serverSha || null,
|
|
799
|
+
clientSha: json.redemption.clientSha || null,
|
|
800
|
+
} : null,
|
|
801
|
+
result: json.install ? {
|
|
802
|
+
licenseFile: json.install.target, signatureVersion: json.install.signatureVersion ?? null,
|
|
803
|
+
deviceSigned: json.install.deviceSigned === true, unchanged: json.install.unchanged === true,
|
|
804
|
+
backups: (json.install.backups || []).length, binding: json.install.device ? json.install.device.binding : null,
|
|
805
|
+
} : null,
|
|
806
|
+
dataDir: (json.request && json.request.target && json.request.target.dataDir) || null,
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** 审计里只留 host(不落完整 URL 的查询串之类)。 */
|
|
811
|
+
function safeHost(baseUrl) {
|
|
812
|
+
try { return new URL(baseUrl).host } catch { return String(baseUrl || '') }
|
|
813
|
+
}
|