@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,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-panels.mjs —— 能力包「声明式设置页」聚合层(0.2 契约,2026-09-30)
|
|
3
|
+
*
|
|
4
|
+
* ## 它解决什么
|
|
5
|
+
* 用户诉求:**能力包是插拔式的** —— 装了就在设置里出现它那一页,没装就不出现;
|
|
6
|
+
* 不要再把所有字段挤在一个 Argent 页里。落地方式分三层(详见
|
|
7
|
+
* `docs/能力包设置页契约.md`):
|
|
8
|
+
* ① pack 侧**声明**:`verticals/<id>/manifest.json` 的可选字段 `settingsPanel`
|
|
9
|
+
* (或数组 `settingsPanels`);无 vertical 的引擎级能力写在注册表
|
|
10
|
+
* `capabilities.<c>.pack.settingsPanel`。**能力包不需要自带客户端 bundle**。
|
|
11
|
+
* ② host 侧**聚合**(本模块):把「声明 + 是否已装 + 是否启用 + 字段当前值」读成一份稳定 JSON;
|
|
12
|
+
* `GET /api/argent-gateway/settings-panels` 与 `scripts/argent-catalog.mjs` 都消费它。
|
|
13
|
+
* ③ 客户端**渲染**:按 catalog 生成页签,只渲染 **installed === true** 的面板。
|
|
14
|
+
* ⇒「未安装即不显示」由数据决定,不靠前端 if 名单(前端写名单一定会漂移)。
|
|
15
|
+
*
|
|
16
|
+
* ## 两条设计红线
|
|
17
|
+
* 1. **不改判据来源**:字段值的读法只有四种(config / env / json-from-env / roles-from-gateway),
|
|
18
|
+
* 写入口只允许**已存在的宿主端点**(形状约束)。本模块**不新建**密钥存储、
|
|
19
|
+
* 不写 `~/.dsh/settings.yaml`、不发明第二份真相。
|
|
20
|
+
* 2. **密码字段绝不出明文**:`type: "password"` 只回 `configured` 与 `masked`,
|
|
21
|
+
* `value` 恒为空串(即便声明方写错也不泄)。
|
|
22
|
+
*
|
|
23
|
+
* ## 0.2 续做(2026-10-01)
|
|
24
|
+
* · **面板级档位 `panel.minPlan`**:不写就用「绑定能力里最低的那个 minPlan」派生
|
|
25
|
+
* (注册表 `capabilities.<c>.minPlan` 是唯一档位表,**不新建第二份**);
|
|
26
|
+
* 账号档位够不够由**服务端**(host 端点 / 仓库脚本)算成 `planGated` 布尔 —— 客户端仍然零名单。
|
|
27
|
+
* 账号档位**取不到时不做任何过滤**(与 `assertFeishuAllowed` 同口径:宁可放行,不误锁)。
|
|
28
|
+
* · **字段级档位 `field.minPlan`**(同日晚补):与面板级**同一条档位链、同一个 planRank**,
|
|
29
|
+
* 只是粒度更细("这一页 pro 就有,但其中某个字段要 enterprise")。过滤同样在**服务端**:
|
|
30
|
+
* 被滤字段从 `panels[].fields[]` 里整个消失,只在 `hiddenByPlan[]` 留一条 `kind: 'field'`
|
|
31
|
+
* 的原因,面板上的 `fieldsHiddenByPlan` 给个计数。声明必须**比面板级更严**,否则报
|
|
32
|
+
* `field-minplan-unreachable`(不可达声明 = 死代码,不静默)。
|
|
33
|
+
* · **受控 env 写入器**(契约 §11):`scope: vertical|user` 的字段原本只读,现在可以在
|
|
34
|
+
* **显式开关**(`ARGENT_ENV_WRITER=1`)打开后受控写入 —— 目标路径**只由声明推导**
|
|
35
|
+
* (请求体里不带任何路径)、写前备份、写后读回、审计 JSONL、只落 `~/.dsh/argent/**`。
|
|
36
|
+
*
|
|
37
|
+
* 模块是**只读 + 纯函数**(除了读文件),无副作用 ⇒ 可以用假 HOME / 夹具仓库离线单测
|
|
38
|
+
* (`scripts/tests/settings-panels.test.mjs`)。真正的写入在 `scripts/argent-env-write.mjs`
|
|
39
|
+
* (单独一个脚本,写盘/备份/审计/读回都在那里;本模块只负责"能不能写、写到哪")。
|
|
40
|
+
*/
|
|
41
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
42
|
+
import { join, resolve as resolvePath, sep } from 'node:path'
|
|
43
|
+
import { resolveDataDir } from './argent-datadir.mjs'
|
|
44
|
+
|
|
45
|
+
// ── 契约枚举(客户端与文档必须与这里一致)────────────────────────────
|
|
46
|
+
/** 字段类型:text 文本框 | password 密码框(只回掩码)| switch 开关 | select 下拉。 */
|
|
47
|
+
export const FIELD_TYPES = ['text', 'password', 'switch', 'select']
|
|
48
|
+
/** 值作用域:vertical = 该业务线的 env 目录;user = 数据目录根(跨业务线共用)。 */
|
|
49
|
+
export const FIELD_SCOPES = ['vertical', 'user']
|
|
50
|
+
/**
|
|
51
|
+
* 只读取值来源:
|
|
52
|
+
* · config = `~/.dsh/argent/config.json`
|
|
53
|
+
* · env = env 文件(目录按 scope)
|
|
54
|
+
* · json = `pathFromEnv` 指向的 JSON(路径只来自 env)
|
|
55
|
+
* · roles = `~/.dsh/argent/gateway.json` 的角色装配状态(只读摘要;给"入口=角色"的能力用)
|
|
56
|
+
*/
|
|
57
|
+
export const READ_SOURCES = ['config', 'env', 'json', 'roles']
|
|
58
|
+
/** env 写入器的宿主端点(形状必须在 {@link WRITE_ENDPOINT_PREFIX} 内)。 */
|
|
59
|
+
export const ENV_WRITE_ENDPOINT = '/api/argent-state/env'
|
|
60
|
+
/** env 写入器的显式开关:**默认关闭**(不开就只读,端点也会 403)。 */
|
|
61
|
+
export const ENV_WRITER_ENV = 'ARGENT_ENV_WRITER'
|
|
62
|
+
/** 可写的 env 文件名形状:只允许 `<名>.env`,不得含路径分隔符/上跳。 */
|
|
63
|
+
export const ENV_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.env$/
|
|
64
|
+
/**
|
|
65
|
+
* 允许被面板字段写入的宿主端点**形状**(不是名单)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么不用名单:名单会把各业务线的端点名写进引擎文件 —— 既触发
|
|
68
|
+
* `verticals/_engine-words.json` 门禁(引擎文件不得含业务词),又意味着"每加一个业务线就要改引擎"。
|
|
69
|
+
* 契约改成形状约束:只允许**本机 Argent 自己的配置端点**(`/api/argent-state/` 前缀、相对路径、
|
|
70
|
+
* 不带查询串),业务线在 `verticals/<id>/manifest.json` 里指名具体端点。
|
|
71
|
+
* 安全性由两层兜底:① 形状(禁止外链/穿越);② 端点自身的校验(如 config 端点的 CONFIG_KEYS 白名单)。
|
|
72
|
+
* 不满足形状 ⇒ 该字段自动降级为只读(客户端渲染只读徽章,不发出请求)。
|
|
73
|
+
*/
|
|
74
|
+
export const WRITE_ENDPOINT_PREFIX = '/api/argent-state/'
|
|
75
|
+
export function isAllowedWriteEndpoint(ep) {
|
|
76
|
+
const s = String(ep || '')
|
|
77
|
+
return s.startsWith(WRITE_ENDPOINT_PREFIX) && !s.includes('//') && !s.includes('?') && !s.includes('..')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const str = (v, d = '') => (v === null || v === undefined ? d : String(v))
|
|
81
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
82
|
+
|
|
83
|
+
/** 注册表里的档位序列(`entitlements.planTiers`;缺省 free < pro < enterprise)。 */
|
|
84
|
+
export function planTiers(registryDoc) {
|
|
85
|
+
const t = ((registryDoc || {}).entitlements || {}).planTiers
|
|
86
|
+
return Array.isArray(t) && t.length ? t.map((x) => str(x)) : ['free', 'pro', 'enterprise']
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 档位排序(**未知/缺失 → -1**:比最低档还低 ⇒ 保守不放行)。 */
|
|
90
|
+
export function planRank(plan, tiers) {
|
|
91
|
+
const i = (Array.isArray(tiers) ? tiers : []).indexOf(str(plan))
|
|
92
|
+
return i < 0 ? -1 : i
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** env 写入器是否打开(**默认关闭**:显式 `ARGENT_ENV_WRITER=1` 才算开)。 */
|
|
96
|
+
export function isEnvWriterEnabled(env) {
|
|
97
|
+
return str((env || {}).ARGENT_ENV_WRITER).trim() === '1'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 掩码:与 host 侧 `maskSecret` 同口径(短串整体打码,绝不回明文)。 */
|
|
101
|
+
export function maskSecret(v) {
|
|
102
|
+
const s = str(v)
|
|
103
|
+
if (!s) return ''
|
|
104
|
+
if (s.length <= 8) return '******'
|
|
105
|
+
return s.slice(0, 3) + '****' + s.slice(-3)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── ① 声明解析(pack 侧)─────────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* 归一化单个字段。返回 `{ field, errors }`;`errors` 为空即合法。
|
|
111
|
+
* 合法形状:`{ key, label, type?, env?, scope?, placeholder?, hint?, options?, minPlan?, bind? }`
|
|
112
|
+
* · `key` —— 面板内唯一(也是客户端表单的键)
|
|
113
|
+
* · `minPlan` —— **字段级档位**(可选):账号档位低于它 ⇒ 服务端把该字段从载荷里滤掉
|
|
114
|
+
* (进 `hiddenByPlan[]`)。与面板级**同口径**:档位序列读注册表 `entitlements.planTiers`,
|
|
115
|
+
* 比较用同一个 {@link planRank},**不新建第二份档位表**;账号档位取不到 ⇒ 不过滤。
|
|
116
|
+
* 刻意**不从能力派生**(字段没有"绑定能力"这回事;面板级才是派生口径)。
|
|
117
|
+
* · `bind.read` —— `{ from: 'config'|'env'|'json', key?, pathFromEnv?, mode? }`(缺省按 type/env 推断)
|
|
118
|
+
* · `bind.write` —— `{ endpoint, field? }`(endpoint 必须在 WRITE_ENDPOINTS 白名单内,否则降级只读)
|
|
119
|
+
*/
|
|
120
|
+
export function normalizeField(raw, ctx = {}) {
|
|
121
|
+
const errors = []
|
|
122
|
+
const where = `${ctx.panelId || '?'}#${str(raw && raw.key) || '?'}`
|
|
123
|
+
if (!isObj(raw)) return { field: null, errors: [{ panel: ctx.panelId || '', code: 'field-not-object', message: `${where} 不是对象` }] }
|
|
124
|
+
const key = str(raw.key).trim()
|
|
125
|
+
if (!key) errors.push({ panel: ctx.panelId || '', code: 'field-key-missing', message: `${where} 缺 key` })
|
|
126
|
+
const label = str(raw.label).trim()
|
|
127
|
+
if (!label) errors.push({ panel: ctx.panelId || '', code: 'field-label-missing', message: `${where} 缺 label(用户看不懂字段名 ⇒ 配不对)` })
|
|
128
|
+
const type = str(raw.type, 'text').trim() || 'text'
|
|
129
|
+
if (!FIELD_TYPES.includes(type)) errors.push({ panel: ctx.panelId || '', code: 'field-type-unknown', message: `${where} type=${type} 不在 ${FIELD_TYPES.join('|')}` })
|
|
130
|
+
const scope = str(raw.scope, 'vertical').trim() || 'vertical'
|
|
131
|
+
if (!FIELD_SCOPES.includes(scope)) errors.push({ panel: ctx.panelId || '', code: 'field-scope-unknown', message: `${where} scope=${scope} 不在 ${FIELD_SCOPES.join('|')}` })
|
|
132
|
+
const options = Array.isArray(raw.options) ? raw.options.map((o) => str(o)).filter(Boolean) : []
|
|
133
|
+
if (type === 'select' && !options.length) errors.push({ panel: ctx.panelId || '', code: 'field-select-no-options', message: `${where} select 必须给 options` })
|
|
134
|
+
// 字段级档位:档位序列与面板级**同一份**(注册表 planTiers);不在册 ⇒ 报错(不静默忽略,
|
|
135
|
+
// 否则"我明明写了 minPlan 却没生效"根本查不出来)。
|
|
136
|
+
const tiers = planTiers(ctx.registry)
|
|
137
|
+
const rawMin = str(raw.minPlan).trim()
|
|
138
|
+
if (rawMin && !tiers.includes(rawMin)) {
|
|
139
|
+
errors.push({ panel: ctx.panelId || '', code: 'field-minplan-unknown', message: `${where} minPlan=${rawMin} 不在注册表 planTiers(${tiers.join('|')})内` })
|
|
140
|
+
}
|
|
141
|
+
const minPlan = rawMin && tiers.includes(rawMin) ? rawMin : ''
|
|
142
|
+
const env = str(raw.env).trim()
|
|
143
|
+
// envFile:env 字段写入哪个文件(**相对**名,如 `fulfillment.env`)。不声明 ⇒ 该字段只读
|
|
144
|
+
//(绝不猜路径 —— 这是写入器"拒绝任意路径"的第一道口子)。
|
|
145
|
+
const envFile = str(raw.envFile).trim()
|
|
146
|
+
if (envFile && !ENV_FILE_RE.test(envFile)) {
|
|
147
|
+
errors.push({ panel: ctx.panelId || '', code: 'field-envfile-invalid', message: `${where} envFile=${envFile} 形状非法(只允许 <名>.env,不含路径分隔符/上跳)——该字段会渲染为只读` })
|
|
148
|
+
}
|
|
149
|
+
const bind = isObj(raw.bind) ? raw.bind : null
|
|
150
|
+
if (!env && !bind) errors.push({ panel: ctx.panelId || '', code: 'field-no-source', message: `${where} 既没有 env 也没有 bind ⇒ 不知道该读哪里` })
|
|
151
|
+
const read = isObj(bind && bind.read) ? bind.read : null
|
|
152
|
+
const from = read ? str(read.from, '').trim() : ''
|
|
153
|
+
if (read && !READ_SOURCES.includes(from)) errors.push({ panel: ctx.panelId || '', code: 'field-read-source-unknown', message: `${where} bind.read.from=${from} 不在 ${READ_SOURCES.join('|')}` })
|
|
154
|
+
if (read && from === 'json' && !str(read.pathFromEnv).trim()) errors.push({ panel: ctx.panelId || '', code: 'field-json-needs-env', message: `${where} bind.read.from=json 必须给 pathFromEnv(路径只能来自 env,不接任意路径)` })
|
|
155
|
+
let write = null
|
|
156
|
+
if (isObj(bind && bind.write)) {
|
|
157
|
+
const endpoint = str(bind.write.endpoint).trim()
|
|
158
|
+
const payloadKey = str(bind.write.field).trim() || key // 请求体字段名(缺省用字段 key)
|
|
159
|
+
if (!isAllowedWriteEndpoint(endpoint)) {
|
|
160
|
+
errors.push({ panel: ctx.panelId || '', code: 'field-write-endpoint-not-allowed', message: `${where} bind.write.endpoint=${endpoint} 不符合写入口形状(须为 ${WRITE_ENDPOINT_PREFIX}* 的相对路径且无查询串)——该字段会渲染为只读` })
|
|
161
|
+
} else {
|
|
162
|
+
write = { endpoint, field: payloadKey }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const field = {
|
|
166
|
+
key, label, type, scope,
|
|
167
|
+
env: env || (read && from === 'env' ? str(read.key) : ''),
|
|
168
|
+
envFile: ENV_FILE_RE.test(envFile) ? envFile : '',
|
|
169
|
+
placeholder: str(raw.placeholder),
|
|
170
|
+
hint: str(raw.hint),
|
|
171
|
+
options,
|
|
172
|
+
minPlan,
|
|
173
|
+
read: read ? { from, key: str(read.key), pathFromEnv: str(read.pathFromEnv), mode: str(read.mode, 'keys') } : null,
|
|
174
|
+
write,
|
|
175
|
+
}
|
|
176
|
+
return { field, errors }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 归一化一个面板声明。`ctx.source` 记录声明位置(排障用),`ctx.vertical` 由声明位置补齐。
|
|
181
|
+
* 合法形状:`{ id, title, order?, capability?, vertical?, hint?, fields: [...] }`
|
|
182
|
+
*/
|
|
183
|
+
export function normalizePanel(raw, ctx = {}) {
|
|
184
|
+
const errors = []
|
|
185
|
+
const id = str(raw && raw.id).trim()
|
|
186
|
+
const title = str(raw && raw.title).trim()
|
|
187
|
+
if (!isObj(raw)) return { panel: null, errors: [{ panel: '', code: 'panel-not-object', message: `settingsPanel 不是对象(来源 ${ctx.source || '?'})` }] }
|
|
188
|
+
if (!id) errors.push({ panel: '', code: 'panel-id-missing', message: `settingsPanel 缺 id(来源 ${ctx.source || '?'})` })
|
|
189
|
+
if (!title) errors.push({ panel: id, code: 'panel-title-missing', message: `面板 ${id} 缺 title(页签上要显示)` })
|
|
190
|
+
const fields = []
|
|
191
|
+
const rawFields = Array.isArray(raw.fields) ? raw.fields : []
|
|
192
|
+
if (!rawFields.length) errors.push({ panel: id, code: 'panel-no-fields', message: `面板 ${id} 没有 fields` })
|
|
193
|
+
const seen = new Set()
|
|
194
|
+
for (const rf of rawFields) {
|
|
195
|
+
// registry 要传下去:字段级 minPlan 的合法性判据 = 注册表 planTiers(与面板级同一份)
|
|
196
|
+
const r = normalizeField(rf, { panelId: id, registry: ctx.registry })
|
|
197
|
+
errors.push(...r.errors)
|
|
198
|
+
if (!r.field) continue
|
|
199
|
+
if (seen.has(r.field.key)) { errors.push({ panel: id, code: 'field-key-duplicate', message: `面板 ${id} 字段 key 重复:${r.field.key}` }); continue }
|
|
200
|
+
seen.add(r.field.key)
|
|
201
|
+
fields.push(r.field)
|
|
202
|
+
}
|
|
203
|
+
// 面板可绑**一个**能力(`capability`)或**一组**能力(`capabilities`):
|
|
204
|
+
// 业务线级面板(一个业务线常常横跨多个能力包)⇒ 用"任一已装即显示"的语义,
|
|
205
|
+
// 避免"只装了 invoice 就看不到飞书表配置"这种自相矛盾的插拔行为。
|
|
206
|
+
const caps = (Array.isArray(raw.capabilities) ? raw.capabilities : []).map((c) => str(c).trim()).filter(Boolean)
|
|
207
|
+
const one = str(raw.capability).trim()
|
|
208
|
+
if (one && !caps.includes(one)) caps.unshift(one)
|
|
209
|
+
const panel = {
|
|
210
|
+
id, title,
|
|
211
|
+
order: Number.isFinite(Number(raw.order)) ? Number(raw.order) : 100,
|
|
212
|
+
vertical: str(raw.vertical, ctx.vertical || '').trim(),
|
|
213
|
+
capabilities: caps,
|
|
214
|
+
hint: str(raw.hint),
|
|
215
|
+
source: str(ctx.source),
|
|
216
|
+
fields,
|
|
217
|
+
}
|
|
218
|
+
// 面板级档位:显式声明的 minPlan 必须在注册表 planTiers 内;不声明则由**绑定能力的 minPlan**
|
|
219
|
+
// 派生(`capabilities.<c>.minPlan` 是唯一档位表)。派生失败(没绑能力/能力不在册)⇒ 不设门槛。
|
|
220
|
+
const tiers = planTiers(ctx.registry)
|
|
221
|
+
const declaredMin = str(raw.minPlan).trim()
|
|
222
|
+
if (declaredMin && !tiers.includes(declaredMin)) {
|
|
223
|
+
errors.push({ panel: id, code: 'panel-minplan-unknown', message: `面板 ${id} 的 minPlan=${declaredMin} 不在注册表 planTiers(${tiers.join('|')})内` })
|
|
224
|
+
}
|
|
225
|
+
let minPlan = ''
|
|
226
|
+
let minPlanSource = ''
|
|
227
|
+
if (declaredMin && tiers.includes(declaredMin)) {
|
|
228
|
+
minPlan = declaredMin
|
|
229
|
+
minPlanSource = 'declared'
|
|
230
|
+
} else if (!declaredMin) {
|
|
231
|
+
const capDoc = (ctx.registry && ctx.registry.capabilities) || {}
|
|
232
|
+
const got = caps.map((c) => str((capDoc[c] || {}).minPlan)).filter((p) => tiers.includes(p))
|
|
233
|
+
if (got.length) {
|
|
234
|
+
// 「任一已装即显示」⇒ 取**最低**档:账号够其中一个能力,这一页就有用
|
|
235
|
+
minPlan = got.reduce((a, b) => (tiers.indexOf(b) < tiers.indexOf(a) ? b : a))
|
|
236
|
+
minPlanSource = 'derived'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
panel.minPlan = minPlan
|
|
240
|
+
panel.minPlanSource = minPlanSource
|
|
241
|
+
// 字段级档位必须比面板级**更严**,否则那条声明**永远不可达**(面板先被滤掉了,字段级根本轮不到)
|
|
242
|
+
// —— 不可达声明按"死代码"处理:报错,不静默(本仓库在 dsh-restart.sh 的 exit 之后吃过同款亏)。
|
|
243
|
+
if (fields.some((f) => f.minPlan)) {
|
|
244
|
+
const panelRank = minPlan ? planRank(minPlan, tiers) : -1
|
|
245
|
+
for (const f of fields) {
|
|
246
|
+
if (!f.minPlan) continue
|
|
247
|
+
if (planRank(f.minPlan, tiers) <= panelRank) {
|
|
248
|
+
errors.push({
|
|
249
|
+
panel: id, code: 'field-minplan-unreachable',
|
|
250
|
+
message: `面板 ${id} 字段 ${f.key} 的 minPlan=${f.minPlan} 不高于面板级(${minPlan || '不限'})⇒ 该声明永远不可达(要它有意义就得比面板级更严;否则删掉)`,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!caps.length && !panel.vertical) errors.push({ panel: id, code: 'panel-no-binding', message: `面板 ${id} 既没绑能力也没绑业务线 ⇒ 无法判定是否已安装` })
|
|
256
|
+
return { panel, errors }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 从业务线 manifest 取面板声明(`settingsPanel` 单数 或 `settingsPanels` 数组,两者都认)。 */
|
|
260
|
+
export function panelsFromVerticalManifest(man, verticalId, registryDoc) {
|
|
261
|
+
const out = []
|
|
262
|
+
const raw = man && man.settingsPanels
|
|
263
|
+
const list = Array.isArray(raw) ? raw : (isObj(man && man.settingsPanel) ? [man.settingsPanel] : [])
|
|
264
|
+
for (const r of list) out.push(normalizePanel(r, { source: `verticals/${verticalId}/manifest.json`, vertical: verticalId, registry: registryDoc }))
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 从注册表能力包取面板声明(`capabilities.<c>.pack.settingsPanel`,引擎级/无 vertical 的能力用)。 */
|
|
269
|
+
export function panelsFromRegistry(doc) {
|
|
270
|
+
const out = []
|
|
271
|
+
for (const [cap, spec] of Object.entries((doc && doc.capabilities) || {})) {
|
|
272
|
+
if (!isObj(spec)) continue
|
|
273
|
+
const pack = isObj(spec.pack) ? spec.pack : {}
|
|
274
|
+
const raw = pack.settingsPanels
|
|
275
|
+
const list = Array.isArray(raw) ? raw : (isObj(pack.settingsPanel) ? [pack.settingsPanel] : [])
|
|
276
|
+
for (const r of list) {
|
|
277
|
+
// 注册表位置天然知道自己是哪个能力 ⇒ 面板没显式声明时自动绑定(安装判定按能力走)
|
|
278
|
+
const rawOne = isObj(r) && !r.capability && !Array.isArray(r.capabilities) ? { ...r, capability: cap } : r
|
|
279
|
+
out.push(normalizePanel(rawOne, { source: `capabilities.${cap}.pack.settingsPanel`, vertical: '', registry: doc }))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 声明合并:同一 id 出现两次 ⇒ 记错误(不静默取其一,两份真相必须先吵出来)。 */
|
|
286
|
+
export function mergeDeclaredPanels(batches) {
|
|
287
|
+
const panels = []
|
|
288
|
+
const errors = []
|
|
289
|
+
const byId = new Map()
|
|
290
|
+
for (const { panel, errors: errs } of batches) {
|
|
291
|
+
errors.push(...errs)
|
|
292
|
+
if (!panel) continue
|
|
293
|
+
if (byId.has(panel.id)) {
|
|
294
|
+
errors.push({ panel: panel.id, code: 'panel-id-duplicate', message: `面板 id 重复:${panel.id}(${byId.get(panel.id).source} 与 ${panel.source})` })
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
// 面板未显式声明 capability 时:业务线 manifest 只挂一个能力 ⇒ 用它(否则按业务线判定)
|
|
298
|
+
if (!Array.isArray(panel.capabilities)) panel.capabilities = []
|
|
299
|
+
byId.set(panel.id, panel)
|
|
300
|
+
panels.push(panel)
|
|
301
|
+
}
|
|
302
|
+
panels.sort((a, b) => (a.order - b.order) || a.id.localeCompare(b.id))
|
|
303
|
+
return { panels, errors }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 声明汇总(唯一的"读声明"入口):业务线 manifest + 注册表能力包 ⇒ 归一化 + 合并。
|
|
308
|
+
* `buildPanelsDoc`(读)与 `collectEnvWriteTargets`(写白名单)**共用**它 ——
|
|
309
|
+
* 读写两套各读一遍声明,迟早会漂。返回 `{ panels, errors }`(panels = **全部声明**,未按装/档位过滤)。
|
|
310
|
+
*/
|
|
311
|
+
export function gatherDeclaredPanels(opts = {}) {
|
|
312
|
+
const doc = opts.registryDoc || {}
|
|
313
|
+
const errors = []
|
|
314
|
+
const batches = []
|
|
315
|
+
const vroot = join(opts.root || '.', 'verticals')
|
|
316
|
+
for (const vid of listDir(vroot)) {
|
|
317
|
+
let man = null
|
|
318
|
+
try { man = JSON.parse(readFileSync(join(vroot, vid, 'manifest.json'), 'utf-8')) } catch { continue }
|
|
319
|
+
const b = panelsFromVerticalManifest(man, vid, doc)
|
|
320
|
+
// 该业务线的能力清单(manifest.capabilities)——面板只挂一个能力时自动绑定,安装判定更准
|
|
321
|
+
const caps = (Array.isArray(man.capabilities) ? man.capabilities : []).map((c) => str(c && c.id)).filter(Boolean)
|
|
322
|
+
for (const one of b) {
|
|
323
|
+
if (!one.panel.capabilities.length && caps.length === 1) one.panel.capabilities = [caps[0]]
|
|
324
|
+
}
|
|
325
|
+
batches.push(...b)
|
|
326
|
+
}
|
|
327
|
+
batches.push(...panelsFromRegistry(doc))
|
|
328
|
+
const merged = mergeDeclaredPanels(batches)
|
|
329
|
+
errors.push(...merged.errors)
|
|
330
|
+
return { panels: merged.panels, errors }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── ② 安装 / 启用判定 ────────────────────────────────────────────────
|
|
334
|
+
/** 装机清单:`<dshHome>/argent/install.json`(安装器写入;开发机常常没有 ⇒ 不能当唯一判据)。 */
|
|
335
|
+
export function readInstallJson(dataDir) {
|
|
336
|
+
try { return JSON.parse(readFileSync(join(dataDir, 'install.json'), 'utf-8')) } catch { return null }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 「已购」事实(针对性安装,2026-10-09)—— 从 `install.json#entitlement` 读(服务端 install-ticket
|
|
341
|
+
* 的 `entitlement` 块,安装器原样落盘)。
|
|
342
|
+
*
|
|
343
|
+
* ⛔ 这不是判定,是**转述服务端说过的话**:本函数不做任何档位比较、不猜、不补默认值。
|
|
344
|
+
* · 清单里**没有** `entitlement`(旧装机 / 安装器未落盘)⇒ `known=false`(未知),
|
|
345
|
+
* 调用方**只能**退回"未装即不显示 + 未开通",**不得**据此报红或冒"缺件";
|
|
346
|
+
* · `mode==='plan'` ⇒ 该账号**没有**被按购买裁剪(= 按套餐下发,既有客户形态),
|
|
347
|
+
* `granted` 里没有的能力**不是"未购"**,只是该档位本来就没有 ⇒ 一律算"未开通";
|
|
348
|
+
* · `mode==='explicit'` ⇒ `granted` 就是"已购 ∩ 套餐允许",不在里面的 = **未购**。
|
|
349
|
+
*/
|
|
350
|
+
export function readEntitlement(installJson) {
|
|
351
|
+
const e = isObj(installJson) && isObj(installJson.entitlement) ? installJson.entitlement : null
|
|
352
|
+
if (!e) return { known: false, mode: '', granted: [], entitled: null, withheld: [] }
|
|
353
|
+
const mode = str(e.mode)
|
|
354
|
+
const granted = Array.isArray(e.granted) ? e.granted.map(str).filter(Boolean) : []
|
|
355
|
+
return {
|
|
356
|
+
known: mode === 'plan' || mode === 'explicit',
|
|
357
|
+
mode,
|
|
358
|
+
granted,
|
|
359
|
+
entitled: Array.isArray(e.entitled) ? e.entitled.map(str).filter(Boolean) : null,
|
|
360
|
+
// withheld = 套餐允许但未购(服务端点名)。⛔ 只用于如实显示"未购",**不是**错误清单。
|
|
361
|
+
withheld: Array.isArray(e.withheld) ? e.withheld.map((w) => str(isObj(w) ? w.id : w)).filter(Boolean) : [],
|
|
362
|
+
planAllowed: Array.isArray(e.planAllowed) ? e.planAllowed.map(str).filter(Boolean) : [],
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 某个能力包的「已购」三态:`true` 已购 / `false` 未购 / `null` 未知(⛔ 未知**不是**未购)。
|
|
368
|
+
*
|
|
369
|
+
* 判据优先级:
|
|
370
|
+
* ① 有服务端已购清单且 `mode==='explicit'` ⇒ 以 `granted` 为准(权威);
|
|
371
|
+
* ② `mode==='plan'` ⇒ 该账号未被裁剪 ⇒ 档位够就是"已购",档位不够是"未开通"(**不称未购**);
|
|
372
|
+
* ③ 无清单 ⇒ `null`,由调用方按"未开通"中性呈现。
|
|
373
|
+
* 多能力面板(`capabilities:[...]`)取**任一**命中即可(与 `detectInstall` 的多能力口径一致)。
|
|
374
|
+
*/
|
|
375
|
+
export function entitlementOfPanel(caps, ctx) {
|
|
376
|
+
const ent = ctx.entitlement || { known: false }
|
|
377
|
+
const list = (Array.isArray(caps) ? caps : []).map(str).filter(Boolean)
|
|
378
|
+
if (!list.length) return null
|
|
379
|
+
if (ent.known && ent.mode === 'explicit') return list.some((c) => ent.granted.includes(c))
|
|
380
|
+
if (ent.known && ent.mode === 'plan') return null // 未裁剪 ⇒ "未购"这个概念不适用(见 doc.entitlement.note)
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 目录下的条目名(不存在 ⇒ 空数组)。 */
|
|
385
|
+
function listDir(dir) {
|
|
386
|
+
try { return readdirSync(dir).filter((d) => !d.startsWith('.')) } catch { return [] }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 能力是否「已安装」。判据分两级(返回值带 `source`/`detail` 供排障与验收):
|
|
391
|
+
*
|
|
392
|
+
* ① **权威来源**:`install.json` 里有非空 capabilities ⇒ 以它为准(客户机装机记录;
|
|
393
|
+
* 有记录但**不含**该能力 ⇒ 未安装,不再看线索 —— 权威说了算)。
|
|
394
|
+
* ② **正面证据**(没有任何 install.json 的开发机/老装机形态,按强度取第一条命中):
|
|
395
|
+
* · 该能力声明的技能至少装了一个(`~/.dsh/skills/<skill>`)
|
|
396
|
+
* · 该能力声明的 preset 至少装了一个(`~/.dsh/.agent-presets/<preset>`)
|
|
397
|
+
* · 能力包缓存存在(`~/.dsh/argent/packs-cache/<cap>-<版本>/PACK.json`)
|
|
398
|
+
* 全不命中 ⇒ 未安装,并给出最可能的原因("应有技能 xxx 一个都没装")——
|
|
399
|
+
* **这就是"未装即不显示"的判据**:没有正面证据,能力包的设置页就不会出现。
|
|
400
|
+
*/
|
|
401
|
+
export function detectInstall(cap, pack, ctx) {
|
|
402
|
+
const home = ctx.home
|
|
403
|
+
const dataDir = ctx.dataDir
|
|
404
|
+
const skills = (pack.skills || []).map((s) => str(s).replace(/^skills\//, '')).filter(Boolean)
|
|
405
|
+
const presets = (pack.presets || []).map((s) => str(s).split('/').pop()).filter(Boolean)
|
|
406
|
+
const install = ctx.installJson || null
|
|
407
|
+
// 装机清单两种形态都认:安装器写 `[{id, version, …}]`,手写夹具/手工记录常写 `["full", …]`。
|
|
408
|
+
// (2026-10-01 修:只认对象形态时,`{"capabilities":["daily-report"]}` 会被当成"没有记录"⇒ 静默落到线索兜底)
|
|
409
|
+
const recorded = install && Array.isArray(install.capabilities)
|
|
410
|
+
? install.capabilities.map((c) => str(c && typeof c === 'object' ? c.id : c)).filter(Boolean)
|
|
411
|
+
: []
|
|
412
|
+
if (recorded.length) {
|
|
413
|
+
const hit = recorded.includes(cap)
|
|
414
|
+
return { installed: hit, source: 'install.json', detail: hit ? 'install.json 已登记' : 'install.json 未登记该能力' }
|
|
415
|
+
}
|
|
416
|
+
const installedSkills = ctx.installedSkills || listDir(join(home, '.dsh', 'skills'))
|
|
417
|
+
const hitSkills = skills.filter((s) => installedSkills.includes(s))
|
|
418
|
+
if (hitSkills.length) return { installed: true, source: 'skills', detail: `已装技能 ${hitSkills.join('、')}` }
|
|
419
|
+
const installedPresets = ctx.installedPresets || listDir(join(home, '.dsh', '.agent-presets'))
|
|
420
|
+
const hitPresets = presets.filter((p) => installedPresets.includes(p))
|
|
421
|
+
if (hitPresets.length) return { installed: true, source: 'presets', detail: `已装 preset ${hitPresets.join('、')}` }
|
|
422
|
+
const cache = listDir(join(dataDir, 'packs-cache'))
|
|
423
|
+
const hitCache = cache.filter((d) => d.startsWith(`${cap}-`))
|
|
424
|
+
if (hitCache.length) return { installed: true, source: 'packs-cache', detail: `包缓存 ${hitCache.slice(-1)[0]}` }
|
|
425
|
+
if (skills.length) return { installed: false, source: 'skills', detail: `技能一个都没装(应有:${skills.join('、')})` }
|
|
426
|
+
if (presets.length) return { installed: false, source: 'presets', detail: `preset 一个都没装(应有:${presets.join('、')})` }
|
|
427
|
+
return { installed: false, source: 'none', detail: '没有任何安装证据' }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 能力是否「启用」:已安装 + 它声明的流程**不是全被暂停**。
|
|
432
|
+
* 暂停判据与 `flow-trigger.sh` 同源:注册表 `trigger.pauseSwitch`(业务线命名空间)
|
|
433
|
+
* 与 `trigger.pauseSwitchLegacy`(迁移期兼容名),在数据目录下存在任一即暂停。
|
|
434
|
+
*/
|
|
435
|
+
export function detectEnabled(cap, flows, ctx) {
|
|
436
|
+
const mine = Object.entries(flows || {}).filter(([, f]) => isObj(f) && str(f.capability) === cap)
|
|
437
|
+
const dataDir = ctx.dataDir
|
|
438
|
+
const paused = []
|
|
439
|
+
for (const [fid, f] of mine) {
|
|
440
|
+
const t = isObj(f.trigger) ? f.trigger : {}
|
|
441
|
+
const cands = [str(t.pauseSwitch), str(t.pauseSwitchLegacy)].filter(Boolean)
|
|
442
|
+
const hit = cands.find((rel) => existsSync(join(dataDir, rel)))
|
|
443
|
+
if (hit) paused.push({ flow: fid, switch: hit })
|
|
444
|
+
}
|
|
445
|
+
const allPaused = mine.length > 0 && paused.length === mine.length
|
|
446
|
+
return { enabled: !allPaused, flows: mine.map(([fid]) => fid), pausedFlows: paused }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── ③ 字段取值(只读)────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/** 解析一个目录下所有 `*.env`(`KEY=VALUE`;同名 KEY 按文件名排序取第一个)。 */
|
|
452
|
+
export function parseEnvDir(dir) {
|
|
453
|
+
const map = new Map()
|
|
454
|
+
for (const name of listDir(dir).filter((n) => n.endsWith('.env')).sort()) {
|
|
455
|
+
let text = ''
|
|
456
|
+
try { text = readFileSync(join(dir, name), 'utf-8') } catch { continue }
|
|
457
|
+
for (const line of text.split('\n')) {
|
|
458
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
|
459
|
+
if (!m) continue
|
|
460
|
+
let v = m[2].trim()
|
|
461
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
|
462
|
+
if (!map.has(m[1])) map.set(m[1], { value: v, file: name })
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return map
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* `from: "config"` 里**由 host 投影**(不在 config.json 原文里)的键。
|
|
470
|
+
*
|
|
471
|
+
* 为什么需要它:`GET /api/argent-state/config` 是这些值的**唯一出处**(它算 `…Configured` /
|
|
472
|
+
* `…Masked` / `…Count` 这类派生键)。聚合器是**仓库侧离线**脚本,读不到端点的
|
|
473
|
+
* 计算结果 ⇒ 只能标 `deferred: true`,由**客户端渲染时**用一次 `GET /api/argent-state/config` 补齐。
|
|
474
|
+
* 这里刻意**不重写**投影公式(第二份真相必然漂移):聚合器只做 best-effort 回显 + 明确标注。
|
|
475
|
+
*/
|
|
476
|
+
export const PROJECTED_KEY_SUFFIXES = ['Configured', 'Masked', 'Count']
|
|
477
|
+
/** 该 config 键是否由 host 端点投影(不在 config.json 原文里)。 */
|
|
478
|
+
export const isProjectedConfigKey = (key) => PROJECTED_KEY_SUFFIXES.some((suf) => String(key || '').endsWith(suf))
|
|
479
|
+
|
|
480
|
+
/** 字段在 env 里的作用域目录。 */
|
|
481
|
+
function envDirFor(field, panel, ctx) {
|
|
482
|
+
return field.scope === 'user'
|
|
483
|
+
? ctx.dataDir
|
|
484
|
+
: join(ctx.dataDir, 'verticals', panel.vertical || '_unknown', 'env')
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* `from: "roles"` 的取值来源:`~/.dsh/argent/gateway.json` 的角色表(**只读**摘要)。
|
|
489
|
+
* 给"入口 = 角色会话"的能力(如 HR 办公)用:它没有可配置参数,但「入口装没装、开没开」
|
|
490
|
+
* 是用户真正关心的事实。**只读**:角色/ Bot 凭据的写入口是网关的角色端点,不是本契约。
|
|
491
|
+
*/
|
|
492
|
+
export function readGatewayRoles(ctx) {
|
|
493
|
+
if (!ctx._gatewayRoles) {
|
|
494
|
+
try {
|
|
495
|
+
const doc = JSON.parse(readFileSync(join(ctx.dataDir, 'gateway.json'), 'utf-8'))
|
|
496
|
+
ctx._gatewayRoles = Array.isArray(doc.roles) ? doc.roles : []
|
|
497
|
+
} catch { ctx._gatewayRoles = [] }
|
|
498
|
+
}
|
|
499
|
+
return ctx._gatewayRoles
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* env 字段的写入目标(**只由声明推导**,请求体里永远不带路径)。
|
|
504
|
+
* 路径规则:`scope: vertical` ⇒ `<dataDir>/verticals/<业务线>/env/<envFile>`;
|
|
505
|
+
* `scope: user` ⇒ `<dataDir>/<envFile>`。
|
|
506
|
+
* 四道口子(任一不满足 ⇒ 该字段只读,不猜、不放宽):
|
|
507
|
+
* ① 声明了 `envFile` 且形状合法(`<名>.env`,无路径分隔符/上跳);
|
|
508
|
+
* ② scope=vertical 时必须知道业务线(面板声明里补的 `vertical`);
|
|
509
|
+
* ③ 拼出来的绝对路径必须**落在数据目录内**(防穿越);
|
|
510
|
+
* ④ 数据目录必须是绝对路径(相对路径无从校验 ⇒ 拒绝)。
|
|
511
|
+
*/
|
|
512
|
+
export function resolveEnvWriteTarget(panel, field, ctx) {
|
|
513
|
+
const file = str(field.envFile).trim()
|
|
514
|
+
if (!file) return { ok: false, error: '该字段没声明 envFile ⇒ 不允许写入(不猜路径)' }
|
|
515
|
+
if (!ENV_FILE_RE.test(file)) return { ok: false, error: `envFile=${file} 形状非法(只允许 <名>.env)` }
|
|
516
|
+
const rawDir = str(ctx.dataDir)
|
|
517
|
+
// ④ 必须是**绝对路径**:相对路径会跟着 cwd 走,"在数据目录内"的校验就失去意义 ⇒ 拒绝
|
|
518
|
+
if (!rawDir || !rawDir.startsWith('/')) return { ok: false, error: '数据目录不是绝对路径 ⇒ 拒绝写入' }
|
|
519
|
+
const root = resolvePath(rawDir)
|
|
520
|
+
let base
|
|
521
|
+
if (field.scope === 'user') {
|
|
522
|
+
base = root
|
|
523
|
+
} else {
|
|
524
|
+
const v = str(panel.vertical).trim()
|
|
525
|
+
if (!v || /[\\/]|\.\./.test(v)) return { ok: false, error: '面板没绑(或绑了非法)业务线 ⇒ 不知道写哪个 env 目录' }
|
|
526
|
+
base = join(root, 'verticals', v, 'env')
|
|
527
|
+
}
|
|
528
|
+
const abs = resolvePath(join(base, file))
|
|
529
|
+
if (!(abs === root || abs.startsWith(root + sep))) return { ok: false, error: '解析出的路径不在数据目录内 ⇒ 拒绝' }
|
|
530
|
+
return { ok: true, path: abs, rel: abs.slice(root.length + 1), envFile: file }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* env 写入器的**白名单**:`<面板 id>::<字段 key>` → 目标文件。
|
|
535
|
+
* 这是写入器唯一的合法性来源 —— 请求体只带这个复合键与值,**不带路径**。
|
|
536
|
+
* @returns {{targets: Map<string, object>, errors: Array}} errors = 因声明问题**没有**写目标的 env 字段
|
|
537
|
+
*/
|
|
538
|
+
export function collectEnvWriteTargets(opts = {}) {
|
|
539
|
+
const home = opts.home
|
|
540
|
+
// 库函数只认调用方给的 `home`(宿主 env 由调用方解析后经 `opts.dataDir` 传入);
|
|
541
|
+
// 默认值仍取自**内核判据**(`env: {}` = 不做环境覆盖),不在这里拼第二份 ~/.dsh/argent。
|
|
542
|
+
const dataDir = opts.dataDir || resolveDataDir({ env: {}, home }).dir
|
|
543
|
+
const ctx = { home, dataDir }
|
|
544
|
+
const gathered = opts.panels ? { panels: opts.panels, errors: [] } : gatherDeclaredPanels(opts)
|
|
545
|
+
const targets = new Map()
|
|
546
|
+
const errors = []
|
|
547
|
+
for (const p of gathered.panels) {
|
|
548
|
+
for (const f of p.fields || []) {
|
|
549
|
+
if (!f.env) continue // 不是 env 字段:不归写入器管
|
|
550
|
+
const t = resolveEnvWriteTarget(p, f, ctx)
|
|
551
|
+
if (!t.ok) { if (f.envFile) errors.push({ panel: p.id, field: f.key, code: 'env-target-unresolved', message: `${p.id}::${f.key} 写目标不可用:${t.error}` }); continue }
|
|
552
|
+
targets.set(`${p.id}::${f.key}`, {
|
|
553
|
+
panelId: p.id, panelTitle: p.title, field: f.key, label: f.label, type: f.type,
|
|
554
|
+
env: f.env, scope: f.scope, vertical: p.vertical, path: t.path, rel: t.rel, envFile: t.envFile,
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return { targets, errors }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 解析字段当前值 → `{ configured, value, masked, from, file, note }`。
|
|
563
|
+
* 规则(与文档逐条对应):
|
|
564
|
+
* · password ⇒ 只回 configured + masked(value 恒空)
|
|
565
|
+
* · switch ⇒ value 为布尔
|
|
566
|
+
* · text/select ⇒ value 为字符串(截断 200 字)
|
|
567
|
+
* · `read.from: json` ⇒ 读 `pathFromEnv` 指向的 JSON(路径只认 env,不接任意路径)
|
|
568
|
+
*/
|
|
569
|
+
export function resolveFieldValue(field, panel, ctx) {
|
|
570
|
+
const read = field.read
|
|
571
|
+
const from = read ? read.from : (field.env ? 'env' : '')
|
|
572
|
+
const out = { configured: false, value: '', masked: '', from: from || 'none', file: '', note: '' }
|
|
573
|
+
let raw
|
|
574
|
+
if (from === 'config') {
|
|
575
|
+
const cfg = ctx.config || {}
|
|
576
|
+
const key = read.key || field.key
|
|
577
|
+
raw = cfg[key]
|
|
578
|
+
out.from = `config.json:${key}`
|
|
579
|
+
if (isProjectedConfigKey(key)) {
|
|
580
|
+
out.deferred = true
|
|
581
|
+
out.note = '该键由 host 的 GET /api/argent-state/config 投影(config.json 原文里没有)⇒ 渲染时以端点值为准'
|
|
582
|
+
}
|
|
583
|
+
} else if (from === 'json') {
|
|
584
|
+
const envName = read.pathFromEnv
|
|
585
|
+
const dir = envDirFor(field, panel, ctx)
|
|
586
|
+
ctx._envCache = ctx._envCache || new Map()
|
|
587
|
+
if (!ctx._envCache.has(dir)) ctx._envCache.set(dir, parseEnvDir(dir))
|
|
588
|
+
const path = (ctx._envCache.get(dir).get(envName) || {}).value || ''
|
|
589
|
+
if (!path) { out.note = `env ${envName} 未配置(拿不到文件路径)`; return finish(field, out, undefined) }
|
|
590
|
+
let doc = null
|
|
591
|
+
try { doc = JSON.parse(readFileSync(path, 'utf-8')) } catch { out.note = `读不到 ${path}`; return finish(field, out, undefined) }
|
|
592
|
+
const keys = isObj(doc) ? Object.keys(doc) : (Array.isArray(doc) ? doc.map((x) => str(x && x.id || x)) : [])
|
|
593
|
+
raw = keys.length ? `${keys.length} 个:${keys.slice(0, 12).join('、')}${keys.length > 12 ? ' …' : ''}` : ''
|
|
594
|
+
out.from = `${envName}=${path}`
|
|
595
|
+
out.file = path
|
|
596
|
+
} else if (from === 'roles') {
|
|
597
|
+
// 会话入口装配状态(gateway.json)——只读摘要,给"入口 = 某条线的持久会话"的能力用
|
|
598
|
+
const role = (read && read.key) || field.key
|
|
599
|
+
const hit = readGatewayRoles(ctx).find((r) => str(r && r.role) === role)
|
|
600
|
+
out.from = `gateway.json:${role}`
|
|
601
|
+
out.file = 'gateway.json'
|
|
602
|
+
if (!hit) {
|
|
603
|
+
// W7.6(2026-10-14):原文指向「Argent 能力」页的「角色」页签 —— 该页签已删除
|
|
604
|
+
// (角色 = DSH preset,Argent 侧不再复述角色清单)⇒ 改指**配置本体**与官方预设区。
|
|
605
|
+
out.note = `gateway.json 里没有 ${role} 这条会话入口(在网关配置 gateway.json#roles 里补齐;`
|
|
606
|
+
+ '角色 = DSH 预设,见 设置 →「Agent 预设」)'
|
|
607
|
+
return finish(field, out, undefined)
|
|
608
|
+
}
|
|
609
|
+
raw = `已装配(自主接单:${hit.autonomous === false ? '关' : '开'} · Bot:${hit.botAppId ? '已配' : '未配'}`
|
|
610
|
+
+ `${Array.isArray(hit.skills) && hit.skills.length ? ` · 技能 ${hit.skills.length} 个` : ''})`
|
|
611
|
+
} else if (from === 'env') {
|
|
612
|
+
const name = (read && read.key) || field.env
|
|
613
|
+
const dir = envDirFor(field, panel, ctx)
|
|
614
|
+
ctx._envCache = ctx._envCache || new Map()
|
|
615
|
+
if (!ctx._envCache.has(dir)) ctx._envCache.set(dir, parseEnvDir(dir))
|
|
616
|
+
const envMap = ctx._envCache.get(dir)
|
|
617
|
+
const hit = envMap.get(name)
|
|
618
|
+
raw = hit ? hit.value : undefined
|
|
619
|
+
out.file = hit ? hit.file : ''
|
|
620
|
+
out.from = `env:${name}`
|
|
621
|
+
} else {
|
|
622
|
+
out.note = '没有声明取值来源'
|
|
623
|
+
return finish(field, out, undefined)
|
|
624
|
+
}
|
|
625
|
+
return finish(field, out, raw)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function finish(field, out, raw) {
|
|
629
|
+
if (field.type === 'switch') {
|
|
630
|
+
out.value = raw === true || raw === 'true' || raw === '1' || raw === 1
|
|
631
|
+
out.configured = out.value === true
|
|
632
|
+
return out
|
|
633
|
+
}
|
|
634
|
+
if (field.type === 'password') {
|
|
635
|
+
// 声明方可直接把 read.key 指向布尔投影(如 `feishuAppSecretConfigured`)⇒ 只认"配没配"
|
|
636
|
+
if (typeof raw === 'boolean') { out.configured = raw; out.masked = ''; out.value = ''; return out }
|
|
637
|
+
const s = str(raw)
|
|
638
|
+
out.configured = !!s
|
|
639
|
+
out.masked = maskSecret(s)
|
|
640
|
+
out.value = '' // 红线:密码字段绝不回明文
|
|
641
|
+
return out
|
|
642
|
+
}
|
|
643
|
+
const s = str(raw)
|
|
644
|
+
out.configured = !!s
|
|
645
|
+
out.value = s.length > 200 ? `${s.slice(0, 200)}…` : s
|
|
646
|
+
return out
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── ④ 汇总入口 ───────────────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 账号档位从哪来(**只认本机已有的权威记录**,不联网、不问服务端):
|
|
653
|
+
* ① `opts.accountPlan`(调用方显式给:CLI `--plan` / 测试夹具)
|
|
654
|
+
* ② env `ARGENT_ACCOUNT_PLAN`(运维/隔离测试用)
|
|
655
|
+
* ③ `~/.dsh/argent/install.json#plan`(安装器记录的**服务端套餐**,客户机形态)
|
|
656
|
+
* ④ 都拿不到 ⇒ `known: false` ⇒ **不做任何档位过滤**(与 assertFeishuAllowed 同口径:
|
|
657
|
+
* 查不到就放行,宁可多显示一页,也不要把付费客户锁在门外;真正的授权判定在服务端)。
|
|
658
|
+
*/
|
|
659
|
+
export function resolveAccountPlan(opts = {}) {
|
|
660
|
+
const explicit = str(opts.accountPlan).trim()
|
|
661
|
+
if (explicit) return { value: explicit, source: 'arg', known: true }
|
|
662
|
+
const fromEnv = str((opts.env || {}).ARGENT_ACCOUNT_PLAN).trim()
|
|
663
|
+
if (fromEnv) return { value: fromEnv, source: 'env', known: true }
|
|
664
|
+
const fromInstall = str((opts.installJson || {}).plan).trim()
|
|
665
|
+
if (fromInstall) return { value: fromInstall, source: 'install.json', known: true }
|
|
666
|
+
return { value: '', source: 'unknown', known: false }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 产出面板聚合文档(`GET /api/argent-gateway/settings-panels` 与 catalog 的共同数据源)。
|
|
671
|
+
* @param {{root:string, home:string, dataDir?:string, registryDoc:object, env?:object,
|
|
672
|
+
* accountPlan?:string, envWriter?:boolean}} opts
|
|
673
|
+
* root = 仓库根(读 verticals/);home = 用户家目录(读 skills/presets);dataDir = `~/.dsh/argent`;
|
|
674
|
+
* accountPlan = 账号档位(缺省按 {@link resolveAccountPlan} 自己找);envWriter = 覆盖写入器开关。
|
|
675
|
+
*/
|
|
676
|
+
export function buildPanelsDoc(opts) {
|
|
677
|
+
const home = opts.home
|
|
678
|
+
const dataDir = opts.dataDir || resolveDataDir({ env: {}, home }).dir // 同 collectEnvWriteTargets:默认值来自内核
|
|
679
|
+
const doc = opts.registryDoc || {}
|
|
680
|
+
const ctx = {
|
|
681
|
+
home, dataDir,
|
|
682
|
+
env: opts.env || process.env,
|
|
683
|
+
installJson: readInstallJson(dataDir),
|
|
684
|
+
config: (() => { try { return JSON.parse(readFileSync(join(dataDir, 'config.json'), 'utf-8')) } catch { return {} } })(),
|
|
685
|
+
}
|
|
686
|
+
ctx.entitlement = readEntitlement(ctx.installJson)
|
|
687
|
+
const tiers = planTiers(doc)
|
|
688
|
+
const account = resolveAccountPlan({ accountPlan: opts.accountPlan, env: ctx.env, installJson: ctx.installJson })
|
|
689
|
+
const envWriter = opts.envWriter === undefined ? isEnvWriterEnabled(ctx.env) : !!opts.envWriter
|
|
690
|
+
const gathered = gatherDeclaredPanels({ root: opts.root, registryDoc: doc })
|
|
691
|
+
const errors = [...gathered.errors]
|
|
692
|
+
const envTargets = collectEnvWriteTargets({ panels: gathered.panels, dataDir }).targets
|
|
693
|
+
const packOf = (cap) => {
|
|
694
|
+
const spec = (doc.capabilities || {})[cap]
|
|
695
|
+
return isObj(spec) && isObj(spec.pack) ? spec.pack : {}
|
|
696
|
+
}
|
|
697
|
+
const hiddenByPlan = []
|
|
698
|
+
const panels = gathered.panels.map((p) => {
|
|
699
|
+
// 安装判定:绑了能力 ⇒ **任一已装即已装**(并在 evidence 里给出每个能力的证据);
|
|
700
|
+
// 没绑能力(纯业务线面板)⇒ 按业务线 id 自身证据判。
|
|
701
|
+
const caps = p.capabilities.length ? p.capabilities : [p.vertical]
|
|
702
|
+
const per = caps.filter(Boolean).map((cap) => {
|
|
703
|
+
const d = detectInstall(cap, packOf(cap), ctx)
|
|
704
|
+
return { capability: cap, ...d }
|
|
705
|
+
})
|
|
706
|
+
const hit = per.find((x) => x.installed) || null
|
|
707
|
+
const install = hit
|
|
708
|
+
? { installed: true, source: hit.source, detail: `${hit.capability}:${hit.detail}` }
|
|
709
|
+
: { installed: false, source: (per[0] || {}).source || 'none', detail: per.map((x) => `${x.capability}:${x.detail}`).join(';') || '没有任何安装证据' }
|
|
710
|
+
const run = { enabled: false, flows: [], pausedFlows: [] }
|
|
711
|
+
for (const cap of caps) {
|
|
712
|
+
const r = detectEnabled(cap, doc.flows, ctx)
|
|
713
|
+
run.flows.push(...r.flows)
|
|
714
|
+
run.pausedFlows.push(...r.pausedFlows)
|
|
715
|
+
if (r.enabled) run.enabled = true
|
|
716
|
+
}
|
|
717
|
+
// 档位闸门:**服务端算成布尔**(客户端只认 planGated,仍然零档位名单)。
|
|
718
|
+
// 账号档位未知(known=false)⇒ 一律不放闸(planGated=false)。
|
|
719
|
+
const gated = !!(account.known && p.minPlan) && planRank(account.value, tiers) < planRank(p.minPlan, tiers)
|
|
720
|
+
if (gated) {
|
|
721
|
+
hiddenByPlan.push({
|
|
722
|
+
kind: 'panel',
|
|
723
|
+
id: p.id, title: p.title, minPlan: p.minPlan,
|
|
724
|
+
reason: `账号档位 ${account.value}(来自 ${account.source})低于该面板要求的 ${p.minPlan}`,
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
// 字段级档位(契约 §11,2026-10-01):与面板级**同一条链、同一个 planRank**,只是粒度更细。
|
|
728
|
+
// 被滤掉的字段**从载荷里整个消失**(客户端只认服务端给的 fields[],不做任何判定 ⇒ 零名单),
|
|
729
|
+
// 同时在 hiddenByPlan[] 里给一条 `kind: 'field'` 的原因(页面上不占位,排障说得清)。
|
|
730
|
+
// 面板自身已被滤掉时不再为它的字段记条目:整页都不出现,逐字段列出来只是噪音。
|
|
731
|
+
let fieldsHiddenByPlan = 0
|
|
732
|
+
const fields = []
|
|
733
|
+
for (const f of p.fields) {
|
|
734
|
+
if (!gated && account.known && f.minPlan && planRank(account.value, tiers) < planRank(f.minPlan, tiers)) {
|
|
735
|
+
fieldsHiddenByPlan += 1
|
|
736
|
+
hiddenByPlan.push({
|
|
737
|
+
kind: 'field', panel: p.id, panelTitle: p.title,
|
|
738
|
+
field: f.key, label: f.label, minPlan: f.minPlan,
|
|
739
|
+
reason: `账号档位 ${account.value}(来自 ${account.source})低于该字段要求的 ${f.minPlan}`,
|
|
740
|
+
})
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
const v = resolveFieldValue(f, p, ctx)
|
|
744
|
+
// 写入口:① 声明里显式给的既有端点;② env 字段 + 写入器已开 + 目标可解析 ⇒ 走受控 env 写入器
|
|
745
|
+
let write = f.write
|
|
746
|
+
let writeNote = ''
|
|
747
|
+
if (!write && f.env) {
|
|
748
|
+
const t = envTargets.get(`${p.id}::${f.key}`)
|
|
749
|
+
// 顺序有讲究:先看"有没有合法写目标"(声明问题,与开关无关),再看开关是否打开 ——
|
|
750
|
+
// 否则用户会看到"写入器没开"这种**误导性**原因(其实是这个字段永远不可写)。
|
|
751
|
+
if (!t) writeNote = '该字段没有可用的 env 写目标(声明里缺 envFile 或路径不合法)'
|
|
752
|
+
else if (!envWriter) writeNote = `env 写入器默认关闭(${ENV_WRITER_ENV}=1 并重启 dsh 后可改)`
|
|
753
|
+
else write = { endpoint: ENV_WRITE_ENDPOINT, field: `${p.id}::${f.key}`, envTarget: t.rel }
|
|
754
|
+
}
|
|
755
|
+
// 注意:**不回** `minPlan`(字段对象里不带档位信息)—— 客户端拿到的永远是"已经滤好的清单",
|
|
756
|
+
// 这样"客户端零档位名单"才是一条可断言的性质,而不是"约定前端别用"。
|
|
757
|
+
fields.push({
|
|
758
|
+
key: f.key, label: f.label, type: f.type, env: f.env, scope: f.scope, envFile: f.envFile,
|
|
759
|
+
placeholder: f.placeholder, hint: f.hint, options: f.options,
|
|
760
|
+
readonly: !write,
|
|
761
|
+
write,
|
|
762
|
+
// 只读原因(客户端渲染小字;不改判定,判定就在上面的 write)
|
|
763
|
+
writeNote,
|
|
764
|
+
// readKey:客户端补齐 deferred 值时按这个键去 host 端点取(`from` 只给排障看,不做解析)
|
|
765
|
+
readKey: f.read ? (f.read.key || '') : '',
|
|
766
|
+
configured: v.configured, value: v.value, masked: v.masked, from: v.from, file: v.file, note: v.note,
|
|
767
|
+
// deferred=true ⇒ 聚合器读不到(host 投影键),客户端用 GET /api/argent-state/config 补齐
|
|
768
|
+
deferred: !!v.deferred,
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
// 已购三态(true 已购 / false 未购 / null 未知)。⛔ null ≠ 未购;未购/未装都**不是异常、不是缺件**,
|
|
772
|
+
// 呈现口径一律是"未开通"(见 doc.entitlement.note 与本文件 note)。算一次,别在下面重复调 4 遍。
|
|
773
|
+
const entitled = entitlementOfPanel(p.capabilities, ctx)
|
|
774
|
+
return {
|
|
775
|
+
id: p.id, title: p.title, order: p.order,
|
|
776
|
+
vertical: p.vertical, capabilities: p.capabilities, hint: p.hint, source: p.source,
|
|
777
|
+
minPlan: p.minPlan, minPlanSource: p.minPlanSource, planGated: gated,
|
|
778
|
+
installed: install.installed, installSource: install.source, installDetail: install.detail,
|
|
779
|
+
installEvidence: per,
|
|
780
|
+
entitled,
|
|
781
|
+
// 四态汇总(给人看的标签,判定仍看 installed/entitled 两个事实):
|
|
782
|
+
// 已开通 / 已购·未装 / 已装·未购 / 未开通(后三者**都不是异常**)
|
|
783
|
+
state: !install.installed
|
|
784
|
+
? (entitled === true ? '已购·未装' : '未开通')
|
|
785
|
+
: (entitled === false ? '已装·未购' : '已开通'),
|
|
786
|
+
enabled: install.installed ? run.enabled : false,
|
|
787
|
+
flows: [...new Set(run.flows)], pausedFlows: run.pausedFlows,
|
|
788
|
+
fields,
|
|
789
|
+
// 因档位被滤掉的字段**数量**(服务端算好的事实;客户端据此只显示一句"另有 n 项不显示")
|
|
790
|
+
fieldsHiddenByPlan,
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
return {
|
|
794
|
+
ok: errors.length === 0,
|
|
795
|
+
generatedAt: new Date().toISOString(),
|
|
796
|
+
panels,
|
|
797
|
+
plan: { value: account.value, source: account.source, known: account.known, tiers,
|
|
798
|
+
note: account.known ? '' : '拿不到账号档位(无 --plan / ARGENT_ACCOUNT_PLAN / install.json.plan)⇒ 不做档位过滤' },
|
|
799
|
+
entitlement: { ...ctx.entitlement,
|
|
800
|
+
note: '已购事实来自 install.json#entitlement(服务端 install-ticket 原样落盘),本文件只转述、不做档位比较。'
|
|
801
|
+
+ 'mode=plan ⇒ 该账号**未**按购买裁剪(既有客户形态)⇒ 没有"未购"这个概念,未下发的能力一律显示"未开通";'
|
|
802
|
+
+ 'mode=explicit ⇒ granted 之外的能力即"未购"。known=false(旧装机没有这一块)⇒ 一律按"未开通"中性呈现。'
|
|
803
|
+
+ '⛔ 未购 / 未装都**不是异常、不是缺件**,任何呈现都不得报红。' },
|
|
804
|
+
envWriter: { enabled: envWriter, switch: ENV_WRITER_ENV, endpoint: ENV_WRITE_ENDPOINT,
|
|
805
|
+
targets: [...envTargets.keys()] },
|
|
806
|
+
hiddenByPlan,
|
|
807
|
+
errors,
|
|
808
|
+
note: '能力包设置页契约:面板由 verticals/<id>/manifest.json#settingsPanel(s) 或 capabilities.<c>.pack.settingsPanel 声明;'
|
|
809
|
+
+ 'installed=false 的面板客户端**不渲染**(未装即不显示);planGated=true 的面板、以及字段级 minPlan 不够的字段,'
|
|
810
|
+
+ '都由**服务端**按账号档位滤掉(客户端零名单;被滤的面板/字段在 hiddenByPlan[] 里给原因);'
|
|
811
|
+
+ '值只读、只来自 config/env/json/roles;env 字段在 ' + ENV_WRITER_ENV + '=1 时走受控写入器(写前备份 + 写后读回 + 审计),'
|
|
812
|
+
+ '非 env 字段只复用既有端点。'
|
|
813
|
+
+ '⚠️ 呈现口径(2026-10-09):未装(installed=false)与未购(entitled=false)**都不是异常、不是缺件**,'
|
|
814
|
+
+ '只是"未开通";二者只影响"显不显示",绝不产生错误标记。',
|
|
815
|
+
}
|
|
816
|
+
}
|