@geraldmaron/construct 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -10
- package/bin/construct +286 -220
- package/bin/construct-postinstall.mjs +0 -21
- package/commands/work/optimize-prompts.md +1 -1
- package/examples/distribution/sources/adr.md +2 -2
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/artifact-loop-core.mjs +412 -0
- package/lib/artifact-manifest.mjs +6 -0
- package/lib/artifact-release-gate.mjs +115 -49
- package/lib/audit-rules.mjs +2 -4
- package/lib/audit-skills.mjs +32 -20
- package/lib/audit-specialists.mjs +44 -26
- package/lib/auto-docs.mjs +6 -5
- package/lib/brand-prose.mjs +3 -3
- package/lib/brand-tokens.mjs +2 -2
- package/lib/certification/artifact-fixtures.mjs +4 -1
- package/lib/certification/demo-parity.mjs +6 -32
- package/lib/certification/real-llm-scenarios.mjs +58 -13
- package/lib/certification/role-cards.mjs +7 -6
- package/lib/certification/role-overlays.mjs +4 -4
- package/lib/certification/runner.mjs +9 -2
- package/lib/certification/skill-inventory.mjs +34 -22
- package/lib/certification/skill-scenarios.mjs +21 -8
- package/lib/certification/specialist-contracts.mjs +4 -3
- package/lib/certification/specialist-scenarios.mjs +7 -6
- package/lib/certification/status.mjs +5 -4
- package/lib/cli-commands.mjs +56 -57
- package/lib/comment-lint.mjs +1 -5
- package/lib/completions.mjs +20 -2
- package/lib/config/legacy-config-migration.mjs +125 -0
- package/lib/config/schema.mjs +5 -5
- package/lib/config/source-targets.mjs +80 -0
- package/lib/contracts/validate.mjs +119 -51
- package/lib/decisions/golden.mjs +3 -2
- package/lib/decisions/registry.mjs +11 -6
- package/lib/demo-project.mjs +188 -0
- package/lib/demo-recording.mjs +9 -34
- package/lib/demo-script.mjs +2 -2
- package/lib/demo-surface.mjs +13 -131
- package/lib/demo-tour-renderer.mjs +92 -0
- package/lib/demo.mjs +69 -54
- package/lib/diagram-export.mjs +63 -14
- package/lib/doctor/source-checkout.mjs +3 -4
- package/lib/doctor/watchers/consistency.mjs +90 -22
- package/lib/doctor/watchers/credential-parity.mjs +97 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +1 -1
- package/lib/doctor/watchers/service-health.mjs +4 -32
- package/lib/document-export.mjs +73 -9
- package/lib/document-extract.mjs +10 -198
- package/lib/embed/artifact.mjs +2 -3
- package/lib/embed/cli.mjs +1 -1
- package/lib/embed/config.mjs +1 -2
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/daemon.mjs +5 -6
- package/lib/embed/demand-fetch.mjs +114 -2
- package/lib/embed/docs-lifecycle.mjs +9 -5
- package/lib/embed/intake-metrics.mjs +2 -2
- package/lib/embed/notifications.mjs +2 -3
- package/lib/embed/recommendation-store.mjs +25 -22
- package/lib/embed/role-framing.mjs +4 -9
- package/lib/embedded-contract/capability.mjs +4 -2
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/env-config.mjs +2 -2
- package/lib/flavors/loader.mjs +21 -19
- package/lib/graph/build-from-registry.mjs +31 -7
- package/lib/graph/staleness.mjs +1 -1
- package/lib/headhunt.mjs +82 -35
- package/lib/hooks/agent-tracker.mjs +2 -2
- package/lib/hooks/config-protection.mjs +1 -1
- package/lib/hooks/registry-sync.mjs +3 -3
- package/lib/host-capabilities.mjs +5 -5
- package/lib/improvement/controller.mjs +2 -2
- package/lib/init-unified.mjs +19 -21
- package/lib/intake/classify.mjs +26 -0
- package/lib/intake/daemon.mjs +2 -3
- package/lib/intake/prepare.mjs +10 -3
- package/lib/intake/session-prelude.mjs +1 -1
- package/lib/intake/tables/creative.mjs +9 -9
- package/lib/intake/tables/operations.mjs +4 -4
- package/lib/intake/tables/research.mjs +2 -2
- package/lib/integrations/intake-integrations.mjs +9 -10
- package/lib/knowledge/research-store.mjs +2 -2
- package/lib/mcp/server.mjs +104 -61
- package/lib/mcp/tool-budget.mjs +1 -46
- package/lib/mcp/tool-recovery.mjs +44 -0
- package/lib/mcp/tools/artifact-author.mjs +36 -0
- package/lib/mcp/tools/find-tool.mjs +86 -0
- package/lib/mcp/tools/orchestration-run.mjs +107 -69
- package/lib/mcp/tools/project.mjs +4 -3
- package/lib/mcp/tools/{profile.mjs → scope.mjs} +54 -52
- package/lib/mcp/tools/skills.mjs +39 -22
- package/lib/mcp/tools/telemetry.mjs +2 -1
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/mcp-platform-config.mjs +7 -5
- package/lib/migrations/index.mjs +4 -2
- package/lib/migrations/v2-unified-registry.mjs +35 -0
- package/lib/model-router.mjs +203 -36
- package/lib/models/catalog.mjs +25 -9
- package/lib/models/execution-capability-profile.mjs +3 -3
- package/lib/models/execution-policy.mjs +7 -6
- package/lib/models/provider-poll.mjs +18 -4
- package/lib/opencode-config.mjs +56 -1
- package/lib/opencode-runtime-plugin.mjs +13 -10
- package/lib/oracle/artifact-gate.mjs +14 -4
- package/lib/oracle/cli.mjs +2 -0
- package/lib/oracle/dispatch.mjs +18 -1
- package/lib/oracle/execute.mjs +39 -7
- package/lib/oracle/index.mjs +1 -1
- package/lib/oracle/org-graph.mjs +10 -5
- package/lib/oracle/policy.mjs +6 -0
- package/lib/oracle/read-model.mjs +103 -6
- package/lib/oracle/reconcile.mjs +31 -4
- package/lib/oracle/remediation-dispatch.mjs +53 -0
- package/lib/oracle/routing.mjs +5 -0
- package/lib/oracle/synthesize.mjs +80 -3
- package/lib/orchestration/routing-tables.mjs +44 -10
- package/lib/orchestration/runtime.mjs +2 -0
- package/lib/orchestration/worker.mjs +1 -1
- package/lib/orchestration-policy.mjs +351 -62
- package/lib/overrides/resolver.mjs +1 -12
- package/lib/parity.mjs +37 -11
- package/lib/policy/engine.mjs +80 -15
- package/lib/prompt-composer.js +51 -15
- package/lib/prompt-metadata.mjs +3 -2
- package/lib/providers/connection-probe.mjs +58 -0
- package/lib/providers/copilot-auth.mjs +1 -1
- package/lib/providers/credential-bootstrap.mjs +1 -1
- package/lib/providers/op-run.mjs +3 -4
- package/lib/providers/secret-resolver.mjs +31 -0
- package/lib/publish-tooling.mjs +3 -11
- package/lib/publish.mjs +12 -29
- package/lib/reconcile/mcp-entry-reconcile.mjs +14 -9
- package/lib/reflect.mjs +2 -2
- package/lib/registry/agent-manifest.mjs +190 -0
- package/lib/registry/assemble.mjs +133 -0
- package/lib/registry/catalog.mjs +171 -0
- package/lib/registry/cli.mjs +590 -6
- package/lib/registry/consolidation.mjs +5 -4
- package/lib/registry/docs-sync.mjs +67 -0
- package/lib/registry/loader.mjs +147 -0
- package/lib/registry/org-io.mjs +76 -0
- package/lib/registry/retired-paths.mjs +71 -0
- package/lib/registry/surface-map.mjs +5 -7
- package/lib/registry/validator.mjs +438 -0
- package/lib/research-execution-policy.mjs +173 -0
- package/lib/roles/catalog.mjs +4 -9
- package/lib/roles/fence.mjs +107 -1
- package/lib/roles/flavor-bindings.mjs +68 -0
- package/lib/roles/gateway.mjs +140 -29
- package/lib/roles/manifest.mjs +22 -13
- package/lib/roles/router.mjs +1 -1
- package/lib/runtime-env.mjs +1 -1
- package/lib/scopes/enrich.mjs +67 -0
- package/lib/scopes/hooks.mjs +12 -0
- package/lib/{profiles → scopes}/lifecycle.mjs +70 -70
- package/lib/scopes/loader.mjs +104 -0
- package/lib/scopes/rebrand.mjs +32 -0
- package/lib/scopes/research-profile.mjs +16 -0
- package/lib/scopes/teams.mjs +98 -0
- package/lib/service-manager.mjs +1 -117
- package/lib/setup-prompts.mjs +1 -1
- package/lib/setup.mjs +17 -28
- package/lib/skills/composition-graph.mjs +98 -0
- package/lib/skills/router.mjs +80 -0
- package/lib/specialist-contracts.mjs +17 -18
- package/lib/specialists/postconditions.mjs +2 -2
- package/lib/specialists/prompt-schema.mjs +6 -5
- package/lib/specialists/roster.mjs +7 -12
- package/lib/specialists/schema.mjs +9 -6
- package/lib/status.mjs +25 -90
- package/lib/storage/file-lock.mjs +6 -3
- package/lib/telemetry/skill-calls.mjs +1 -1
- package/lib/templates/doc-presentation.mjs +63 -0
- package/lib/templates/visual-requirements.mjs +35 -2
- package/lib/term-format.mjs +107 -2
- package/lib/test-env-setup.mjs +21 -0
- package/lib/ui/components.mjs +42 -0
- package/lib/ui/glyphs.mjs +58 -0
- package/lib/ui/links.mjs +115 -0
- package/lib/ui/theme.mjs +108 -0
- package/lib/uninstall/uninstall.mjs +2 -1
- package/lib/validator.mjs +45 -8
- package/lib/validators/skill-effectiveness.mjs +174 -0
- package/lib/validators/skills.mjs +1 -1
- package/package.json +12 -7
- package/personas/construct.md +12 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/beads-hygiene.md +52 -0
- package/rules/common/neurodivergent-output.md +4 -7
- package/rules/common/research.md +2 -0
- package/scripts/sync-specialists.mjs +260 -49
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/brand/output-vibe.md +48 -0
- package/skills/docs/adr-workflow.md +3 -0
- package/skills/docs/backlog-proposal-workflow.md +3 -0
- package/skills/docs/codebase-research-workflow.md +5 -2
- package/skills/docs/customer-profile-workflow.md +3 -0
- package/skills/docs/document-ingest-workflow.md +3 -0
- package/skills/docs/evidence-ingest-workflow.md +4 -1
- package/skills/docs/init-project.md +1 -1
- package/skills/docs/prd-workflow.md +1 -1
- package/skills/docs/prfaq-workflow.md +3 -0
- package/skills/docs/product-intelligence-workflow.md +4 -1
- package/skills/docs/product-signal-workflow.md +3 -0
- package/skills/docs/research-workflow.md +13 -6
- package/skills/docs/runbook-workflow.md +3 -0
- package/skills/docs/strategy-workflow.md +3 -0
- package/skills/docs/user-research-workflow.md +6 -3
- package/skills/operating/orchestration-reference.md +1 -1
- package/skills/roles/{engineer.ai.md → ai-engineer.md} +2 -2
- package/skills/roles/architect.ai-systems.md +1 -1
- package/skills/roles/architect.data.md +1 -1
- package/skills/roles/architect.enterprise.md +1 -1
- package/skills/roles/architect.integration.md +1 -1
- package/skills/roles/architect.md +1 -1
- package/skills/roles/architect.platform.md +1 -1
- package/skills/roles/{product-manager.business-strategy.md → business-strategist.md} +3 -3
- package/skills/roles/data-analyst.experiment.md +1 -1
- package/skills/roles/data-analyst.md +1 -1
- package/skills/roles/data-analyst.product-intelligence.md +1 -1
- package/skills/roles/data-analyst.product.md +1 -1
- package/skills/roles/data-analyst.telemetry.md +1 -1
- package/skills/roles/{engineer.data.md → data-engineer.md} +2 -2
- package/skills/roles/data-engineer.pipeline.md +2 -2
- package/skills/roles/data-engineer.vector-retrieval.md +2 -2
- package/skills/roles/data-engineer.warehouse.md +2 -2
- package/skills/roles/debugger.md +1 -1
- package/skills/roles/designer.accessibility.md +1 -1
- package/skills/roles/designer.md +1 -1
- package/skills/roles/{reviewer.devil-advocate.md → devil-advocate.md} +2 -2
- package/skills/roles/{operator.docs.md → docs-keeper.md} +3 -3
- package/skills/roles/engineer.md +3 -7
- package/skills/roles/{reviewer.evaluator.md → evaluator.md} +2 -2
- package/skills/roles/{researcher.explorer.md → explorer.md} +2 -2
- package/skills/roles/{operator.md → operations.md} +2 -5
- package/skills/roles/orchestrator.md +1 -1
- package/skills/roles/{engineer.platform.md → platform-engineer.md} +2 -2
- package/skills/roles/product-manager.ai-product.md +1 -1
- package/skills/roles/product-manager.enterprise.md +1 -1
- package/skills/roles/product-manager.growth.md +1 -2
- package/skills/roles/product-manager.md +1 -2
- package/skills/roles/product-manager.platform.md +1 -1
- package/skills/roles/product-manager.product.md +1 -1
- package/skills/roles/qa.ai-eval.md +1 -1
- package/skills/roles/qa.api-contract.md +1 -1
- package/skills/roles/qa.data-pipeline.md +1 -1
- package/skills/roles/qa.md +1 -1
- package/skills/roles/qa.web-ui.md +1 -1
- package/skills/roles/{operator.release.md → release-manager.md} +3 -3
- package/skills/roles/researcher.md +1 -1
- package/skills/roles/reviewer.md +1 -1
- package/skills/roles/security.ai.md +1 -1
- package/skills/roles/security.appsec.md +1 -1
- package/skills/roles/security.cloud.md +1 -1
- package/skills/roles/security.legal-compliance.md +1 -1
- package/skills/roles/security.md +1 -1
- package/skills/roles/security.privacy.md +1 -1
- package/skills/roles/security.supply-chain.md +1 -1
- package/skills/roles/{operator.sre.md → sre.md} +3 -3
- package/skills/roles/{qa.test-automation.md → test-automation.md} +2 -2
- package/skills/roles/{reviewer.trace.md → trace-reviewer.md} +2 -2
- package/skills/roles/{researcher.ux.md → ux-researcher.md} +2 -2
- package/skills/routing.json +18 -0
- package/skills/routing.md +1 -1
- package/specialists/artifact-manifest.json +2 -1
- package/specialists/audit-enrichments.json +14 -14
- package/specialists/org/contracts/accessibility-to-qa.json +37 -0
- package/specialists/org/contracts/any-to-business-strategist.json +57 -0
- package/specialists/org/contracts/any-to-debugger.json +38 -0
- package/specialists/org/contracts/any-to-designer.json +33 -0
- package/specialists/org/contracts/any-to-docs-keeper.json +34 -0
- package/specialists/org/contracts/any-to-explorer.json +39 -0
- package/specialists/org/contracts/any-to-sre-incident.json +33 -0
- package/specialists/org/contracts/any-to-trace-reviewer.json +32 -0
- package/specialists/org/contracts/architect-to-ai-engineer.json +32 -0
- package/specialists/org/contracts/architect-to-data-engineer.json +52 -0
- package/specialists/org/contracts/architect-to-devil-advocate.json +38 -0
- package/specialists/org/contracts/architect-to-engineer.json +34 -0
- package/specialists/org/contracts/architect-to-evaluator.json +59 -0
- package/specialists/org/contracts/architect-to-legal-compliance.json +55 -0
- package/specialists/org/contracts/architect-to-operations.json +45 -0
- package/specialists/org/contracts/architect-to-platform-engineer.json +42 -0
- package/specialists/org/contracts/business-strategist-to-product-manager.json +39 -0
- package/specialists/org/contracts/construct-to-orchestrator.json +36 -0
- package/specialists/org/contracts/construct-to-rd-lead.json +53 -0
- package/specialists/org/contracts/data-analyst-to-product-manager.json +43 -0
- package/specialists/org/contracts/data-engineer-to-platform-engineer.json +36 -0
- package/specialists/org/contracts/designer-to-accessibility.json +36 -0
- package/specialists/org/contracts/engineer-to-qa.json +34 -0
- package/specialists/org/contracts/engineer-to-reviewer.json +52 -0
- package/specialists/org/contracts/explorer-to-engineer.json +38 -0
- package/specialists/org/contracts/legal-compliance-to-release-manager.json +43 -0
- package/specialists/org/contracts/platform-engineer-to-engineer.json +31 -0
- package/specialists/org/contracts/product-manager-to-architect.json +71 -0
- package/specialists/org/contracts/product-manager-to-data-analyst.json +49 -0
- package/specialists/org/contracts/product-manager-to-ux-researcher.json +51 -0
- package/specialists/org/contracts/qa-to-release-manager.json +42 -0
- package/specialists/org/contracts/qa-to-test-automation.json +42 -0
- package/specialists/org/contracts/rd-lead-to-architect.json +38 -0
- package/specialists/org/contracts/researcher-to-architect.json +40 -0
- package/specialists/org/contracts/researcher-to-product-manager.json +57 -0
- package/specialists/org/contracts/reviewer-to-security.json +41 -0
- package/specialists/org/contracts/sre-to-release-manager.json +36 -0
- package/specialists/org/contracts/test-automation-to-engineer.json +34 -0
- package/specialists/org/contracts/trace-reviewer-to-sre.json +41 -0
- package/specialists/org/contracts/user-to-construct.json +30 -0
- package/specialists/org/groups/engineering-group.json +43 -0
- package/specialists/org/groups/governance-group.json +38 -0
- package/specialists/org/groups/operations-group.json +41 -0
- package/specialists/org/groups/product-group.json +46 -0
- package/specialists/org/groups/quality-group.json +42 -0
- package/specialists/org/groups/strategy-group.json +41 -0
- package/specialists/org/policies/action-approval.json +13 -0
- package/specialists/org/policies/agents-routing.json +13 -0
- package/specialists/org/policies/architecture.json +20 -0
- package/specialists/org/policies/bash-safety.json +13 -0
- package/specialists/org/policies/beads-hygiene.json +13 -0
- package/specialists/org/policies/bootstrap-state.json +13 -0
- package/specialists/org/policies/code-review.json +13 -0
- package/specialists/org/policies/coding-style.json +13 -0
- package/specialists/org/policies/comments.json +13 -0
- package/specialists/org/policies/commit-approval.json +13 -0
- package/specialists/org/policies/contract-preconditions.json +13 -0
- package/specialists/org/policies/deployment.json +20 -0
- package/specialists/org/policies/description.json +14 -0
- package/specialists/org/policies/design-approval.json +19 -0
- package/specialists/org/policies/doc-ownership.json +13 -0
- package/specialists/org/policies/file-path-fence.json +13 -0
- package/specialists/org/policies/framing.json +13 -0
- package/specialists/org/policies/git-workflow.json +13 -0
- package/specialists/org/policies/incident-response.json +15 -0
- package/specialists/org/policies/intake-triage.json +15 -0
- package/specialists/org/policies/neurodivergent-output.json +13 -0
- package/specialists/org/policies/no-fabrication.json +13 -0
- package/specialists/org/policies/patterns.json +13 -0
- package/specialists/org/policies/quality-gate-approval.json +17 -0
- package/specialists/org/policies/release-gates.json +13 -0
- package/specialists/org/policies/research-evidence.json +13 -0
- package/specialists/org/policies/review-before-change.json +13 -0
- package/specialists/org/policies/rollback.json +15 -0
- package/specialists/org/policies/scope-change.json +20 -0
- package/specialists/org/policies/secret-scan.json +13 -0
- package/specialists/org/policies/security-approval.json +17 -0
- package/specialists/org/policies/security.json +13 -0
- package/specialists/org/policies/session-efficiency.json +13 -0
- package/specialists/org/policies/skill-routing.json +13 -0
- package/specialists/org/policies/strategic-prioritization.json +17 -0
- package/specialists/org/policies/testing.json +13 -0
- package/specialists/org/policies/tool-invisibility.json +14 -0
- package/specialists/org/scopes/creative.json +54 -0
- package/specialists/org/scopes/operations.json +51 -0
- package/specialists/org/scopes/research.json +60 -0
- package/specialists/org/scopes/rnd.json +81 -0
- package/specialists/org/specialists/cx-accessibility.json +69 -0
- package/specialists/org/specialists/cx-ai-engineer.json +75 -0
- package/specialists/org/specialists/cx-architect.json +89 -0
- package/specialists/org/specialists/cx-business-strategist.json +72 -0
- package/specialists/org/specialists/cx-data-analyst.json +68 -0
- package/specialists/org/specialists/cx-data-engineer.json +71 -0
- package/specialists/org/specialists/cx-debugger.json +75 -0
- package/specialists/org/specialists/cx-designer.json +85 -0
- package/specialists/org/specialists/cx-devil-advocate.json +71 -0
- package/specialists/org/specialists/cx-docs-keeper.json +82 -0
- package/specialists/org/specialists/cx-engineer.json +106 -0
- package/specialists/org/specialists/cx-evaluator.json +69 -0
- package/specialists/org/specialists/cx-explorer.json +69 -0
- package/specialists/org/specialists/cx-legal-compliance.json +74 -0
- package/specialists/org/specialists/cx-operations.json +72 -0
- package/specialists/org/specialists/cx-oracle.json +46 -0
- package/specialists/org/specialists/cx-orchestrator.json +81 -0
- package/specialists/org/specialists/cx-platform-engineer.json +80 -0
- package/specialists/org/specialists/cx-product-manager.json +102 -0
- package/specialists/org/specialists/cx-qa.json +79 -0
- package/specialists/org/specialists/cx-rd-lead.json +73 -0
- package/specialists/org/specialists/cx-release-manager.json +77 -0
- package/specialists/org/specialists/cx-researcher.json +82 -0
- package/specialists/org/specialists/cx-reviewer.json +71 -0
- package/specialists/org/specialists/cx-security.json +91 -0
- package/specialists/org/specialists/cx-sre.json +94 -0
- package/specialists/org/specialists/cx-test-automation.json +69 -0
- package/specialists/org/specialists/cx-trace-reviewer.json +69 -0
- package/specialists/org/specialists/cx-ux-researcher.json +68 -0
- package/specialists/org/teams/accessibility-team.json +39 -0
- package/specialists/org/teams/design-team.json +40 -0
- package/specialists/org/teams/engineering-team.json +50 -0
- package/specialists/org/teams/governance-team.json +41 -0
- package/specialists/org/teams/operations-team.json +46 -0
- package/specialists/org/teams/product-management-team.json +45 -0
- package/specialists/org/teams/quality-team.json +49 -0
- package/specialists/org/teams/research-team.json +39 -0
- package/specialists/org/teams/strategy-team.json +48 -0
- package/specialists/org/teams/ux-research-team.json +39 -0
- package/specialists/prompts/_shared/validation-contract.md +1 -1
- package/specialists/prompts/_team-template.md +10 -0
- package/specialists/prompts/cx-accessibility.md +1 -0
- package/specialists/prompts/cx-ai-engineer.md +1 -1
- package/specialists/prompts/cx-business-strategist.md +1 -1
- package/specialists/prompts/cx-data-engineer.md +1 -1
- package/specialists/prompts/cx-designer.md +1 -0
- package/specialists/prompts/cx-devil-advocate.md +1 -1
- package/specialists/prompts/cx-docs-keeper.md +1 -1
- package/specialists/prompts/cx-evaluator.md +1 -1
- package/specialists/prompts/cx-explorer.md +1 -1
- package/specialists/prompts/cx-operations.md +1 -1
- package/specialists/prompts/cx-oracle.md +7 -3
- package/specialists/prompts/cx-orchestrator.md +17 -1
- package/specialists/prompts/cx-platform-engineer.md +1 -1
- package/specialists/prompts/cx-product-manager.md +2 -0
- package/specialists/prompts/cx-release-manager.md +1 -1
- package/specialists/prompts/cx-researcher.md +7 -4
- package/specialists/prompts/cx-sre.md +1 -1
- package/specialists/prompts/cx-test-automation.md +2 -2
- package/specialists/prompts/cx-trace-reviewer.md +3 -3
- package/specialists/prompts/cx-ux-researcher.md +2 -1
- package/templates/demos/scripts/architecture-review-adr.json +30 -0
- package/templates/demos/scripts/capability-contract.json +25 -0
- package/templates/demos/scripts/intake-triage.json +25 -0
- package/templates/demos/scripts/profile-doctor-health.json +25 -0
- package/templates/demos/tapes/agentic-platforms-prd.tape +2 -2
- package/templates/demos/tapes/architecture-review-adr.tape +44 -0
- package/templates/demos/tapes/capability-contract.tape +39 -0
- package/templates/demos/tapes/intake-triage.tape +39 -0
- package/templates/demos/tapes/profile-doctor-health.tape +39 -0
- package/templates/distribution/construct-brand.typ +23 -18
- package/templates/distribution/construct-decision.typ +1 -1
- package/templates/distribution/construct-pdf.typ +1 -1
- package/templates/distribution/construct-prd.typ +1 -1
- package/templates/distribution/construct-research.typ +1 -1
- package/templates/distribution/mermaid-puppeteer.json +8 -0
- package/templates/docs/persona-artifact.md +1 -1
- package/templates/docs/prd.md +6 -0
- package/lib/boundary.mjs +0 -127
- package/lib/certification/dashboard-api.mjs +0 -71
- package/lib/chat/cli.mjs +0 -333
- package/lib/chat/command-suggest.mjs +0 -161
- package/lib/chat/commands.mjs +0 -215
- package/lib/chat/config.mjs +0 -142
- package/lib/chat/context-compactor.mjs +0 -250
- package/lib/chat/context-continuation.mjs +0 -253
- package/lib/chat/continuation-source.mjs +0 -58
- package/lib/chat/demo-guide.mjs +0 -61
- package/lib/chat/design-tokens.mjs +0 -91
- package/lib/chat/desktop-binary.mjs +0 -81
- package/lib/chat/desktop-build.mjs +0 -130
- package/lib/chat/desktop-launcher.mjs +0 -133
- package/lib/chat/evidence.mjs +0 -145
- package/lib/chat/export.mjs +0 -74
- package/lib/chat/harness/driver.mjs +0 -91
- package/lib/chat/list-picker.mjs +0 -112
- package/lib/chat/model-picker.mjs +0 -356
- package/lib/chat/openrouter-fallback.mjs +0 -151
- package/lib/chat/permission-prompt.mjs +0 -33
- package/lib/chat/picker-catalog.mjs +0 -45
- package/lib/chat/policy-telemetry.mjs +0 -34
- package/lib/chat/present.mjs +0 -246
- package/lib/chat/session-context.mjs +0 -39
- package/lib/chat/session-persist.mjs +0 -73
- package/lib/chat/session-restore.mjs +0 -71
- package/lib/chat/session-settings.mjs +0 -53
- package/lib/chat/system-prompt.mjs +0 -52
- package/lib/chat/transparency.mjs +0 -93
- package/lib/chat/tui/color-scheme.mjs +0 -42
- package/lib/chat/tui/markdown.mjs +0 -123
- package/lib/chat/tui/presentation.mjs +0 -100
- package/lib/chat/tui/render.mjs +0 -500
- package/lib/chat/tui/turn-block.mjs +0 -284
- package/lib/chat/tui/turn-present.mjs +0 -18
- package/lib/chat/tui/turn-state.mjs +0 -88
- package/lib/chat/tui/usage.mjs +0 -122
- package/lib/chat/web-commands.mjs +0 -146
- package/lib/chat/web-launcher.mjs +0 -63
- package/lib/chat/web-picker-keys.mjs +0 -46
- package/lib/chat/web-session.mjs +0 -159
- package/lib/config/alias.mjs +0 -57
- package/lib/dashboard-demo.mjs +0 -71
- package/lib/dashboard-static.mjs +0 -175
- package/lib/install/desktop-binary-download.mjs +0 -88
- package/lib/profiles/loader.mjs +0 -123
- package/lib/profiles/rebrand.mjs +0 -46
- package/lib/server/auth.mjs +0 -169
- package/lib/server/chat-loop.mjs +0 -622
- package/lib/server/chat.mjs +0 -336
- package/lib/server/cors.mjs +0 -77
- package/lib/server/csrf.mjs +0 -103
- package/lib/server/demo-preview.mjs +0 -63
- package/lib/server/index.mjs +0 -2641
- package/lib/server/insights.mjs +0 -780
- package/lib/server/langfuse-login.mjs +0 -58
- package/lib/server/rate-limit.mjs +0 -93
- package/lib/server/telemetry-login.mjs +0 -100
- package/lib/server/webhook.mjs +0 -512
- package/specialists/contracts.json +0 -1032
- package/specialists/contracts.schema.json +0 -83
- package/specialists/policy-inventory.json +0 -188
- package/specialists/registry.json +0 -1412
- package/specialists/role-manifests.json +0 -217
- package/specialists/teams.json +0 -94
package/lib/server/index.mjs
DELETED
|
@@ -1,2641 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/server/index.mjs — Construct dashboard HTTP server.
|
|
3
|
-
*
|
|
4
|
-
* Serves the single-page dashboard from lib/server/static/, provides a JSON
|
|
5
|
-
* status API, SSE live-reload, REST endpoints for registry management,
|
|
6
|
-
* artifact generation/listing, approval queue inspection, snapshot data,
|
|
7
|
-
* token-based auth, legacy claude CLI chat, and owned-loop SSE web chat.
|
|
8
|
-
* Runs on port 4242 (overridable via PORT env var), bound to 127.0.0.1.
|
|
9
|
-
*/
|
|
10
|
-
import { createServer } from 'http';
|
|
11
|
-
import { registerBoundary } from '../boundary.mjs';
|
|
12
|
-
import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync, mkdirSync, renameSync, chmodSync, appendFileSync } from 'fs';
|
|
13
|
-
import { spawnSync } from 'child_process';
|
|
14
|
-
import { join, extname, relative, normalize, dirname } from 'path';
|
|
15
|
-
import { homedir } from 'os';
|
|
16
|
-
import { fileURLToPath } from 'url';
|
|
17
|
-
import { spawn } from 'child_process';
|
|
18
|
-
import { buildStatus as buildSharedStatus } from '../status.mjs';
|
|
19
|
-
import { generateArtifact, listArtifacts } from '../embed/artifact.mjs';
|
|
20
|
-
import { ApprovalQueue } from '../embed/approval-queue.mjs';
|
|
21
|
-
import { resolveEmbedStatus } from '../embed/cli.mjs';
|
|
22
|
-
import { loadConstructEnv, prepareConstructEnv } from '../runtime-env.mjs';
|
|
23
|
-
import { configDir, doctorRoot } from '../config/xdg.mjs';
|
|
24
|
-
import {
|
|
25
|
-
isAuthConfigured, isAuthenticated, rejectUnauthorized,
|
|
26
|
-
validateToken, createSession, sessionCookieHeader, clearSessionCookieHeader,
|
|
27
|
-
getDashboardToken, getAuthConfig,
|
|
28
|
-
} from './auth.mjs';
|
|
29
|
-
import { handleChatStream, handleChat, handleChatHistory } from './chat.mjs';
|
|
30
|
-
import {
|
|
31
|
-
handleChatLoopStream, handleChatLoopPermission, handleChatLoopPending, handleChatLoopConfig,
|
|
32
|
-
handleChatLoopCommand, handleChatLoopHistory, handleChatLoopCancel, handleChatModels, handleChatModelSelect,
|
|
33
|
-
} from './chat-loop.mjs';
|
|
34
|
-
import { createWebhookHandler, createSlackCommandHandler } from './webhook.mjs';
|
|
35
|
-
import { onEmbedNotification } from '../embed/notifications.mjs';
|
|
36
|
-
import { artifactTypes, templateMetadata } from '../artifact-manifest.mjs';
|
|
37
|
-
import { resolveDemoPreviewPath, demoPreviewMime } from './demo-preview.mjs';
|
|
38
|
-
|
|
39
|
-
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
40
|
-
const ROOT_DIR = join(__dirname, '..', '..');
|
|
41
|
-
const HOME = homedir();
|
|
42
|
-
|
|
43
|
-
// The daemon is a long-lived, detached service not bound to one project, so its
|
|
44
|
-
// orchestration runs persist under the user data root (HOME → ~/.cx/runtime/
|
|
45
|
-
// orchestration via the run store's relative path) rather than the install
|
|
46
|
-
// directory. A global install dir can be shared or read-only and would mix
|
|
47
|
-
// every project's runs together; HOME is always writable and survives reinstalls.
|
|
48
|
-
|
|
49
|
-
const ORCHESTRATION_RUN_ROOT = HOME;
|
|
50
|
-
const PORT = parseInt(process.env.PORT ?? '4242', 10);
|
|
51
|
-
|
|
52
|
-
// Default bind is loopback regardless of NODE_ENV. Exposing the dashboard to
|
|
53
|
-
// non-loopback interfaces is opt-in via BIND_HOST and gated on configured
|
|
54
|
-
// auth (see assertSafeBind below).
|
|
55
|
-
|
|
56
|
-
const BIND_HOST = process.env.BIND_HOST ?? '127.0.0.1';
|
|
57
|
-
|
|
58
|
-
function isLoopbackHost(host) {
|
|
59
|
-
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function assertSafeBind() {
|
|
63
|
-
if (isLoopbackHost(BIND_HOST)) return;
|
|
64
|
-
if (!isAuthConfigured()) {
|
|
65
|
-
process.stderr.write(
|
|
66
|
-
`[dashboard] refuses to bind to non-loopback host ${BIND_HOST} without auth configured. ` +
|
|
67
|
-
`Set CONSTRUCT_DASHBOARD_TOKEN (or run \`construct dashboard auth init\`) before exposing the dashboard.\n`,
|
|
68
|
-
);
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const STATIC_DIR = join(__dirname, 'static');
|
|
74
|
-
const REGISTRY_FILE = join(ROOT_DIR, 'specialists', 'registry.json');
|
|
75
|
-
const FEATURES_FILE = join(configDir(HOME), 'features.json');
|
|
76
|
-
const WORKFLOW_FILE = join(ROOT_DIR, 'plan.md');
|
|
77
|
-
const SKILLS_DIR = join(ROOT_DIR, 'skills');
|
|
78
|
-
const COMMANDS_DIR = join(ROOT_DIR, 'commands');
|
|
79
|
-
const SNAPSHOTS_FILE = join(doctorRoot(), 'snapshots.jsonl');
|
|
80
|
-
const APPROVAL_QUEUE_FILE = join(doctorRoot(), 'approval-queue.jsonl');
|
|
81
|
-
const CONFIG_ENV_FILE = join(configDir(HOME), 'config.env');
|
|
82
|
-
const EMBED_YAML_FILE = join(configDir(HOME), 'embed.yaml');
|
|
83
|
-
const CREDENTIAL_AUDIT_FILE = join(doctorRoot(), 'credential-audit.jsonl');
|
|
84
|
-
|
|
85
|
-
// Provider → expected env vars. Source of truth for the credentials surface,
|
|
86
|
-
// the per-provider health classification ("not configured" vs "unhealthy"),
|
|
87
|
-
// and the write-side allowlist — POST refuses env vars not listed here.
|
|
88
|
-
|
|
89
|
-
const BUILTIN_CREDENTIAL_MAP = [
|
|
90
|
-
{ provider: 'anthropic', label: 'Anthropic', kind: 'llm', envVars: ['ANTHROPIC_API_KEY'] },
|
|
91
|
-
{ provider: 'openai', label: 'OpenAI', kind: 'llm', envVars: ['OPENAI_API_KEY'] },
|
|
92
|
-
{ provider: 'openrouter', label: 'OpenRouter', kind: 'llm', envVars: ['OPENROUTER_API_KEY'] },
|
|
93
|
-
{ provider: 'gemini', label: 'Google Gemini', kind: 'llm', envVars: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
|
|
94
|
-
{ provider: 'github', label: 'GitHub', kind: 'integration', envVars: ['GITHUB_TOKEN', 'GH_TOKEN'] },
|
|
95
|
-
{ provider: 'atlassian-jira', label: 'Jira', kind: 'integration', envVars: ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'] },
|
|
96
|
-
{ provider: 'atlassian-confluence',label: 'Confluence', kind: 'integration', envVars: ['CONFLUENCE_BASE_URL', 'CONFLUENCE_EMAIL', 'CONFLUENCE_API_TOKEN'] },
|
|
97
|
-
{ provider: 'slack', label: 'Slack', kind: 'integration', envVars: ['SLACK_BOT_TOKEN', 'SLACK_USER_TOKEN'] },
|
|
98
|
-
{ provider: 'salesforce', label: 'Salesforce', kind: 'integration', envVars: ['SALESFORCE_INSTANCE_URL', 'SALESFORCE_ACCESS_TOKEN'] },
|
|
99
|
-
];
|
|
100
|
-
|
|
101
|
-
const CUSTOM_CREDENTIALS_FILE = join(configDir(HOME), 'custom-credentials.json');
|
|
102
|
-
|
|
103
|
-
// Env-var name validator: must match POSIX shell variable syntax and avoid
|
|
104
|
-
// stomping built-in entries. Refusing $PATH / $HOME / OS-special prefixes
|
|
105
|
-
// keeps a custom provider from being a foot-gun for the shell.
|
|
106
|
-
const ENV_VAR_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
107
|
-
const ENV_VAR_BLOCKLIST = new Set(['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'PWD', 'TERM']);
|
|
108
|
-
|
|
109
|
-
function readCustomCredentials() {
|
|
110
|
-
if (!existsSync(CUSTOM_CREDENTIALS_FILE)) return [];
|
|
111
|
-
try {
|
|
112
|
-
const raw = JSON.parse(readFileSync(CUSTOM_CREDENTIALS_FILE, 'utf8'));
|
|
113
|
-
const list = Array.isArray(raw?.providers) ? raw.providers : [];
|
|
114
|
-
return list.filter((entry) => {
|
|
115
|
-
if (!entry || typeof entry.provider !== 'string') return false;
|
|
116
|
-
if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === entry.provider)) return false;
|
|
117
|
-
if (!Array.isArray(entry.envVars)) return false;
|
|
118
|
-
return entry.envVars.every((name) => ENV_VAR_PATTERN.test(name) && !ENV_VAR_BLOCKLIST.has(name));
|
|
119
|
-
});
|
|
120
|
-
} catch {
|
|
121
|
-
return [];
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function writeCustomCredentials(list) {
|
|
126
|
-
mkdirSync(dirname(CUSTOM_CREDENTIALS_FILE), { recursive: true });
|
|
127
|
-
writeFileSync(CUSTOM_CREDENTIALS_FILE, JSON.stringify({ providers: list }, null, 2) + '\n');
|
|
128
|
-
try { chmodSync(CUSTOM_CREDENTIALS_FILE, 0o600); } catch { /* perms best-effort */ }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function credentialMap() {
|
|
132
|
-
return [...BUILTIN_CREDENTIAL_MAP, ...readCustomCredentials()];
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function allowedCredentialEnvVars() {
|
|
136
|
-
return new Set(credentialMap().flatMap((c) => c.envVars));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function maskValue(value) {
|
|
140
|
-
if (!value) return null;
|
|
141
|
-
if (value.length <= 10) return `${value.slice(0, 2)}…${value.slice(-2)}`;
|
|
142
|
-
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function buildCredentialsView(env = process.env) {
|
|
146
|
-
return credentialMap().map(c => {
|
|
147
|
-
const vars = c.envVars.map(name => {
|
|
148
|
-
const val = resolveCredential(name, env);
|
|
149
|
-
const set = Boolean(val);
|
|
150
|
-
return { envVar: name, set, preview: set ? maskValue(val) : null };
|
|
151
|
-
});
|
|
152
|
-
const setCount = vars.filter(v => v.set).length;
|
|
153
|
-
let configured;
|
|
154
|
-
if (setCount === 0) configured = 'none';
|
|
155
|
-
else if (setCount === c.envVars.length) configured = 'full';
|
|
156
|
-
else configured = 'partial';
|
|
157
|
-
return { provider: c.provider, label: c.label, kind: c.kind, vars, configured };
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// A provider with no required env vars set is "not_configured" regardless of
|
|
162
|
-
// whether the probe succeeds — some probes (e.g. GitHub anonymous rate_limit)
|
|
163
|
-
// return ok:true even with zero auth, which would otherwise read as a green
|
|
164
|
-
// "Healthy" while the credentials column shows everything blank.
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Resolve a credential from env, config.env, ~/.env, and shell rc files.
|
|
168
|
-
* Mirrors the logic in model-router.mjs isProviderConfigured.
|
|
169
|
-
*/
|
|
170
|
-
function resolveCredential(varName, env) {
|
|
171
|
-
if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
|
|
172
|
-
const homeDir = HOME || homedir();
|
|
173
|
-
|
|
174
|
-
// Special case: Ollama on default port
|
|
175
|
-
if (varName === 'OLLAMA_BASE_URL') {
|
|
176
|
-
try {
|
|
177
|
-
const r = spawnSync('curl', ['-s', '--connect-timeout', '1', '-o', '/dev/null', '-w', '%{http_code}', 'http://localhost:11434/api/tags'], { encoding: 'utf8', timeout: 3000 });
|
|
178
|
-
if (r.status === 0 && r.stdout?.trim() === '200') return 'http://localhost:11434';
|
|
179
|
-
} catch { /* not available */ }
|
|
180
|
-
try {
|
|
181
|
-
const r = spawnSync('ollama', ['--version'], { encoding: 'utf8', timeout: 2000 });
|
|
182
|
-
if (r.status === 0) return 'found-ollama-binary';
|
|
183
|
-
} catch { /* not available */ }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const paths = [join(configDir(homeDir), 'config.env'), join(homeDir, '.env')];
|
|
187
|
-
for (const p of paths) {
|
|
188
|
-
try {
|
|
189
|
-
if (existsSync(p)) {
|
|
190
|
-
const content = readFileSync(p, 'utf8');
|
|
191
|
-
const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
192
|
-
if (m && m[1]) return m[1].trim();
|
|
193
|
-
}
|
|
194
|
-
} catch { /* skip */ }
|
|
195
|
-
}
|
|
196
|
-
// Check shell rc files for op:// refs
|
|
197
|
-
const shellFiles = [join(homeDir, '.zshrc'), join(homeDir, '.bashrc'), join(homeDir, '.bash_profile'), join(homeDir, '.profile')];
|
|
198
|
-
for (const rc of shellFiles) {
|
|
199
|
-
if (!existsSync(rc)) continue;
|
|
200
|
-
try {
|
|
201
|
-
const content = readFileSync(rc, 'utf8');
|
|
202
|
-
const directRe = new RegExp(`^\\s*export\\s+${varName}=`, 'm');
|
|
203
|
-
if (directRe.test(content)) return 'found-in-rc';
|
|
204
|
-
const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
|
|
205
|
-
const m = content.match(opRe);
|
|
206
|
-
if (m) {
|
|
207
|
-
const r = spawnSync('op', ['read', m[1]], { encoding: 'utf8', timeout: 5000 });
|
|
208
|
-
if (r.status === 0 && r.stdout?.trim()) return r.stdout.trim();
|
|
209
|
-
}
|
|
210
|
-
} catch { /* skip */ }
|
|
211
|
-
}
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function classifyProviderStatus(providerId, healthOk, env = process.env) {
|
|
216
|
-
const entry = credentialMap().find(c => c.provider === providerId);
|
|
217
|
-
const anySet = entry ? entry.envVars.some(name => Boolean(resolveCredential(name, env))) : false;
|
|
218
|
-
if (!anySet) return 'not_configured';
|
|
219
|
-
return healthOk ? 'healthy' : 'unhealthy';
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function describeMissingCredentials(providerId, env = process.env) {
|
|
223
|
-
const entry = credentialMap().find(c => c.provider === providerId);
|
|
224
|
-
if (!entry) return null;
|
|
225
|
-
const missing = entry.envVars.filter(name => !resolveCredential(name, env));
|
|
226
|
-
if (missing.length === 0) return null;
|
|
227
|
-
return `missing: ${missing.join(', ')}`;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const approvalQueue = new ApprovalQueue({ path: APPROVAL_QUEUE_FILE });
|
|
231
|
-
const sseClients = new Set();
|
|
232
|
-
|
|
233
|
-
// ── Terraform config ───────────────────────────────────────────────────────
|
|
234
|
-
const TERRAFORM_DIR = join(ROOT_DIR, 'deploy', 'terraform');
|
|
235
|
-
const TERRAFORM_ALLOWED_EXTS = new Set(['.tf', '.tfvars', '.json']);
|
|
236
|
-
|
|
237
|
-
function terraformFiles(dir, base) {
|
|
238
|
-
const results = [];
|
|
239
|
-
if (!existsSync(dir)) return results;
|
|
240
|
-
for (const entry of readdirSync(dir)) {
|
|
241
|
-
const full = join(dir, entry);
|
|
242
|
-
let stat;
|
|
243
|
-
try { stat = statSync(full); } catch { continue; }
|
|
244
|
-
if (stat.isDirectory()) {
|
|
245
|
-
results.push(...terraformFiles(full, base));
|
|
246
|
-
} else {
|
|
247
|
-
const ext = extname(entry);
|
|
248
|
-
if (TERRAFORM_ALLOWED_EXTS.has(ext)) {
|
|
249
|
-
results.push(relative(base, full));
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return results.sort();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function assertTerraformPath(relPath) {
|
|
257
|
-
const abs = normalize(join(TERRAFORM_DIR, relPath));
|
|
258
|
-
if (!abs.startsWith(TERRAFORM_DIR + '/') && abs !== TERRAFORM_DIR) {
|
|
259
|
-
throw new Error('Path traversal not allowed');
|
|
260
|
-
}
|
|
261
|
-
if (!TERRAFORM_ALLOWED_EXTS.has(extname(abs))) {
|
|
262
|
-
throw new Error('Only .tf, .tfvars, and .json files are editable');
|
|
263
|
-
}
|
|
264
|
-
return abs;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Webhook handler — created after approvalQueue so it can share the instance
|
|
268
|
-
let _webhookHandler = null;
|
|
269
|
-
function getWebhookHandler() {
|
|
270
|
-
if (!_webhookHandler) {
|
|
271
|
-
_webhookHandler = createWebhookHandler({ approvalQueue, notifyClients });
|
|
272
|
-
}
|
|
273
|
-
return _webhookHandler;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
let _slackCommandHandler = null;
|
|
277
|
-
function getSlackCommandHandler() {
|
|
278
|
-
if (!_slackCommandHandler) {
|
|
279
|
-
_slackCommandHandler = createSlackCommandHandler();
|
|
280
|
-
}
|
|
281
|
-
return _slackCommandHandler;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const MIME = {
|
|
285
|
-
'.html': 'text/html; charset=utf-8',
|
|
286
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
287
|
-
'.css': 'text/css; charset=utf-8',
|
|
288
|
-
'.json': 'application/json',
|
|
289
|
-
'.svg': 'image/svg+xml',
|
|
290
|
-
'.ico': 'image/x-icon',
|
|
291
|
-
'.png': 'image/png',
|
|
292
|
-
'.jpg': 'image/jpeg',
|
|
293
|
-
'.jpeg': 'image/jpeg',
|
|
294
|
-
'.gif': 'image/gif',
|
|
295
|
-
'.webp': 'image/webp',
|
|
296
|
-
'.woff': 'font/woff',
|
|
297
|
-
'.woff2': 'font/woff2',
|
|
298
|
-
'.ttf': 'font/ttf',
|
|
299
|
-
'.xml': 'application/xml',
|
|
300
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
301
|
-
'.manifest': 'text/cache-manifest',
|
|
302
|
-
'.map': 'application/json',
|
|
303
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
304
|
-
'.otf': 'font/otf',
|
|
305
|
-
'.mp4': 'video/mp4',
|
|
306
|
-
'.mp3': 'audio/mpeg',
|
|
307
|
-
'.pdf': 'application/pdf',
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
function listCommands() {
|
|
311
|
-
if (!existsSync(COMMANDS_DIR)) return [];
|
|
312
|
-
const result = [];
|
|
313
|
-
for (const domain of readdirSync(COMMANDS_DIR).sort()) {
|
|
314
|
-
const domainPath = join(COMMANDS_DIR, domain);
|
|
315
|
-
try {
|
|
316
|
-
if (!statSync(domainPath).isDirectory()) continue;
|
|
317
|
-
const commands = [];
|
|
318
|
-
for (const file of readdirSync(domainPath).sort()) {
|
|
319
|
-
if (!file.endsWith('.md')) continue;
|
|
320
|
-
const content = readFileSync(join(domainPath, file), 'utf8');
|
|
321
|
-
const match = content.match(/^---\r?\n[\s\S]*?description:\s*(.+?)\r?\n[\s\S]*?---/);
|
|
322
|
-
const description = match ? match[1].trim() : file.replace('.md', '');
|
|
323
|
-
commands.push({ name: file.replace('.md', ''), description, slash: `/${domain}:${file.replace('.md', '')}` });
|
|
324
|
-
}
|
|
325
|
-
if (commands.length) result.push({ domain, commands });
|
|
326
|
-
} catch { continue; }
|
|
327
|
-
}
|
|
328
|
-
return result;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function listSkills() {
|
|
332
|
-
if (!existsSync(SKILLS_DIR)) return [];
|
|
333
|
-
const result = [];
|
|
334
|
-
for (const cat of readdirSync(SKILLS_DIR)) {
|
|
335
|
-
const catPath = join(SKILLS_DIR, cat);
|
|
336
|
-
try {
|
|
337
|
-
const stat = statSync(catPath);
|
|
338
|
-
if (!stat.isDirectory()) continue;
|
|
339
|
-
const files = readdirSync(catPath)
|
|
340
|
-
.filter(f => f.endsWith('.md') || f.endsWith('.mjs'))
|
|
341
|
-
.filter(f => f !== 'SKILL.md');
|
|
342
|
-
result.push({ category: cat, files });
|
|
343
|
-
} catch { continue; }
|
|
344
|
-
}
|
|
345
|
-
return result;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function buildStatus() {
|
|
349
|
-
const status = await buildSharedStatus({
|
|
350
|
-
rootDir: ROOT_DIR,
|
|
351
|
-
cwd: process.cwd(),
|
|
352
|
-
homeDir: HOME,
|
|
353
|
-
// The dashboard is long-lived and `construct dev` rewrites managed values in
|
|
354
|
-
// ~/.construct/config.env. Read fresh config on every request instead of
|
|
355
|
-
// letting inherited process env shadow updated ports/credentials.
|
|
356
|
-
env: {},
|
|
357
|
-
dashboardPort: PORT,
|
|
358
|
-
selfDashboard: true,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
return {
|
|
362
|
-
...status,
|
|
363
|
-
skills: listSkills(),
|
|
364
|
-
commands: listCommands(),
|
|
365
|
-
artifactManifest: Object.fromEntries(
|
|
366
|
-
artifactTypes({ rootDir: ROOT_DIR }).map((type) => [type, templateMetadata(type, { rootDir: ROOT_DIR })]),
|
|
367
|
-
),
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
async function handleSessionUsage(_req, res) {
|
|
372
|
-
try {
|
|
373
|
-
const status = await buildSharedStatus({
|
|
374
|
-
rootDir: ROOT_DIR,
|
|
375
|
-
cwd: process.cwd(),
|
|
376
|
-
homeDir: HOME,
|
|
377
|
-
env: {},
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const payload = {
|
|
381
|
-
source: status.sessionUsage?.status === 'available' ? 'local-session-log' : 'unavailable',
|
|
382
|
-
status: status.sessionUsage?.status ?? 'unavailable',
|
|
383
|
-
sessionUsage: status.sessionUsage,
|
|
384
|
-
guidance: status.sessionUsage?.status === 'available'
|
|
385
|
-
? 'Construct can answer token usage questions from locally recorded session usage.'
|
|
386
|
-
: 'No local token usage has been recorded for this session yet.',
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
390
|
-
res.end(JSON.stringify(payload));
|
|
391
|
-
} catch (err) {
|
|
392
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
393
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ── Artifacts handler ─────────────────────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
async function handleArtifacts(req, res) {
|
|
400
|
-
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
401
|
-
|
|
402
|
-
if (req.method === 'GET') {
|
|
403
|
-
const type = url.searchParams.get('type') || undefined;
|
|
404
|
-
try {
|
|
405
|
-
const artifacts = listArtifacts({ type, rootDir: ROOT_DIR });
|
|
406
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
407
|
-
res.end(JSON.stringify({ artifacts }));
|
|
408
|
-
} catch (err) {
|
|
409
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
410
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
411
|
-
}
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (req.method === 'POST') {
|
|
416
|
-
let body = '';
|
|
417
|
-
req.on('data', chunk => { body += chunk; });
|
|
418
|
-
req.on('end', () => {
|
|
419
|
-
try {
|
|
420
|
-
const data = JSON.parse(body || '{}');
|
|
421
|
-
const result = generateArtifact({
|
|
422
|
-
type: data.type,
|
|
423
|
-
title: data.title,
|
|
424
|
-
rootDir: ROOT_DIR,
|
|
425
|
-
fields: data.fields || {},
|
|
426
|
-
dryRun: data.dryRun === true,
|
|
427
|
-
});
|
|
428
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
429
|
-
res.end(JSON.stringify({ success: true, ...result }));
|
|
430
|
-
if (!data.dryRun) notifyClients();
|
|
431
|
-
} catch (err) {
|
|
432
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
433
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
434
|
-
}
|
|
435
|
-
});
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
res.writeHead(405);
|
|
440
|
-
res.end('Method Not Allowed');
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// ── Approval queue handler ────────────────────────────────────────────────
|
|
444
|
-
|
|
445
|
-
async function handleApprovals(req, res) {
|
|
446
|
-
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
447
|
-
|
|
448
|
-
if (req.method === 'GET') {
|
|
449
|
-
const pending = approvalQueue.list('pending');
|
|
450
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
451
|
-
res.end(JSON.stringify({ items: pending }));
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (req.method === 'POST') {
|
|
456
|
-
let body = '';
|
|
457
|
-
req.on('data', chunk => { body += chunk; });
|
|
458
|
-
req.on('end', () => {
|
|
459
|
-
try {
|
|
460
|
-
const data = JSON.parse(body || '{}');
|
|
461
|
-
const { action, id, note } = data;
|
|
462
|
-
if (!id) throw new Error('Missing id');
|
|
463
|
-
if (action === 'approve') {
|
|
464
|
-
approvalQueue.approve(id);
|
|
465
|
-
} else if (action === 'reject') {
|
|
466
|
-
approvalQueue.reject(id, { reason: note || 'Rejected via dashboard' });
|
|
467
|
-
} else {
|
|
468
|
-
throw new Error('action must be approve or reject');
|
|
469
|
-
}
|
|
470
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
471
|
-
res.end(JSON.stringify({ success: true }));
|
|
472
|
-
notifyClients();
|
|
473
|
-
} catch (err) {
|
|
474
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
475
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
res.writeHead(405);
|
|
482
|
-
res.end('Method Not Allowed');
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Orchestration daemon surface: start a run (background execution), inspect runs,
|
|
486
|
-
// and stream a single run's lifecycle over SSE. Construct's runtime owns the
|
|
487
|
-
// orchestration; this lets any thin client (editor, TUI, CI) reach the outcome.
|
|
488
|
-
// Responses are wrapped in the versioned, secret-free embedded-contract envelope.
|
|
489
|
-
|
|
490
|
-
async function handleOrchestration(req, res) {
|
|
491
|
-
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
492
|
-
const { startRun, getRun, getRuns } = await import('../orchestration/runtime.mjs');
|
|
493
|
-
const { onRunEvent, requestCancel } = await import('../orchestration/events.mjs');
|
|
494
|
-
const { wrapResponse } = await import('../embedded-contract/envelope.mjs');
|
|
495
|
-
|
|
496
|
-
const send = (code, data) => {
|
|
497
|
-
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
498
|
-
res.end(JSON.stringify(wrapResponse({ data, surface: 'sdk' })));
|
|
499
|
-
};
|
|
500
|
-
const fail = (code, message) => {
|
|
501
|
-
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
502
|
-
res.end(JSON.stringify({ error: message }));
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
if (url.pathname === '/api/orchestration/runs' && req.method === 'POST') {
|
|
506
|
-
let body = '';
|
|
507
|
-
req.on('data', (c) => { body += c; });
|
|
508
|
-
req.on('end', async () => {
|
|
509
|
-
try {
|
|
510
|
-
const d = JSON.parse(body || '{}');
|
|
511
|
-
if (!d.request || typeof d.request !== 'string') throw new Error('Missing "request" string');
|
|
512
|
-
const run = await startRun(
|
|
513
|
-
{ request: d.request, workflowType: d.workflowType, requestedStrategy: d.requestedStrategy, host: d.host, hostModel: d.hostModel, hostProvider: d.hostProvider, fileCount: d.fileCount, moduleCount: d.moduleCount },
|
|
514
|
-
{ cwd: ORCHESTRATION_RUN_ROOT, workerBackend: d.workerBackend || null },
|
|
515
|
-
);
|
|
516
|
-
send(202, run);
|
|
517
|
-
} catch (err) {
|
|
518
|
-
fail(400, err.message);
|
|
519
|
-
}
|
|
520
|
-
});
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (url.pathname === '/api/orchestration/runs' && req.method === 'GET') {
|
|
525
|
-
const limit = Number(url.searchParams.get('limit') || 20);
|
|
526
|
-
send(200, { runs: await getRuns(ORCHESTRATION_RUN_ROOT, { limit }) });
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const match = url.pathname.match(/^\/api\/orchestration\/runs\/([^/]+)(\/events|\/cancel)?$/);
|
|
531
|
-
if (match) {
|
|
532
|
-
const runId = decodeURIComponent(match[1]);
|
|
533
|
-
const sub = match[2];
|
|
534
|
-
|
|
535
|
-
if (!sub && req.method === 'GET') {
|
|
536
|
-
const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
|
|
537
|
-
if (!run) { fail(404, `Run not found: ${runId}`); return; }
|
|
538
|
-
send(200, run);
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (sub === '/cancel' && req.method === 'POST') {
|
|
543
|
-
requestCancel(runId);
|
|
544
|
-
send(202, { runId, cancelRequested: true });
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
if (sub === '/events' && req.method === 'GET') {
|
|
549
|
-
const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
|
|
550
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
551
|
-
res.write(': connected\n\n');
|
|
552
|
-
if (run) {
|
|
553
|
-
const snapshot = { type: 'snapshot', runId, status: run.status, chainOfThought: run.chainOfThought ?? null, tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })) };
|
|
554
|
-
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
555
|
-
}
|
|
556
|
-
const off = onRunEvent(runId, (event) => {
|
|
557
|
-
try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { off(); }
|
|
558
|
-
});
|
|
559
|
-
req.on('close', off);
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
fail(404, 'Not found');
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// ── Config handler ────────────────────────────────────────────────────────
|
|
568
|
-
|
|
569
|
-
function handleConfig(req, res) {
|
|
570
|
-
if (req.method === 'GET') {
|
|
571
|
-
const env = existsSync(CONFIG_ENV_FILE) ? readFileSync(CONFIG_ENV_FILE, 'utf8') : '';
|
|
572
|
-
const embed = existsSync(EMBED_YAML_FILE) ? readFileSync(EMBED_YAML_FILE, 'utf8') : '';
|
|
573
|
-
// Extract roles from embed YAML (simple regex — works for flat keys)
|
|
574
|
-
let roles = { primary: null, secondary: null };
|
|
575
|
-
const primaryMatch = embed.match(/^\s*primary:\s*(.+)$/m);
|
|
576
|
-
const secondaryMatch = embed.match(/^\s*secondary:\s*(.+)$/m);
|
|
577
|
-
if (primaryMatch) roles.primary = primaryMatch[1].trim() || null;
|
|
578
|
-
if (secondaryMatch) roles.secondary = secondaryMatch[1].trim() || null;
|
|
579
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
580
|
-
res.end(JSON.stringify({ env, embed, roles }));
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
if (req.method === 'POST') {
|
|
585
|
-
let body = '';
|
|
586
|
-
req.on('data', chunk => { body += chunk; });
|
|
587
|
-
req.on('end', () => {
|
|
588
|
-
try {
|
|
589
|
-
const { type, content } = JSON.parse(body || '{}');
|
|
590
|
-
if (type !== 'env' && type !== 'embed') throw new Error('type must be env or embed');
|
|
591
|
-
if (typeof content !== 'string') throw new Error('content must be a string');
|
|
592
|
-
mkdirSync(configDir(HOME), { recursive: true });
|
|
593
|
-
const target = type === 'env' ? CONFIG_ENV_FILE : EMBED_YAML_FILE;
|
|
594
|
-
writeFileSync(target, content, 'utf8');
|
|
595
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
596
|
-
res.end(JSON.stringify({ success: true }));
|
|
597
|
-
} catch (err) {
|
|
598
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
599
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
600
|
-
}
|
|
601
|
-
});
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
res.writeHead(405);
|
|
606
|
-
res.end('Method Not Allowed');
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// ── Snapshots handler ─────────────────────────────────────────────────────
|
|
610
|
-
|
|
611
|
-
function handleSnapshots(req, res) {
|
|
612
|
-
if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return; }
|
|
613
|
-
|
|
614
|
-
const snapshots = [];
|
|
615
|
-
if (existsSync(SNAPSHOTS_FILE)) {
|
|
616
|
-
const lines = readFileSync(SNAPSHOTS_FILE, 'utf8').split('\n').filter(Boolean);
|
|
617
|
-
// Return last 20 snapshots most-recent-first
|
|
618
|
-
for (const line of lines.slice(-20).reverse()) {
|
|
619
|
-
try { snapshots.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
624
|
-
res.end(JSON.stringify({ snapshots }));
|
|
625
|
-
}
|
|
626
|
-
const activeWatchers = [];
|
|
627
|
-
let watchRefreshTimer = null;
|
|
628
|
-
|
|
629
|
-
function notifyClients(event) {
|
|
630
|
-
const payload = event ? `data: ${JSON.stringify(event)}\n\n` : 'data: refresh\n\n';
|
|
631
|
-
for (const res of sseClients) {
|
|
632
|
-
try { res.write(payload); }
|
|
633
|
-
catch { sseClients.delete(res); }
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function closeWatchers() {
|
|
638
|
-
while (activeWatchers.length) {
|
|
639
|
-
try { activeWatchers.pop().close(); }
|
|
640
|
-
catch { /* ignore close errors */ }
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function scheduleWatchRefresh() {
|
|
645
|
-
clearTimeout(watchRefreshTimer);
|
|
646
|
-
watchRefreshTimer = setTimeout(() => {
|
|
647
|
-
watchFiles();
|
|
648
|
-
notifyClients();
|
|
649
|
-
}, 150);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function addWatcher(target) {
|
|
653
|
-
if (!existsSync(target)) return;
|
|
654
|
-
try {
|
|
655
|
-
const watcher = watch(target, () => scheduleWatchRefresh());
|
|
656
|
-
activeWatchers.push(watcher);
|
|
657
|
-
} catch {
|
|
658
|
-
/* ignore watch errors */
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function addDirectoryTreeWatch(root) {
|
|
663
|
-
if (!existsSync(root)) return;
|
|
664
|
-
addWatcher(root);
|
|
665
|
-
try {
|
|
666
|
-
for (const entry of readdirSync(root)) {
|
|
667
|
-
const full = join(root, entry);
|
|
668
|
-
try {
|
|
669
|
-
if (statSync(full).isDirectory()) addWatcher(full);
|
|
670
|
-
} catch {
|
|
671
|
-
/* ignore stat errors */
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
} catch {
|
|
675
|
-
/* ignore read errors */
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function watchFiles() {
|
|
680
|
-
closeWatchers();
|
|
681
|
-
[REGISTRY_FILE, FEATURES_FILE, WORKFLOW_FILE].forEach(addWatcher);
|
|
682
|
-
addDirectoryTreeWatch(SKILLS_DIR);
|
|
683
|
-
addDirectoryTreeWatch(COMMANDS_DIR);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const server = createServer(async (req, res) => {
|
|
687
|
-
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
688
|
-
|
|
689
|
-
// ── Security middleware ────────────────────────────────────────────────
|
|
690
|
-
// Imported lazily so a missing module never breaks the server boot.
|
|
691
|
-
let applyCors, ensureCsrfCookie, verifyCsrf, csrfDefaultSkip, checkRateLimit, logger;
|
|
692
|
-
try {
|
|
693
|
-
({ applyCors } = await import('./cors.mjs'));
|
|
694
|
-
({ ensureCsrfCookie, verifyCsrf, defaultSkip: csrfDefaultSkip } = await import('./csrf.mjs'));
|
|
695
|
-
({ checkRateLimit } = await import('./rate-limit.mjs'));
|
|
696
|
-
({ logger } = await import('../logger.mjs'));
|
|
697
|
-
} catch { /* run without the middleware if any module is unavailable */ }
|
|
698
|
-
|
|
699
|
-
if (applyCors) {
|
|
700
|
-
if (applyCors(req, res, { env: process.env })) return;
|
|
701
|
-
}
|
|
702
|
-
if (ensureCsrfCookie && req.method === 'GET') {
|
|
703
|
-
ensureCsrfCookie(req, res);
|
|
704
|
-
}
|
|
705
|
-
const rateTier = url.pathname === '/api/chat/stream' || url.pathname === '/api/chat'
|
|
706
|
-
|| url.pathname === '/api/chat/loop/stream'
|
|
707
|
-
? 'chat'
|
|
708
|
-
: url.pathname === '/api/chat/loop/command'
|
|
709
|
-
? 'command'
|
|
710
|
-
: (req.method !== 'GET' && req.method !== 'HEAD' ? 'write' : 'read');
|
|
711
|
-
if (checkRateLimit) {
|
|
712
|
-
const limit = checkRateLimit(req, rateTier, { env: process.env });
|
|
713
|
-
if (!limit.allowed) {
|
|
714
|
-
res.writeHead(429, {
|
|
715
|
-
'Content-Type': 'application/json',
|
|
716
|
-
'Retry-After': Math.ceil(limit.retryAfterMs / 1000),
|
|
717
|
-
});
|
|
718
|
-
res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: limit.retryAfterMs }));
|
|
719
|
-
logger?.().warn('http.rate_limited', { route: url.pathname, tier: rateTier });
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
if (verifyCsrf && !verifyCsrf(req, { skip: csrfDefaultSkip })) {
|
|
724
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
725
|
-
res.end(JSON.stringify({ error: 'csrf_token_missing_or_invalid' }));
|
|
726
|
-
logger?.().warn('http.csrf_blocked', { route: url.pathname });
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// ── Auth endpoints (always public) ──────────────────────────────────────
|
|
731
|
-
if (url.pathname === '/api/auth/status' && req.method === 'GET') {
|
|
732
|
-
const auth = getAuthConfig();
|
|
733
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
734
|
-
res.end(JSON.stringify({
|
|
735
|
-
configured: auth.tokenConfigured,
|
|
736
|
-
authenticated: isAuthenticated(req),
|
|
737
|
-
auth,
|
|
738
|
-
}));
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
|
743
|
-
let body = '';
|
|
744
|
-
req.on('data', c => { body += c; });
|
|
745
|
-
req.on('end', () => {
|
|
746
|
-
try {
|
|
747
|
-
const { token } = JSON.parse(body || '{}');
|
|
748
|
-
if (!validateToken(token)) {
|
|
749
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
750
|
-
res.end(JSON.stringify({ error: 'Invalid token' }));
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
const sessionToken = createSession();
|
|
754
|
-
res.writeHead(200, {
|
|
755
|
-
'Content-Type': 'application/json',
|
|
756
|
-
'Set-Cookie': sessionCookieHeader(sessionToken),
|
|
757
|
-
});
|
|
758
|
-
res.end(JSON.stringify({ success: true }));
|
|
759
|
-
} catch {
|
|
760
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
761
|
-
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
|
|
768
|
-
res.writeHead(200, {
|
|
769
|
-
'Content-Type': 'application/json',
|
|
770
|
-
'Set-Cookie': clearSessionCookieHeader(),
|
|
771
|
-
});
|
|
772
|
-
res.end(JSON.stringify({ success: true }));
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// ── Webhook ingestion (public — signature-verified per provider) ─────────
|
|
777
|
-
if (url.pathname.startsWith('/api/webhooks/') && req.method === 'POST') {
|
|
778
|
-
await getWebhookHandler()(req, res);
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// ── Slack slash commands (public — HMAC-verified) ────────────────────────
|
|
783
|
-
if (url.pathname === '/api/slack/commands' && req.method === 'POST') {
|
|
784
|
-
await getSlackCommandHandler()(req, res);
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// ── Auth gate for all /api/* routes ─────────────────────────────────────
|
|
789
|
-
if (url.pathname.startsWith('/api/') && !isAuthenticated(req)) {
|
|
790
|
-
rejectUnauthorized(res);
|
|
791
|
-
return;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
if (url.pathname === '/events') {
|
|
795
|
-
res.writeHead(200, {
|
|
796
|
-
'Content-Type': 'text/event-stream',
|
|
797
|
-
'Cache-Control': 'no-cache',
|
|
798
|
-
'Connection': 'keep-alive',
|
|
799
|
-
'Access-Control-Allow-Origin': '*',
|
|
800
|
-
});
|
|
801
|
-
res.write(': connected\n\n');
|
|
802
|
-
sseClients.add(res);
|
|
803
|
-
req.on('close', () => sseClients.delete(res));
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
if (url.pathname.startsWith('/api/orchestration/')) {
|
|
808
|
-
await handleOrchestration(req, res);
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
if (url.pathname === '/api/registry' && req.method === 'GET') {
|
|
813
|
-
try {
|
|
814
|
-
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
815
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
816
|
-
res.end(JSON.stringify({ mcpServers: registry.mcpServers ?? {}, models: registry.models ?? {} }));
|
|
817
|
-
} catch (err) {
|
|
818
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
819
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
820
|
-
}
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Provider-agnostic telemetry-UI sign-in. An external backend configured via
|
|
825
|
-
// CONSTRUCT_TELEMETRY_URL uses the vendor-neutral bridge; with none set, fall
|
|
826
|
-
// back to the zero-touch local Langfuse stack. `/langfuse/login` stays as a
|
|
827
|
-
// back-compat alias of the canonical `/telemetry/login`.
|
|
828
|
-
|
|
829
|
-
if ((url.pathname === '/api/services/telemetry/login' || url.pathname === '/api/services/langfuse/login') && req.method === 'GET') {
|
|
830
|
-
if (process.env.CONSTRUCT_TELEMETRY_URL) {
|
|
831
|
-
const { handleTelemetryLogin } = await import('./telemetry-login.mjs');
|
|
832
|
-
await handleTelemetryLogin(req, res, {});
|
|
833
|
-
} else {
|
|
834
|
-
const { handleLangfuseLogin } = await import('./langfuse-login.mjs');
|
|
835
|
-
await handleLangfuseLogin(req, res);
|
|
836
|
-
}
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
if (url.pathname === '/api/status') {
|
|
841
|
-
try {
|
|
842
|
-
const status = await buildStatus();
|
|
843
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
844
|
-
res.end(JSON.stringify(status));
|
|
845
|
-
} catch (err) {
|
|
846
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
847
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
848
|
-
}
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
if (url.pathname === '/api/certification/status' && req.method === 'GET') {
|
|
853
|
-
try {
|
|
854
|
-
const { buildCertificationDashboard } = await import('../certification/dashboard-api.mjs');
|
|
855
|
-
const capabilityId = url.searchParams.get('capabilityId');
|
|
856
|
-
const payload = capabilityId
|
|
857
|
-
? (await import('../certification/status.mjs')).buildCertificationStatus({
|
|
858
|
-
rootDir: process.cwd(),
|
|
859
|
-
capabilityId,
|
|
860
|
-
})
|
|
861
|
-
: buildCertificationDashboard({ rootDir: process.cwd() });
|
|
862
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
863
|
-
res.end(JSON.stringify(payload));
|
|
864
|
-
} catch (err) {
|
|
865
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
866
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
867
|
-
}
|
|
868
|
-
return;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
if (url.pathname === '/api/oracle/pending' && req.method === 'GET') {
|
|
872
|
-
try {
|
|
873
|
-
const { listPending } = await import('../oracle/actions.mjs');
|
|
874
|
-
const pending = listPending(process.cwd()).filter((p) => p.status !== 'approved');
|
|
875
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
876
|
-
res.end(JSON.stringify({ pending }));
|
|
877
|
-
} catch (err) {
|
|
878
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
879
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
880
|
-
}
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
if (url.pathname === '/api/improvement/pending' && req.method === 'GET') {
|
|
885
|
-
try {
|
|
886
|
-
const { listPending } = await import('../improvement/surface.mjs');
|
|
887
|
-
const pending = listPending(process.cwd());
|
|
888
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
889
|
-
res.end(JSON.stringify({ pending }));
|
|
890
|
-
} catch (err) {
|
|
891
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
892
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
893
|
-
}
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
if (url.pathname.startsWith('/api/improvement/') && req.method === 'POST') {
|
|
898
|
-
const action = url.pathname.slice('/api/improvement/'.length);
|
|
899
|
-
let body = '';
|
|
900
|
-
req.on('data', (chunk) => { body += chunk; });
|
|
901
|
-
req.on('end', async () => {
|
|
902
|
-
try {
|
|
903
|
-
const data = JSON.parse(body || '{}');
|
|
904
|
-
const projectDir = process.cwd();
|
|
905
|
-
const { reviewRecord, approveRecord, applyRecord, rollbackRecord } = await import('../improvement/surface.mjs');
|
|
906
|
-
let result;
|
|
907
|
-
if (action === 'review') {
|
|
908
|
-
if (!data.id) throw new Error('Missing id');
|
|
909
|
-
result = reviewRecord(projectDir, data.id);
|
|
910
|
-
} else if (action === 'approve') {
|
|
911
|
-
if (!data.id) throw new Error('Missing id');
|
|
912
|
-
result = approveRecord(projectDir, data.id, { identity: data.identity });
|
|
913
|
-
} else if (action === 'apply') {
|
|
914
|
-
if (!data.id) throw new Error('Missing id');
|
|
915
|
-
result = applyRecord(projectDir, data.id, { monitor: data.monitor });
|
|
916
|
-
} else if (action === 'rollback') {
|
|
917
|
-
if (!data.id) throw new Error('Missing id');
|
|
918
|
-
result = rollbackRecord(projectDir, data.id, { reason: data.reason });
|
|
919
|
-
} else {
|
|
920
|
-
throw new Error(`Unknown improvement action: ${action}`);
|
|
921
|
-
}
|
|
922
|
-
const status = result.ok === false && result.error ? 400 : 200;
|
|
923
|
-
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
924
|
-
res.end(JSON.stringify(result));
|
|
925
|
-
notifyClients();
|
|
926
|
-
} catch (err) {
|
|
927
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
928
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
929
|
-
}
|
|
930
|
-
});
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
if (url.pathname.startsWith('/api/improvement/') && req.method === 'GET') {
|
|
935
|
-
const id = decodeURIComponent(url.pathname.slice('/api/improvement/'.length));
|
|
936
|
-
if (id && id !== 'pending') {
|
|
937
|
-
try {
|
|
938
|
-
const { loadRecord } = await import('../improvement/store.mjs');
|
|
939
|
-
const record = loadRecord(process.cwd(), id);
|
|
940
|
-
if (!record) {
|
|
941
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
942
|
-
res.end(JSON.stringify({ error: 'not-found' }));
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
946
|
-
res.end(JSON.stringify(record));
|
|
947
|
-
} catch (err) {
|
|
948
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
949
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
950
|
-
}
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
if (url.pathname === '/api/oracle' && req.method === 'GET') {
|
|
956
|
-
try {
|
|
957
|
-
const { homedir: osHome } = await import('node:os');
|
|
958
|
-
const { readHeartbeat } = await import('../daemons/contract.mjs');
|
|
959
|
-
const { heartbeatPath, readLastTick } = await import('../oracle/index.mjs');
|
|
960
|
-
const { collectReadModel, enrichReadModel } = await import('../oracle/read-model.mjs');
|
|
961
|
-
const { synthesizeVerdict } = await import('../oracle/synthesize.mjs');
|
|
962
|
-
const home = osHome();
|
|
963
|
-
const projectDir = process.cwd();
|
|
964
|
-
const readModel = collectReadModel({ rootDir: ROOT_DIR, projectDir, homeDir: home });
|
|
965
|
-
const enriched = await enrichReadModel(readModel);
|
|
966
|
-
const synthesis = synthesizeVerdict(enriched);
|
|
967
|
-
const hb = readHeartbeat(heartbeatPath(home));
|
|
968
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
969
|
-
res.end(JSON.stringify({
|
|
970
|
-
running: !!hb,
|
|
971
|
-
heartbeat: hb,
|
|
972
|
-
lastTick: readLastTick(home),
|
|
973
|
-
verdict: synthesis.verdict,
|
|
974
|
-
gaps: synthesis.gaps,
|
|
975
|
-
recommendedActions: synthesis.recommendedActions,
|
|
976
|
-
orgGraph: enriched.orgGraph,
|
|
977
|
-
openBeads: enriched.beads?.openCount ?? null,
|
|
978
|
-
lastPropagationCheck: enriched.orgGraph?.propagation ?? null,
|
|
979
|
-
}));
|
|
980
|
-
} catch (err) {
|
|
981
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
982
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
983
|
-
}
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
if (url.pathname === '/api/doctor') {
|
|
988
|
-
try {
|
|
989
|
-
const { readState } = await import('../doctor/index.mjs');
|
|
990
|
-
const { recent } = await import('../doctor/audit.mjs');
|
|
991
|
-
const { getTotalDailySpend, getDailySpend, totalBudget, personaBudget, dayKey } = await import('../cost-ledger.mjs');
|
|
992
|
-
const { listOnboardedPersonas } = await import('../roles/manifest.mjs');
|
|
993
|
-
const { listPending } = await import('../roles/gateway.mjs');
|
|
994
|
-
const { existsSync: fsExists, readFileSync: fsRead } = await import('node:fs');
|
|
995
|
-
const { join: pathJoin } = await import('node:path');
|
|
996
|
-
const { homedir: osHome } = await import('node:os');
|
|
997
|
-
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
998
|
-
const watcher = url.searchParams.get('watcher') || undefined;
|
|
999
|
-
const personas = listOnboardedPersonas();
|
|
1000
|
-
const costsByPersona = {};
|
|
1001
|
-
for (const p of personas) {
|
|
1002
|
-
const spend = getDailySpend({ personaId: p });
|
|
1003
|
-
if (spend.invocations > 0 || spend.costUsd > 0) {
|
|
1004
|
-
costsByPersona[p] = { spent: spend.costUsd, cap: personaBudget(p), invocations: spend.invocations };
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
const total = getTotalDailySpend();
|
|
1008
|
-
const approvalPath = pathJoin(osHome(), '.cx', 'approval-pending.jsonl');
|
|
1009
|
-
let approvals = [];
|
|
1010
|
-
if (fsExists(approvalPath)) {
|
|
1011
|
-
approvals = fsRead(approvalPath, 'utf8').split('\n').filter(Boolean)
|
|
1012
|
-
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
1013
|
-
.filter(Boolean)
|
|
1014
|
-
.slice(-25).reverse();
|
|
1015
|
-
}
|
|
1016
|
-
const pendingRoleInvocations = listPending({ unresolved: true }).slice(-25).reverse();
|
|
1017
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1018
|
-
res.end(JSON.stringify({
|
|
1019
|
-
daemon: readState(),
|
|
1020
|
-
audit: recent({ watcher, limit }),
|
|
1021
|
-
cost: { dayKey: dayKey(), total: { spent: total.costUsd, cap: totalBudget(), invocations: total.invocations }, byPersona: costsByPersona },
|
|
1022
|
-
approvals,
|
|
1023
|
-
pendingRoleInvocations,
|
|
1024
|
-
onboardedPersonas: personas,
|
|
1025
|
-
}));
|
|
1026
|
-
} catch (err) {
|
|
1027
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1028
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1029
|
-
}
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
if (url.pathname.startsWith('/api/overrides/') && req.method === 'GET') {
|
|
1034
|
-
try {
|
|
1035
|
-
const { describeOverrides, resolveOverride, readResolved, listBackups, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
|
|
1036
|
-
const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
|
|
1037
|
-
const category = segs[0];
|
|
1038
|
-
if (!category || !SUPPORTED_CATEGORIES.includes(category)) {
|
|
1039
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1040
|
-
res.end(JSON.stringify({ error: `unknown category: ${category}. Use ${SUPPORTED_CATEGORIES.join(', ')}` }));
|
|
1041
|
-
return;
|
|
1042
|
-
}
|
|
1043
|
-
if (segs.length === 1) {
|
|
1044
|
-
const root = ROOT_DIR;
|
|
1045
|
-
const overrides = describeOverrides(root)[category] || [];
|
|
1046
|
-
const cwdOverrides = describeOverrides(process.cwd())[category] || [];
|
|
1047
|
-
let originals = [];
|
|
1048
|
-
try {
|
|
1049
|
-
const dir =
|
|
1050
|
-
category === 'contracts' || category === 'role-manifests'
|
|
1051
|
-
? join(root, 'specialists')
|
|
1052
|
-
: category === 'agents'
|
|
1053
|
-
? join(root, 'specialists', 'prompts')
|
|
1054
|
-
: join(root, category);
|
|
1055
|
-
if (existsSync(dir)) {
|
|
1056
|
-
const walk = (rel = '') => {
|
|
1057
|
-
const cur = join(dir, rel);
|
|
1058
|
-
for (const name of readdirSync(cur)) {
|
|
1059
|
-
const full = join(cur, name);
|
|
1060
|
-
const relPath = rel ? `${rel}/${name}` : name;
|
|
1061
|
-
if (statSync(full).isDirectory()) { walk(relPath); continue; }
|
|
1062
|
-
if (!name.endsWith('.md') && !name.endsWith('.json')) continue;
|
|
1063
|
-
originals.push(relPath);
|
|
1064
|
-
}
|
|
1065
|
-
};
|
|
1066
|
-
walk();
|
|
1067
|
-
}
|
|
1068
|
-
} catch { /* best effort */ }
|
|
1069
|
-
const items = originals.map((name) => ({
|
|
1070
|
-
name,
|
|
1071
|
-
hasOverride: overrides.includes(name) || cwdOverrides.includes(name),
|
|
1072
|
-
source: overrides.includes(name) || cwdOverrides.includes(name) ? 'override' : 'original',
|
|
1073
|
-
}));
|
|
1074
|
-
for (const ov of [...overrides, ...cwdOverrides]) {
|
|
1075
|
-
if (!originals.includes(ov)) items.push({ name: ov, hasOverride: true, source: 'override', custom: true });
|
|
1076
|
-
}
|
|
1077
|
-
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
1078
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1079
|
-
res.end(JSON.stringify({ category, items }));
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
|
|
1083
|
-
const action = url.searchParams.get('action');
|
|
1084
|
-
if (action === 'backups') {
|
|
1085
|
-
const backups = listBackups(process.cwd(), category, name);
|
|
1086
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1087
|
-
res.end(JSON.stringify({ category, name, backups: backups.map((b) => ({ filename: b.filename, mtimeMs: b.mtimeMs, size: b.size })) }));
|
|
1088
|
-
return;
|
|
1089
|
-
}
|
|
1090
|
-
const tryRoot = (root) => {
|
|
1091
|
-
const r = resolveOverride(root, category, name);
|
|
1092
|
-
if (r.path) return { ...r, content: readFileSync(r.path, 'utf8') };
|
|
1093
|
-
return null;
|
|
1094
|
-
};
|
|
1095
|
-
const result = tryRoot(process.cwd()) || tryRoot(ROOT_DIR);
|
|
1096
|
-
if (!result) {
|
|
1097
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1098
|
-
res.end(JSON.stringify({ error: 'not found', category, name }));
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1102
|
-
res.end(JSON.stringify({ category, name, source: result.source, content: result.content, path: result.path }));
|
|
1103
|
-
} catch (err) {
|
|
1104
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1105
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1106
|
-
}
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
if (url.pathname.startsWith('/api/overrides/') && (req.method === 'PUT' || req.method === 'POST')) {
|
|
1111
|
-
let body = '';
|
|
1112
|
-
req.on('data', (chunk) => { body += chunk; });
|
|
1113
|
-
req.on('end', async () => {
|
|
1114
|
-
try {
|
|
1115
|
-
const { applyEdit, restoreFromBackup, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
|
|
1116
|
-
const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
|
|
1117
|
-
const category = segs[0];
|
|
1118
|
-
if (!SUPPORTED_CATEGORIES.includes(category)) {
|
|
1119
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1120
|
-
res.end(JSON.stringify({ error: `unknown category: ${category}` }));
|
|
1121
|
-
return;
|
|
1122
|
-
}
|
|
1123
|
-
const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
|
|
1124
|
-
const action = url.searchParams.get('action');
|
|
1125
|
-
const payload = JSON.parse(body || '{}');
|
|
1126
|
-
if (action === 'restore') {
|
|
1127
|
-
if (!payload.backupFilename) { res.writeHead(400); res.end(JSON.stringify({ error: 'backupFilename required' })); return; }
|
|
1128
|
-
const result = restoreFromBackup(process.cwd(), category, name, payload.backupFilename);
|
|
1129
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1130
|
-
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
if (typeof payload.content !== 'string') {
|
|
1134
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1135
|
-
res.end(JSON.stringify({ error: 'content (string) required' }));
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
const result = applyEdit(process.cwd(), category, name, payload.content);
|
|
1139
|
-
try {
|
|
1140
|
-
const auditDir = join(process.cwd(), '.cx');
|
|
1141
|
-
mkdirSync(auditDir, { recursive: true });
|
|
1142
|
-
appendFileSync(join(auditDir, 'audit.jsonl'), JSON.stringify({
|
|
1143
|
-
ts: new Date().toISOString(),
|
|
1144
|
-
action: `override.${category}.${name}.write`,
|
|
1145
|
-
backupPath: result.backupPath,
|
|
1146
|
-
bytes: result.wrote,
|
|
1147
|
-
}) + '\n');
|
|
1148
|
-
} catch { /* audit best-effort */ }
|
|
1149
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1150
|
-
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1151
|
-
} catch (err) {
|
|
1152
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1153
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1154
|
-
}
|
|
1155
|
-
});
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
if (url.pathname === '/api/project-config' && req.method === 'GET') {
|
|
1160
|
-
try {
|
|
1161
|
-
const { loadProjectConfig } = await import('../config/project-config.mjs');
|
|
1162
|
-
const result = loadProjectConfig(process.cwd(), process.env);
|
|
1163
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1164
|
-
res.end(JSON.stringify(result));
|
|
1165
|
-
} catch (err) {
|
|
1166
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1167
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1168
|
-
}
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
if (url.pathname === '/api/project-config' && (req.method === 'PUT' || req.method === 'PATCH')) {
|
|
1173
|
-
let body = '';
|
|
1174
|
-
req.on('data', (chunk) => { body += chunk; });
|
|
1175
|
-
req.on('end', async () => {
|
|
1176
|
-
try {
|
|
1177
|
-
const payload = JSON.parse(body || '{}');
|
|
1178
|
-
const { writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
|
|
1179
|
-
const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
1180
|
-
writeProjectConfig(cfgPath, payload);
|
|
1181
|
-
try {
|
|
1182
|
-
const auditDir = join(process.cwd(), '.cx');
|
|
1183
|
-
mkdirSync(auditDir, { recursive: true });
|
|
1184
|
-
const entry = JSON.stringify({
|
|
1185
|
-
ts: new Date().toISOString(),
|
|
1186
|
-
action: 'project-config.write',
|
|
1187
|
-
path: cfgPath,
|
|
1188
|
-
keys: Object.keys(payload || {}),
|
|
1189
|
-
}) + '\n';
|
|
1190
|
-
writeFileSync(join(auditDir, 'audit.jsonl'), entry, { flag: 'a' });
|
|
1191
|
-
} catch { /* audit is best-effort */ }
|
|
1192
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1193
|
-
res.end(JSON.stringify({ ok: true, path: cfgPath }));
|
|
1194
|
-
} catch (err) {
|
|
1195
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1196
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1197
|
-
}
|
|
1198
|
-
});
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
if (url.pathname === '/api/performance/reviews' && req.method === 'GET') {
|
|
1203
|
-
try {
|
|
1204
|
-
const dir = join(doctorRoot(), 'performance-reviews');
|
|
1205
|
-
if (!existsSync(dir)) {
|
|
1206
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1207
|
-
res.end(JSON.stringify({ reviews: [], total: 0, mockFiltered: 0, generatorImplemented: false }));
|
|
1208
|
-
return;
|
|
1209
|
-
}
|
|
1210
|
-
const allFiles = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
1211
|
-
// Filter out test/mock fixtures — anything matching `test-*-mock.json` or
|
|
1212
|
-
// `*-mock.json` is a seeded fixture, not a real session-end review.
|
|
1213
|
-
// The real generator isn't wired yet; until it is, this endpoint should
|
|
1214
|
-
// return an honest empty list rather than parade fake data.
|
|
1215
|
-
const realFiles = allFiles.filter((f) => !/(^test-|-mock\.json$)/i.test(f));
|
|
1216
|
-
const files = realFiles
|
|
1217
|
-
.map((f) => {
|
|
1218
|
-
try {
|
|
1219
|
-
const full = join(dir, f);
|
|
1220
|
-
const stat = statSync(full);
|
|
1221
|
-
const body = JSON.parse(readFileSync(full, 'utf8'));
|
|
1222
|
-
return { filename: f, mtimeMs: stat.mtimeMs, ...body };
|
|
1223
|
-
} catch { return null; }
|
|
1224
|
-
})
|
|
1225
|
-
.filter(Boolean)
|
|
1226
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1227
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1228
|
-
res.end(JSON.stringify({
|
|
1229
|
-
reviews: files,
|
|
1230
|
-
total: files.length,
|
|
1231
|
-
mockFiltered: allFiles.length - realFiles.length,
|
|
1232
|
-
generatorImplemented: true,
|
|
1233
|
-
}));
|
|
1234
|
-
} catch (err) {
|
|
1235
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1236
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1237
|
-
}
|
|
1238
|
-
return;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
if (url.pathname === '/api/performance/feedback' && req.method === 'POST') {
|
|
1242
|
-
let body = '';
|
|
1243
|
-
req.on('data', (chunk) => { body += chunk; });
|
|
1244
|
-
req.on('end', () => {
|
|
1245
|
-
try {
|
|
1246
|
-
const payload = JSON.parse(body || '{}');
|
|
1247
|
-
if (!payload.agent || !payload.text) {
|
|
1248
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1249
|
-
res.end(JSON.stringify({ error: 'agent + text required' }));
|
|
1250
|
-
return;
|
|
1251
|
-
}
|
|
1252
|
-
const dir = join(process.cwd(), '.cx', 'feedback');
|
|
1253
|
-
mkdirSync(dir, { recursive: true });
|
|
1254
|
-
const ts = new Date().toISOString();
|
|
1255
|
-
const filename = `${ts.replace(/[:.]/g, '-')}-${payload.agent}.json`;
|
|
1256
|
-
writeFileSync(join(dir, filename), JSON.stringify({
|
|
1257
|
-
ts,
|
|
1258
|
-
agent: payload.agent,
|
|
1259
|
-
text: payload.text,
|
|
1260
|
-
proposedChanges: payload.proposedChanges || null,
|
|
1261
|
-
}, null, 2));
|
|
1262
|
-
appendFileSync(join(process.cwd(), '.cx', 'audit.jsonl'), JSON.stringify({
|
|
1263
|
-
ts,
|
|
1264
|
-
action: 'performance.feedback.submitted',
|
|
1265
|
-
agent: payload.agent,
|
|
1266
|
-
filename,
|
|
1267
|
-
}) + '\n');
|
|
1268
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1269
|
-
res.end(JSON.stringify({ ok: true, filename }));
|
|
1270
|
-
} catch (err) {
|
|
1271
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1272
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1273
|
-
}
|
|
1274
|
-
});
|
|
1275
|
-
return;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
if (url.pathname === '/api/audit' && req.method === 'GET') {
|
|
1279
|
-
try {
|
|
1280
|
-
const auditPath = join(process.cwd(), '.cx', 'audit.jsonl');
|
|
1281
|
-
if (!existsSync(auditPath)) {
|
|
1282
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1283
|
-
res.end(JSON.stringify({ entries: [], total: 0 }));
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
|
-
const limit = Math.min(500, Number(url.searchParams.get('limit')) || 200);
|
|
1287
|
-
const raw = readFileSync(auditPath, 'utf8');
|
|
1288
|
-
const lines = raw.split('\n').filter(Boolean);
|
|
1289
|
-
const entries = [];
|
|
1290
|
-
for (let i = Math.max(0, lines.length - limit); i < lines.length; i++) {
|
|
1291
|
-
try { entries.push(JSON.parse(lines[i])); } catch { /* skip malformed */ }
|
|
1292
|
-
}
|
|
1293
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1294
|
-
res.end(JSON.stringify({ entries: entries.reverse(), total: lines.length }));
|
|
1295
|
-
} catch (err) {
|
|
1296
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1297
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1298
|
-
}
|
|
1299
|
-
return;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
if (url.pathname === '/api/insights' && req.method === 'GET') {
|
|
1303
|
-
try {
|
|
1304
|
-
const { buildInsights } = await import('./insights.mjs');
|
|
1305
|
-
const data = await buildInsights({ env: process.env, cwd: process.cwd(), rootDir: ROOT_DIR });
|
|
1306
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1307
|
-
res.end(JSON.stringify(data));
|
|
1308
|
-
} catch (err) {
|
|
1309
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1310
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1311
|
-
}
|
|
1312
|
-
return;
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
if (url.pathname === '/api/alias' && req.method === 'GET') {
|
|
1316
|
-
try {
|
|
1317
|
-
const { resolveAlias } = await import('../config/alias.mjs');
|
|
1318
|
-
const r = resolveAlias({ cwd: process.cwd(), env: process.env });
|
|
1319
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1320
|
-
res.end(JSON.stringify(r));
|
|
1321
|
-
} catch (err) {
|
|
1322
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1323
|
-
res.end(JSON.stringify({ error: err.message, value: 'Construct', source: 'default' }));
|
|
1324
|
-
}
|
|
1325
|
-
return;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
if (url.pathname === '/api/mode' && req.method === 'GET') {
|
|
1329
|
-
try {
|
|
1330
|
-
const env = loadConstructEnv();
|
|
1331
|
-
const embedYamlPath = join(configDir(HOME), 'embed.yaml');
|
|
1332
|
-
const embedStatus = resolveEmbedStatus(env);
|
|
1333
|
-
|
|
1334
|
-
// Determine mode based on embed status and configuration
|
|
1335
|
-
let mode = 'init';
|
|
1336
|
-
if (embedStatus.level === 'running') {
|
|
1337
|
-
mode = 'embed';
|
|
1338
|
-
} else if (existsSync(embedYamlPath)) {
|
|
1339
|
-
mode = 'live';
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// Get instance ID if set
|
|
1343
|
-
const instanceId = env.CONSTRUCT_INSTANCE_ID || null;
|
|
1344
|
-
|
|
1345
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1346
|
-
res.end(JSON.stringify({
|
|
1347
|
-
mode,
|
|
1348
|
-
instanceId,
|
|
1349
|
-
embedStatus: embedStatus.level,
|
|
1350
|
-
embedYamlExists: existsSync(embedYamlPath),
|
|
1351
|
-
embedYamlPath
|
|
1352
|
-
}));
|
|
1353
|
-
} catch (err) {
|
|
1354
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1355
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1356
|
-
}
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
|
|
1361
|
-
try {
|
|
1362
|
-
const env = loadConstructEnv();
|
|
1363
|
-
const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
|
|
1364
|
-
const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
|
|
1365
|
-
const parentUrl = env.CONSTRUCT_PARENT_URL || null;
|
|
1366
|
-
const isEmbedded = !!parentConstruct;
|
|
1367
|
-
const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
|
|
1368
|
-
const embedStatus = resolveEmbedStatus(env);
|
|
1369
|
-
|
|
1370
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1371
|
-
res.end(JSON.stringify({
|
|
1372
|
-
boundaryStatus,
|
|
1373
|
-
instanceId,
|
|
1374
|
-
parentConstruct,
|
|
1375
|
-
parentUrl,
|
|
1376
|
-
isEmbedded,
|
|
1377
|
-
embedStatus: embedStatus.level,
|
|
1378
|
-
embedConfigExists: existsSync(join(configDir(HOME), 'embed.yaml')),
|
|
1379
|
-
capabilities: {
|
|
1380
|
-
modeDetection: true,
|
|
1381
|
-
snapshotStatus: true,
|
|
1382
|
-
approvalQueue: true,
|
|
1383
|
-
configManagement: true
|
|
1384
|
-
}
|
|
1385
|
-
}));
|
|
1386
|
-
} catch (err) {
|
|
1387
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1388
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1389
|
-
}
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
if (url.pathname === '/api/embed/boundary/register' && req.method === 'POST') {
|
|
1394
|
-
try {
|
|
1395
|
-
const chunks = [];
|
|
1396
|
-
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1397
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
1398
|
-
|
|
1399
|
-
const { parentInstance, parentUrl, childInstanceId, nonce, signature } = body;
|
|
1400
|
-
|
|
1401
|
-
if (!parentInstance || !parentUrl) {
|
|
1402
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1403
|
-
res.end(JSON.stringify({ error: 'parentInstance and parentUrl are required' }));
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
const result = await registerBoundary({
|
|
1408
|
-
parentInstance,
|
|
1409
|
-
parentUrl,
|
|
1410
|
-
childInstanceId: childInstanceId || process.env.CONSTRUCT_INSTANCE_ID || 'default',
|
|
1411
|
-
nonce,
|
|
1412
|
-
signature,
|
|
1413
|
-
sharedSecret: process.env.CONSTRUCT_BOUNDARY_SECRET || null,
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
if (!result.ok) {
|
|
1417
|
-
res.writeHead(result.status || 400, { 'Content-Type': 'application/json' });
|
|
1418
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1419
|
-
return;
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1423
|
-
res.end(JSON.stringify({
|
|
1424
|
-
success: true,
|
|
1425
|
-
message: 'Boundary registration accepted',
|
|
1426
|
-
boundaryConfig: result.config,
|
|
1427
|
-
boundaryConfigPath: result.path,
|
|
1428
|
-
}));
|
|
1429
|
-
} catch (err) {
|
|
1430
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1431
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1432
|
-
}
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
if (url.pathname === '/api/embed/status' && req.method === 'GET') {
|
|
1437
|
-
try {
|
|
1438
|
-
const env = loadConstructEnv();
|
|
1439
|
-
const status = resolveEmbedStatus(env);
|
|
1440
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1441
|
-
res.end(JSON.stringify(status));
|
|
1442
|
-
} catch (err) {
|
|
1443
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1444
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1445
|
-
}
|
|
1446
|
-
return;
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
if (url.pathname === '/api/models/providers' && req.method === 'GET') {
|
|
1450
|
-
try {
|
|
1451
|
-
const { getProviderModelCatalog } = await import('../model-router.mjs');
|
|
1452
|
-
const catalog = getProviderModelCatalog({ env: process.env });
|
|
1453
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1454
|
-
res.end(JSON.stringify(catalog));
|
|
1455
|
-
} catch (err) {
|
|
1456
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1457
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1458
|
-
}
|
|
1459
|
-
return;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
if (url.pathname === '/api/models/pricing' && req.method === 'GET') {
|
|
1463
|
-
try {
|
|
1464
|
-
const idsParam = url.searchParams.get('ids') || '';
|
|
1465
|
-
const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean);
|
|
1466
|
-
const { getPricingForModels } = await import('../model-pricing.mjs');
|
|
1467
|
-
const pricing = await getPricingForModels(ids);
|
|
1468
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1469
|
-
res.end(JSON.stringify({ pricing }));
|
|
1470
|
-
} catch (err) {
|
|
1471
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1472
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1473
|
-
}
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
if (url.pathname === '/api/providers/credentials/custom' && req.method === 'GET') {
|
|
1478
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1479
|
-
res.end(JSON.stringify({ providers: readCustomCredentials(), path: CUSTOM_CREDENTIALS_FILE }));
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
if (url.pathname === '/api/providers/billing' && req.method === 'GET') {
|
|
1484
|
-
try {
|
|
1485
|
-
const { loadProjectConfig } = await import('../config/project-config.mjs');
|
|
1486
|
-
const cfg = loadProjectConfig(process.cwd(), process.env);
|
|
1487
|
-
const costsCfg = cfg?.config?.costs ?? {};
|
|
1488
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1489
|
-
res.end(JSON.stringify({
|
|
1490
|
-
global: costsCfg.billingMode ?? 'metered',
|
|
1491
|
-
providers: costsCfg.providers || {},
|
|
1492
|
-
}));
|
|
1493
|
-
} catch (err) {
|
|
1494
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1495
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1496
|
-
}
|
|
1497
|
-
return;
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
if (url.pathname === '/api/providers/credentials/op-status' && req.method === 'GET') {
|
|
1501
|
-
// Tells the dashboard whether the 1Password CLI is installed and an account
|
|
1502
|
-
// is signed in. No vault content is read here; this is just the availability
|
|
1503
|
-
// probe. The pull endpoint below is the only thing that touches secret data.
|
|
1504
|
-
try {
|
|
1505
|
-
const which = spawnSync('which', ['op'], { encoding: 'utf8' });
|
|
1506
|
-
if (which.status !== 0) {
|
|
1507
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1508
|
-
res.end(JSON.stringify({ available: false, signedIn: false, version: null, accounts: [] }));
|
|
1509
|
-
return;
|
|
1510
|
-
}
|
|
1511
|
-
const versionProc = spawnSync('op', ['--version'], { encoding: 'utf8', timeout: 2000 });
|
|
1512
|
-
const version = versionProc.status === 0 ? versionProc.stdout.trim() : null;
|
|
1513
|
-
const accountsProc = spawnSync('op', ['account', 'list', '--format=json'], { encoding: 'utf8', timeout: 3000 });
|
|
1514
|
-
let accounts = [];
|
|
1515
|
-
if (accountsProc.status === 0) {
|
|
1516
|
-
try {
|
|
1517
|
-
const parsed = JSON.parse(accountsProc.stdout || '[]');
|
|
1518
|
-
accounts = (Array.isArray(parsed) ? parsed : []).map((a) => ({
|
|
1519
|
-
url: a.url || null,
|
|
1520
|
-
email: a.email || null,
|
|
1521
|
-
user_uuid: a.user_uuid || null,
|
|
1522
|
-
}));
|
|
1523
|
-
} catch { accounts = []; }
|
|
1524
|
-
}
|
|
1525
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1526
|
-
res.end(JSON.stringify({ available: true, signedIn: accounts.length > 0, version, accounts }));
|
|
1527
|
-
} catch (err) {
|
|
1528
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1529
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1530
|
-
}
|
|
1531
|
-
return;
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
if (url.pathname === '/api/fs/browse' && req.method === 'GET') {
|
|
1535
|
-
try {
|
|
1536
|
-
const { readdirSync, statSync } = await import('node:fs');
|
|
1537
|
-
const { resolve: pathResolve, dirname: pathDirname, join: pathJoin, sep: pathSep } = await import('node:path');
|
|
1538
|
-
const os = await import('node:os');
|
|
1539
|
-
const HOME_DIR = os.homedir();
|
|
1540
|
-
// Only roots the dashboard is allowed to expose. Loopback + same-user
|
|
1541
|
-
// already gates file access at the OS level, but cap the reach to the
|
|
1542
|
-
// user's HOME and the active project root anyway — surprise reads
|
|
1543
|
-
// outside those are almost certainly a typo.
|
|
1544
|
-
const allowedRoots = [HOME_DIR, ROOT_DIR];
|
|
1545
|
-
const rawPath = url.searchParams.get('path') || HOME_DIR;
|
|
1546
|
-
const target = pathResolve(rawPath.startsWith('~') ? pathJoin(HOME_DIR, rawPath.slice(1)) : rawPath);
|
|
1547
|
-
const inAllowed = allowedRoots.some((root) => target === root || target.startsWith(root + pathSep));
|
|
1548
|
-
if (!inAllowed) {
|
|
1549
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1550
|
-
res.end(JSON.stringify({ error: `path outside allowed roots (${allowedRoots.join(', ')})` }));
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
let entries = [];
|
|
1554
|
-
try {
|
|
1555
|
-
entries = readdirSync(target, { withFileTypes: true })
|
|
1556
|
-
.filter((e) => !e.name.startsWith('.'))
|
|
1557
|
-
.map((e) => ({
|
|
1558
|
-
name: e.name,
|
|
1559
|
-
type: e.isDirectory() ? 'dir' : e.isFile() ? 'file' : 'other',
|
|
1560
|
-
}))
|
|
1561
|
-
.sort((a, b) => {
|
|
1562
|
-
if (a.type === b.type) return a.name.localeCompare(b.name);
|
|
1563
|
-
return a.type === 'dir' ? -1 : 1;
|
|
1564
|
-
});
|
|
1565
|
-
} catch (err) {
|
|
1566
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1567
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1568
|
-
return;
|
|
1569
|
-
}
|
|
1570
|
-
const parent = pathDirname(target);
|
|
1571
|
-
const parentAllowed = allowedRoots.some((root) => parent === root || parent.startsWith(root + pathSep));
|
|
1572
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1573
|
-
res.end(JSON.stringify({
|
|
1574
|
-
path: target,
|
|
1575
|
-
parent: parentAllowed && parent !== target ? parent : null,
|
|
1576
|
-
roots: allowedRoots,
|
|
1577
|
-
entries,
|
|
1578
|
-
}));
|
|
1579
|
-
} catch (err) {
|
|
1580
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1581
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1582
|
-
}
|
|
1583
|
-
return;
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
if (url.pathname === '/api/sources' && req.method === 'GET') {
|
|
1587
|
-
try {
|
|
1588
|
-
const { loadProjectConfig } = await import('../config/project-config.mjs');
|
|
1589
|
-
const {
|
|
1590
|
-
resolveEffectiveSourceTargetsFromConfig,
|
|
1591
|
-
legacyEnvSourceTargets,
|
|
1592
|
-
normalizeConfigTarget,
|
|
1593
|
-
} = await import('../config/source-targets.mjs');
|
|
1594
|
-
const loaded = loadProjectConfig(ROOT_DIR, process.env);
|
|
1595
|
-
const configTargets = (loaded.config.sources?.targets ?? []).map(normalizeConfigTarget);
|
|
1596
|
-
const envTargets = legacyEnvSourceTargets(process.env);
|
|
1597
|
-
const effective = resolveEffectiveSourceTargetsFromConfig(loaded.config, process.env);
|
|
1598
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1599
|
-
res.end(JSON.stringify({
|
|
1600
|
-
path: loaded.path,
|
|
1601
|
-
configTargets,
|
|
1602
|
-
envTargets,
|
|
1603
|
-
effective,
|
|
1604
|
-
}));
|
|
1605
|
-
} catch (err) {
|
|
1606
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1607
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1608
|
-
}
|
|
1609
|
-
return;
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
if (url.pathname === '/api/intake/config' && req.method === 'GET') {
|
|
1613
|
-
try {
|
|
1614
|
-
const { loadIntakeConfig, INTAKE_DEPTH_GUIDANCE, INTAKE_HARD_MAX_DEPTH } = await import('../intake/intake-config.mjs');
|
|
1615
|
-
const { getRebrand } = await import('../profiles/rebrand.mjs');
|
|
1616
|
-
const config = loadIntakeConfig(ROOT_DIR, process.env);
|
|
1617
|
-
const rebrand = getRebrand(ROOT_DIR);
|
|
1618
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1619
|
-
res.end(JSON.stringify({
|
|
1620
|
-
config,
|
|
1621
|
-
rootDir: ROOT_DIR,
|
|
1622
|
-
guidance: INTAKE_DEPTH_GUIDANCE,
|
|
1623
|
-
hardMaxDepth: INTAKE_HARD_MAX_DEPTH,
|
|
1624
|
-
defaults: { rootInbox: 'inbox/' },
|
|
1625
|
-
label: rebrand.intakeQueueLabel,
|
|
1626
|
-
itemNoun: rebrand.signalNoun,
|
|
1627
|
-
}));
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1630
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1631
|
-
}
|
|
1632
|
-
return;
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
if (url.pathname === '/api/intake/list' && req.method === 'GET') {
|
|
1636
|
-
try {
|
|
1637
|
-
const { createIntakeQueue } = await import('../intake/queue.mjs');
|
|
1638
|
-
const { getRebrand } = await import('../profiles/rebrand.mjs');
|
|
1639
|
-
const queue = createIntakeQueue(ROOT_DIR, process.env);
|
|
1640
|
-
const items = queue.listPending();
|
|
1641
|
-
const rebrand = getRebrand(ROOT_DIR);
|
|
1642
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1643
|
-
res.end(JSON.stringify({
|
|
1644
|
-
items,
|
|
1645
|
-
total: items.length,
|
|
1646
|
-
label: rebrand.intakeQueueLabel,
|
|
1647
|
-
itemNoun: rebrand.signalNoun,
|
|
1648
|
-
}));
|
|
1649
|
-
} catch (err) {
|
|
1650
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1651
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1652
|
-
}
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
|
1657
|
-
try {
|
|
1658
|
-
const probe = url.searchParams.get('probe') === '1';
|
|
1659
|
-
const { resolveProviders } = await import('../providers/registry.mjs');
|
|
1660
|
-
const { providers, sources, errors } = await resolveProviders({ rootDir: ROOT_DIR, env: process.env });
|
|
1661
|
-
const summary = await Promise.all(
|
|
1662
|
-
Object.entries(providers).map(async ([id, p]) => {
|
|
1663
|
-
const base = {
|
|
1664
|
-
id,
|
|
1665
|
-
displayName: p.meta.displayName,
|
|
1666
|
-
description: p.meta.description || null,
|
|
1667
|
-
capabilities: [...p.meta.capabilities],
|
|
1668
|
-
source: sources[id],
|
|
1669
|
-
configSchema: p.configSchema || null,
|
|
1670
|
-
};
|
|
1671
|
-
if (!probe) {
|
|
1672
|
-
return { ...base, health: null, status: 'unknown' };
|
|
1673
|
-
}
|
|
1674
|
-
let health = { ok: false, detail: 'health probe failed' };
|
|
1675
|
-
try { health = await p.health({}); } catch (err) { health = { ok: false, detail: err.message }; }
|
|
1676
|
-
const status = classifyProviderStatus(id, health.ok, process.env);
|
|
1677
|
-
// For not_configured, surface what's missing instead of the probe detail
|
|
1678
|
-
// (which may incorrectly read as healthy from an anonymous code path).
|
|
1679
|
-
const missing = status === 'not_configured' ? describeMissingCredentials(id, process.env) : null;
|
|
1680
|
-
const finalHealth = missing ? { ...health, ok: false, detail: missing } : health;
|
|
1681
|
-
return { ...base, health: finalHealth, status };
|
|
1682
|
-
})
|
|
1683
|
-
);
|
|
1684
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1685
|
-
res.end(JSON.stringify({ summary, errors }));
|
|
1686
|
-
} catch (err) {
|
|
1687
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1688
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1689
|
-
}
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
if (url.pathname === '/api/beads' && req.method === 'GET') {
|
|
1694
|
-
try {
|
|
1695
|
-
// Resolve to the main repo when running from a worktree — the bd
|
|
1696
|
-
// database lives next to the canonical .git/, not the worktree's
|
|
1697
|
-
// private .git/ stub. git rev-parse --git-common-dir returns the
|
|
1698
|
-
// shared .git for both main and worktrees; its parent is the
|
|
1699
|
-
// main repo root.
|
|
1700
|
-
let bdCwd = ROOT_DIR;
|
|
1701
|
-
try {
|
|
1702
|
-
const commonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
1703
|
-
cwd: ROOT_DIR,
|
|
1704
|
-
encoding: 'utf8',
|
|
1705
|
-
});
|
|
1706
|
-
if (commonDir.status === 0) {
|
|
1707
|
-
const trimmed = commonDir.stdout.trim();
|
|
1708
|
-
const resolved = trimmed.startsWith('/') ? trimmed : join(ROOT_DIR, trimmed);
|
|
1709
|
-
const candidate = dirname(resolved);
|
|
1710
|
-
if (existsSync(join(candidate, '.beads'))) bdCwd = candidate;
|
|
1711
|
-
}
|
|
1712
|
-
} catch { /* fall back to ROOT_DIR */ }
|
|
1713
|
-
const result = spawnSync('bd', ['list', '--status', 'all', '--json'], {
|
|
1714
|
-
cwd: bdCwd,
|
|
1715
|
-
encoding: 'utf8',
|
|
1716
|
-
env: { ...process.env, NO_COLOR: '1' },
|
|
1717
|
-
timeout: 5000,
|
|
1718
|
-
});
|
|
1719
|
-
// bd can return non-zero for legitimate reasons that don't warrant a
|
|
1720
|
-
// server 500: bd not installed, dolt lock held by a concurrent bd, no
|
|
1721
|
-
// .beads/ in cwd. Degrade gracefully — return an empty issue list with
|
|
1722
|
-
// `degraded: true` so the dashboard UI shows "no beads" instead of
|
|
1723
|
-
// breaking, and the dashboard-endpoints test (which sweeps every read
|
|
1724
|
-
// endpoint for 5xx) stays green when bd's runtime is wobbly.
|
|
1725
|
-
|
|
1726
|
-
if (result.status !== 0) {
|
|
1727
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1728
|
-
res.end(JSON.stringify({
|
|
1729
|
-
issues: [],
|
|
1730
|
-
counts: { total: 0, byStatus: {}, byPriority: {} },
|
|
1731
|
-
degraded: true,
|
|
1732
|
-
reason: (result.stderr || result.stdout || 'bd list failed').slice(0, 300).trim(),
|
|
1733
|
-
}));
|
|
1734
|
-
return;
|
|
1735
|
-
}
|
|
1736
|
-
let raw = [];
|
|
1737
|
-
try { raw = JSON.parse(result.stdout); } catch (parseErr) {
|
|
1738
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1739
|
-
res.end(JSON.stringify({
|
|
1740
|
-
issues: [],
|
|
1741
|
-
counts: { total: 0, byStatus: {}, byPriority: {} },
|
|
1742
|
-
degraded: true,
|
|
1743
|
-
reason: `bd list JSON parse failed: ${parseErr.message}`,
|
|
1744
|
-
}));
|
|
1745
|
-
return;
|
|
1746
|
-
}
|
|
1747
|
-
const issues = raw.map(obj => ({
|
|
1748
|
-
id: obj.id,
|
|
1749
|
-
title: obj.title,
|
|
1750
|
-
description: obj.description || null,
|
|
1751
|
-
status: obj.status,
|
|
1752
|
-
priority: obj.priority,
|
|
1753
|
-
issue_type: obj.issue_type || null,
|
|
1754
|
-
owner: obj.owner || null,
|
|
1755
|
-
created_at: obj.created_at,
|
|
1756
|
-
updated_at: obj.updated_at,
|
|
1757
|
-
dependency_count: obj.dependency_count || 0,
|
|
1758
|
-
dependent_count: obj.dependent_count || 0,
|
|
1759
|
-
comment_count: obj.comment_count || 0,
|
|
1760
|
-
labels: Array.isArray(obj.labels) ? obj.labels : [],
|
|
1761
|
-
}));
|
|
1762
|
-
const byStatus = {};
|
|
1763
|
-
const byPriority = {};
|
|
1764
|
-
for (const it of issues) {
|
|
1765
|
-
byStatus[it.status] = (byStatus[it.status] || 0) + 1;
|
|
1766
|
-
byPriority[`P${it.priority}`] = (byPriority[`P${it.priority}`] || 0) + 1;
|
|
1767
|
-
}
|
|
1768
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1769
|
-
res.end(JSON.stringify({ issues, counts: { total: issues.length, byStatus, byPriority } }));
|
|
1770
|
-
} catch (err) {
|
|
1771
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1772
|
-
res.end(JSON.stringify({
|
|
1773
|
-
issues: [],
|
|
1774
|
-
counts: { total: 0, byStatus: {}, byPriority: {} },
|
|
1775
|
-
degraded: true,
|
|
1776
|
-
reason: err.message,
|
|
1777
|
-
}));
|
|
1778
|
-
}
|
|
1779
|
-
return;
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
if (url.pathname === '/api/providers/subscriptions' && req.method === 'GET') {
|
|
1783
|
-
try {
|
|
1784
|
-
const file = join(configDir(HOME), 'provider-subscriptions.json');
|
|
1785
|
-
let data = { subscriptions: [] };
|
|
1786
|
-
if (existsSync(file)) {
|
|
1787
|
-
try { data = JSON.parse(readFileSync(file, 'utf8')); } catch { /* fall back to empty */ }
|
|
1788
|
-
if (!Array.isArray(data.subscriptions)) data.subscriptions = [];
|
|
1789
|
-
}
|
|
1790
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1791
|
-
res.end(JSON.stringify(data));
|
|
1792
|
-
} catch (err) {
|
|
1793
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1794
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1795
|
-
}
|
|
1796
|
-
return;
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
if (url.pathname === '/api/providers/credentials' && req.method === 'GET') {
|
|
1800
|
-
try {
|
|
1801
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1802
|
-
res.end(JSON.stringify({ credentials: buildCredentialsView(process.env) }));
|
|
1803
|
-
} catch (err) {
|
|
1804
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1805
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1806
|
-
}
|
|
1807
|
-
return;
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
if (url.pathname === '/api/providers/config-path' && req.method === 'GET') {
|
|
1811
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1812
|
-
res.end(JSON.stringify({
|
|
1813
|
-
envPath: CONFIG_ENV_FILE,
|
|
1814
|
-
overridesPath: join(configDir(HOME), 'providers.json'),
|
|
1815
|
-
}));
|
|
1816
|
-
return;
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
if (url.pathname === '/api/session-usage') {
|
|
1820
|
-
await handleSessionUsage(req, res);
|
|
1821
|
-
return;
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
if (url.pathname === '/api/artifacts') {
|
|
1825
|
-
await handleArtifacts(req, res);
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
if (url.pathname === '/api/approvals') {
|
|
1830
|
-
await handleApprovals(req, res);
|
|
1831
|
-
return;
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
// ── Recommendations API ────────────────────────────────────────────────────
|
|
1835
|
-
if (url.pathname === '/api/recommendations') {
|
|
1836
|
-
if (req.method === 'GET') {
|
|
1837
|
-
const { listActiveRecommendations, recommendationStats } = await import('../embed/recommendation-store.mjs');
|
|
1838
|
-
const priority = url.searchParams.get('priority') || undefined;
|
|
1839
|
-
const type = url.searchParams.get('type') || undefined;
|
|
1840
|
-
const limit = Number(url.searchParams.get('limit') || 50);
|
|
1841
|
-
const items = listActiveRecommendations({ priority, type, limit });
|
|
1842
|
-
const stats = recommendationStats();
|
|
1843
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1844
|
-
res.end(JSON.stringify({ items, stats }));
|
|
1845
|
-
return;
|
|
1846
|
-
}
|
|
1847
|
-
if (req.method === 'PATCH' || req.method === 'POST') {
|
|
1848
|
-
const chunks = [];
|
|
1849
|
-
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1850
|
-
let body;
|
|
1851
|
-
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1852
|
-
const action = body.action || url.searchParams.get('action');
|
|
1853
|
-
const dedupKey = body.dedupKey || url.searchParams.get('dedupKey');
|
|
1854
|
-
if (!action || !dedupKey) {
|
|
1855
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1856
|
-
res.end(JSON.stringify({ error: 'action and dedupKey are required' }));
|
|
1857
|
-
return;
|
|
1858
|
-
}
|
|
1859
|
-
const { dismissRecommendation, reviveRecommendation } = await import('../embed/recommendation-store.mjs');
|
|
1860
|
-
try {
|
|
1861
|
-
if (action === 'dismiss') {
|
|
1862
|
-
const result = dismissRecommendation(dedupKey, { reason: body.reason, suppressDays: body.suppressDays });
|
|
1863
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1864
|
-
res.end(JSON.stringify(result));
|
|
1865
|
-
} else if (action === 'revive') {
|
|
1866
|
-
const result = reviveRecommendation(dedupKey, { signalCount: body.signalCount, sourceSignalIds: body.sourceSignalIds });
|
|
1867
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1868
|
-
res.end(JSON.stringify(result));
|
|
1869
|
-
} else {
|
|
1870
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1871
|
-
res.end(JSON.stringify({ error: `unknown action: ${action}` }));
|
|
1872
|
-
}
|
|
1873
|
-
} catch (err) {
|
|
1874
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1875
|
-
res.end(JSON.stringify({ error: err?.message || 'not found' }));
|
|
1876
|
-
}
|
|
1877
|
-
return;
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
// ── Strategy API ───────────────────────────────────────────────────────────
|
|
1882
|
-
if (url.pathname === '/api/strategy') {
|
|
1883
|
-
if (req.method === 'GET') {
|
|
1884
|
-
const { readAllStrategies } = await import('../strategy-store.mjs');
|
|
1885
|
-
const result = await readAllStrategies(process.env);
|
|
1886
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1887
|
-
res.end(JSON.stringify({ scopes: Object.fromEntries(result) }));
|
|
1888
|
-
return;
|
|
1889
|
-
}
|
|
1890
|
-
if (req.method === 'PUT' || req.method === 'POST') {
|
|
1891
|
-
const chunks = [];
|
|
1892
|
-
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1893
|
-
let body;
|
|
1894
|
-
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1895
|
-
const content = (body.content || '').trim();
|
|
1896
|
-
const scope = (body.scope || 'product').trim();
|
|
1897
|
-
if (!content) {
|
|
1898
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1899
|
-
res.end(JSON.stringify({ error: 'content is required' }));
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
const { writeStrategy } = await import('../strategy-store.mjs');
|
|
1903
|
-
await writeStrategy(content, scope, { updatedBy: 'dashboard', env: process.env });
|
|
1904
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1905
|
-
res.end(JSON.stringify({ ok: true }));
|
|
1906
|
-
return;
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
if (url.pathname === '/api/snapshots') {
|
|
1911
|
-
handleSnapshots(req, res);
|
|
1912
|
-
return;
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
// ── Knowledge API ──────────────────────────────────────────────────────
|
|
1916
|
-
if (url.pathname === '/api/knowledge/trends') {
|
|
1917
|
-
const { buildTrendReport } = await import('../knowledge/trends.mjs');
|
|
1918
|
-
const report = buildTrendReport(ROOT_DIR);
|
|
1919
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1920
|
-
res.end(JSON.stringify(report));
|
|
1921
|
-
return;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
if (url.pathname === '/api/knowledge/index') {
|
|
1925
|
-
const { buildCorpus } = await import('../knowledge/rag.mjs');
|
|
1926
|
-
const corpus = buildCorpus(ROOT_DIR);
|
|
1927
|
-
const sources = {};
|
|
1928
|
-
for (const c of corpus) sources[c.source] = (sources[c.source] || 0) + 1;
|
|
1929
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1930
|
-
res.end(JSON.stringify({ total: corpus.length, sources }));
|
|
1931
|
-
return;
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
if (url.pathname === '/api/knowledge/ask' && req.method === 'POST') {
|
|
1935
|
-
const chunks = [];
|
|
1936
|
-
await new Promise((resolve, reject) => {
|
|
1937
|
-
req.on('data', (c) => chunks.push(c));
|
|
1938
|
-
req.on('end', resolve);
|
|
1939
|
-
req.on('error', reject);
|
|
1940
|
-
});
|
|
1941
|
-
let body;
|
|
1942
|
-
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1943
|
-
const question = (body.question || '').trim();
|
|
1944
|
-
if (!question) {
|
|
1945
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1946
|
-
res.end(JSON.stringify({ error: 'question is required' }));
|
|
1947
|
-
return;
|
|
1948
|
-
}
|
|
1949
|
-
const { ask } = await import('../knowledge/rag.mjs');
|
|
1950
|
-
const result = await ask(question, { rootDir: ROOT_DIR });
|
|
1951
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1952
|
-
res.end(JSON.stringify(result));
|
|
1953
|
-
return;
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
// ── Workflow API ──────────────────────────────────────────────────────────
|
|
1957
|
-
if (url.pathname === '/api/workflow' && req.method === 'GET') {
|
|
1958
|
-
try {
|
|
1959
|
-
const planMd = existsSync(WORKFLOW_FILE) ? readFileSync(WORKFLOW_FILE, 'utf8') : '';
|
|
1960
|
-
const { loadWorkflow } = await import('../workflow-state.mjs');
|
|
1961
|
-
const wf = loadWorkflow(ROOT_DIR);
|
|
1962
|
-
const tasks = wf?.tasks ?? [];
|
|
1963
|
-
const phase = wf?.phase ?? null;
|
|
1964
|
-
const phases = wf?.phases ?? {};
|
|
1965
|
-
const status = wf?.status ?? null;
|
|
1966
|
-
const summary = planMd
|
|
1967
|
-
? planMd.split('\n').slice(0, 30).filter(l => l.trim()).slice(0, 8).join('\n')
|
|
1968
|
-
: '';
|
|
1969
|
-
|
|
1970
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1971
|
-
res.end(JSON.stringify({
|
|
1972
|
-
hasPlan: Boolean(planMd),
|
|
1973
|
-
planSummary: summary,
|
|
1974
|
-
planPath: WORKFLOW_FILE,
|
|
1975
|
-
workflowState: wf ? { status, phase, phases, tasks, currentTaskKey: wf.currentTaskKey } : null,
|
|
1976
|
-
taskCount: tasks.length,
|
|
1977
|
-
taskStatusCounts: {
|
|
1978
|
-
todo: tasks.filter(t => t.status === 'todo' || !t.status).length,
|
|
1979
|
-
inProgress: tasks.filter(t => t.status === 'in-progress').length,
|
|
1980
|
-
blocked: tasks.filter(t => t.status?.startsWith('blocked')).length,
|
|
1981
|
-
done: tasks.filter(t => t.status === 'done').length,
|
|
1982
|
-
skipped: tasks.filter(t => t.status === 'skipped').length,
|
|
1983
|
-
},
|
|
1984
|
-
}));
|
|
1985
|
-
} catch (err) {
|
|
1986
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1987
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
1988
|
-
}
|
|
1989
|
-
return;
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
if (url.pathname === '/api/config') {
|
|
1993
|
-
handleConfig(req, res);
|
|
1994
|
-
return;
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
// ── Terraform API ─────────────────────────────────────────────────────────
|
|
1998
|
-
if (url.pathname === '/api/terraform/files' && req.method === 'GET') {
|
|
1999
|
-
try {
|
|
2000
|
-
const files = terraformFiles(TERRAFORM_DIR, TERRAFORM_DIR);
|
|
2001
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2002
|
-
res.end(JSON.stringify({ files, terraformDir: TERRAFORM_DIR }));
|
|
2003
|
-
} catch (err) {
|
|
2004
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2005
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2006
|
-
}
|
|
2007
|
-
return;
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
if (url.pathname === '/api/terraform/file') {
|
|
2011
|
-
if (req.method === 'GET') {
|
|
2012
|
-
const relPath = url.searchParams.get('path');
|
|
2013
|
-
if (!relPath) { res.writeHead(400); res.end(JSON.stringify({ error: 'path required' })); return; }
|
|
2014
|
-
try {
|
|
2015
|
-
const abs = assertTerraformPath(relPath);
|
|
2016
|
-
const content = readFileSync(abs, 'utf8');
|
|
2017
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2018
|
-
res.end(JSON.stringify({ path: relPath, content }));
|
|
2019
|
-
} catch (err) {
|
|
2020
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2021
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2022
|
-
}
|
|
2023
|
-
return;
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
if (req.method === 'POST') {
|
|
2027
|
-
const chunks = [];
|
|
2028
|
-
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
2029
|
-
let body;
|
|
2030
|
-
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
2031
|
-
try {
|
|
2032
|
-
const abs = assertTerraformPath(body.path || '');
|
|
2033
|
-
writeFileSync(abs, body.content ?? '', 'utf8');
|
|
2034
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2035
|
-
res.end(JSON.stringify({ success: true }));
|
|
2036
|
-
} catch (err) {
|
|
2037
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2038
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2039
|
-
}
|
|
2040
|
-
return;
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
if (url.pathname === '/api/terraform/run' && req.method === 'POST') {
|
|
2045
|
-
const chunks = [];
|
|
2046
|
-
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
2047
|
-
let body;
|
|
2048
|
-
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
2049
|
-
|
|
2050
|
-
const subcommand = body.subcommand; // 'plan' | 'apply'
|
|
2051
|
-
const environment = body.environment || 'staging'; // 'staging' | 'production'
|
|
2052
|
-
|
|
2053
|
-
if (!['plan', 'apply', 'validate', 'output'].includes(subcommand)) {
|
|
2054
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2055
|
-
res.end(JSON.stringify({ error: "subcommand must be 'plan', 'apply', 'validate', or 'output'" }));
|
|
2056
|
-
return;
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
const envDir = join(TERRAFORM_DIR, 'environments', environment);
|
|
2060
|
-
if (!existsSync(envDir)) {
|
|
2061
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2062
|
-
res.end(JSON.stringify({ error: `Environment '${environment}' not found at ${envDir}` }));
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
// Stream output via SSE-style chunked response
|
|
2067
|
-
res.writeHead(200, {
|
|
2068
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
2069
|
-
'Transfer-Encoding': 'chunked',
|
|
2070
|
-
'Cache-Control': 'no-cache',
|
|
2071
|
-
'X-Accel-Buffering': 'no',
|
|
2072
|
-
});
|
|
2073
|
-
|
|
2074
|
-
const args = subcommand === 'plan'
|
|
2075
|
-
? ['plan', `-chdir=${envDir}`, '-no-color']
|
|
2076
|
-
: subcommand === 'apply'
|
|
2077
|
-
? ['apply', `-chdir=${envDir}`, '-no-color', '-auto-approve']
|
|
2078
|
-
: subcommand === 'validate'
|
|
2079
|
-
? ['validate', `-chdir=${envDir}`, '-no-color']
|
|
2080
|
-
: ['output', `-chdir=${envDir}`, '-no-color'];
|
|
2081
|
-
|
|
2082
|
-
res.write(`\u001b[90m$ terraform ${args.join(' ')}\u001b[0m\n\n`);
|
|
2083
|
-
|
|
2084
|
-
const tf = spawn('terraform', args, {
|
|
2085
|
-
cwd: envDir,
|
|
2086
|
-
env: { ...process.env, TF_IN_AUTOMATION: '1' },
|
|
2087
|
-
});
|
|
2088
|
-
|
|
2089
|
-
tf.stdout.on('data', chunk => { try { res.write(chunk); } catch {} });
|
|
2090
|
-
tf.stderr.on('data', chunk => { try { res.write(chunk); } catch {} });
|
|
2091
|
-
|
|
2092
|
-
tf.on('close', (code) => {
|
|
2093
|
-
try {
|
|
2094
|
-
res.write(`\n\u001b[${code === 0 ? '32' : '31'}m\nExit code: ${code}\u001b[0m\n`);
|
|
2095
|
-
res.end();
|
|
2096
|
-
} catch {}
|
|
2097
|
-
notifyClients();
|
|
2098
|
-
});
|
|
2099
|
-
|
|
2100
|
-
tf.on('error', (err) => {
|
|
2101
|
-
try { res.write(`\n❌ Failed to run terraform: ${err.message}\n`); res.end(); } catch {}
|
|
2102
|
-
});
|
|
2103
|
-
|
|
2104
|
-
return;
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
if (url.pathname === '/api/chat/stream' && req.method === 'GET') {
|
|
2108
|
-
handleChatStream(req, res, { rootDir: ROOT_DIR });
|
|
2109
|
-
return;
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
if (url.pathname === '/api/chat/history' && req.method === 'GET') {
|
|
2113
|
-
handleChatHistory(req, res);
|
|
2114
|
-
return;
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
if (url.pathname === '/api/chat' && req.method === 'POST') {
|
|
2118
|
-
handleChat(req, res, { rootDir: ROOT_DIR });
|
|
2119
|
-
return;
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
if (url.pathname === '/api/chat/loop/stream' && req.method === 'GET') {
|
|
2123
|
-
handleChatLoopStream(req, res, { rootDir: ROOT_DIR });
|
|
2124
|
-
return;
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
if (url.pathname === '/api/chat/loop/pending' && req.method === 'GET') {
|
|
2128
|
-
handleChatLoopPending(req, res);
|
|
2129
|
-
return;
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
if (url.pathname === '/api/chat/loop/permission' && req.method === 'POST') {
|
|
2133
|
-
handleChatLoopPermission(req, res);
|
|
2134
|
-
return;
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
if (url.pathname === '/api/chat/config' && (req.method === 'GET' || req.method === 'POST')) {
|
|
2138
|
-
handleChatLoopConfig(req, res, { rootDir: ROOT_DIR });
|
|
2139
|
-
return;
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
if (url.pathname === '/api/chat/models' && req.method === 'GET') {
|
|
2143
|
-
void handleChatModels(req, res, { rootDir: ROOT_DIR });
|
|
2144
|
-
return;
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
if (url.pathname === '/api/chat/models/select' && req.method === 'POST') {
|
|
2148
|
-
handleChatModelSelect(req, res, { rootDir: ROOT_DIR });
|
|
2149
|
-
return;
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
if (url.pathname === '/api/chat/loop/command' && req.method === 'POST') {
|
|
2153
|
-
handleChatLoopCommand(req, res, { rootDir: ROOT_DIR });
|
|
2154
|
-
return;
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
if (url.pathname === '/api/chat/loop/history' && req.method === 'GET') {
|
|
2158
|
-
handleChatLoopHistory(req, res, { rootDir: ROOT_DIR });
|
|
2159
|
-
return;
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
if (url.pathname === '/api/chat/loop/cancel' && req.method === 'POST') {
|
|
2163
|
-
handleChatLoopCancel(req, res);
|
|
2164
|
-
return;
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
if (req.method === 'POST') {
|
|
2168
|
-
let body = '';
|
|
2169
|
-
req.on('data', chunk => { body += chunk; });
|
|
2170
|
-
req.on('end', async () => {
|
|
2171
|
-
try {
|
|
2172
|
-
const data = JSON.parse(body || '{}');
|
|
2173
|
-
const { promoteHeadhunt, updatePromotionChallenge } = await import('../headhunt.mjs');
|
|
2174
|
-
|
|
2175
|
-
if (url.pathname === '/api/headhunt/promote') {
|
|
2176
|
-
if (!data.id) throw new Error('Missing overlay id');
|
|
2177
|
-
const request = promoteHeadhunt(data.id, { cwd: process.cwd(), owner: data.owner || null });
|
|
2178
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2179
|
-
res.end(JSON.stringify({ success: true, request }));
|
|
2180
|
-
notifyClients();
|
|
2181
|
-
} else if (url.pathname === '/api/headhunt/challenge') {
|
|
2182
|
-
if (!data.id) throw new Error('Missing overlay id');
|
|
2183
|
-
if (!data.status) throw new Error('Missing challenge status');
|
|
2184
|
-
const request = updatePromotionChallenge(data.id, {
|
|
2185
|
-
cwd: process.cwd(),
|
|
2186
|
-
status: data.status,
|
|
2187
|
-
note: data.note || null,
|
|
2188
|
-
});
|
|
2189
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2190
|
-
res.end(JSON.stringify({ success: true, request }));
|
|
2191
|
-
notifyClients();
|
|
2192
|
-
} else if (url.pathname === '/api/registry/mcp') {
|
|
2193
|
-
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
2194
|
-
if (!registry.mcpServers) registry.mcpServers = {};
|
|
2195
|
-
const { action, id, server } = data;
|
|
2196
|
-
if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid MCP server id');
|
|
2197
|
-
if (action === 'delete') {
|
|
2198
|
-
delete registry.mcpServers[id];
|
|
2199
|
-
} else if (action === 'save' && server && typeof server === 'object') {
|
|
2200
|
-
const entry = {};
|
|
2201
|
-
if (server.type === 'url') {
|
|
2202
|
-
if (!server.url) throw new Error('URL required for type=url');
|
|
2203
|
-
entry.type = 'url';
|
|
2204
|
-
entry.url = String(server.url);
|
|
2205
|
-
if (server.headers && typeof server.headers === 'object') entry.headers = server.headers;
|
|
2206
|
-
} else {
|
|
2207
|
-
if (!server.command) throw new Error('command required');
|
|
2208
|
-
entry.command = String(server.command);
|
|
2209
|
-
entry.args = Array.isArray(server.args) ? server.args.map(String) : [];
|
|
2210
|
-
}
|
|
2211
|
-
if (server.description) entry.description = String(server.description);
|
|
2212
|
-
registry.mcpServers[id] = entry;
|
|
2213
|
-
} else {
|
|
2214
|
-
throw new Error('action must be save or delete');
|
|
2215
|
-
}
|
|
2216
|
-
writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
|
|
2217
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2218
|
-
res.end(JSON.stringify({ success: true }));
|
|
2219
|
-
notifyClients();
|
|
2220
|
-
} else if (url.pathname === '/api/registry/models') {
|
|
2221
|
-
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
2222
|
-
const { tier, primary, fallback } = data;
|
|
2223
|
-
const VALID_TIERS = ['reasoning', 'standard', 'fast'];
|
|
2224
|
-
if (!VALID_TIERS.includes(tier)) throw new Error('tier must be reasoning, standard, or fast');
|
|
2225
|
-
if (!primary || typeof primary !== 'string') throw new Error('primary model required');
|
|
2226
|
-
if (!registry.models) registry.models = {};
|
|
2227
|
-
registry.models[tier] = {
|
|
2228
|
-
primary: String(primary),
|
|
2229
|
-
fallback: Array.isArray(fallback) ? fallback.map(String).filter(Boolean) : [],
|
|
2230
|
-
};
|
|
2231
|
-
writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
|
|
2232
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2233
|
-
res.end(JSON.stringify({ success: true }));
|
|
2234
|
-
notifyClients();
|
|
2235
|
-
} else if (url.pathname === '/api/providers/billing') {
|
|
2236
|
-
// Persists per-provider billing mode into construct.config.json under
|
|
2237
|
-
// costs.providers.<id>.billingMode. Validates ids against the known
|
|
2238
|
-
// CREDENTIAL_MAP (built-in + custom) so we don't accept arbitrary
|
|
2239
|
-
// strings into the schema.
|
|
2240
|
-
const { provider, billingMode } = data;
|
|
2241
|
-
if (typeof provider !== 'string' || !provider) throw new Error('provider id required');
|
|
2242
|
-
const validProviders = new Set([
|
|
2243
|
-
...credentialMap().map((c) => c.provider),
|
|
2244
|
-
'ollama', 'local', 'mistral', 'groq',
|
|
2245
|
-
]);
|
|
2246
|
-
if (!validProviders.has(provider)) {
|
|
2247
|
-
throw new Error(`unknown provider '${provider}'. Add a custom provider first or use one of: ${[...validProviders].join(', ')}`);
|
|
2248
|
-
}
|
|
2249
|
-
if (!['metered', 'subscription', 'mixed'].includes(billingMode)) {
|
|
2250
|
-
throw new Error("billingMode must be 'metered', 'subscription', or 'mixed'");
|
|
2251
|
-
}
|
|
2252
|
-
const { loadProjectConfig, writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
|
|
2253
|
-
const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
2254
|
-
const { config } = loadProjectConfig(process.cwd(), process.env);
|
|
2255
|
-
const next = {
|
|
2256
|
-
...config,
|
|
2257
|
-
costs: {
|
|
2258
|
-
...(config.costs || {}),
|
|
2259
|
-
providers: {
|
|
2260
|
-
...(config.costs?.providers || {}),
|
|
2261
|
-
[provider]: { billingMode },
|
|
2262
|
-
},
|
|
2263
|
-
},
|
|
2264
|
-
};
|
|
2265
|
-
writeProjectConfig(cfgPath, next);
|
|
2266
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2267
|
-
res.end(JSON.stringify({ success: true, providers: next.costs.providers }));
|
|
2268
|
-
notifyClients();
|
|
2269
|
-
} else if (url.pathname === '/api/providers/credentials/op-pull') {
|
|
2270
|
-
// Resolve a 1Password secret reference (op://vault/item/field) and
|
|
2271
|
-
// store the result into ~/.construct/config.env via the same write
|
|
2272
|
-
// path as the manual editor. The op:// URI never leaves the machine
|
|
2273
|
-
// and the resolved value flows secret server-side → config.env
|
|
2274
|
-
// (chmod 0600) → process.env, identical to a manual paste.
|
|
2275
|
-
const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
|
|
2276
|
-
if (isAuthConfigured()) {
|
|
2277
|
-
if (!isAuthenticated(req)) {
|
|
2278
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
2279
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
2280
|
-
return;
|
|
2281
|
-
}
|
|
2282
|
-
} else if (!isLocal) {
|
|
2283
|
-
throw new Error('op-pull on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
|
|
2284
|
-
}
|
|
2285
|
-
const { envVar, opRef } = data;
|
|
2286
|
-
if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
|
|
2287
|
-
if (!allowedCredentialEnvVars().has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
|
|
2288
|
-
if (typeof opRef !== 'string' || !opRef.startsWith('op://')) {
|
|
2289
|
-
throw new Error("opRef must be a 1Password secret reference starting with 'op://'");
|
|
2290
|
-
}
|
|
2291
|
-
if (!/^op:\/\/[\w\s\-._]+\/[\w\s\-._]+\/[\w\s\-._]+$/.test(opRef)) {
|
|
2292
|
-
throw new Error("opRef must look like 'op://vault/item/field' (letters, digits, spaces, _-.)");
|
|
2293
|
-
}
|
|
2294
|
-
const opRead = spawnSync('op', ['read', '--no-newline', opRef], { encoding: 'utf8', timeout: 10000 });
|
|
2295
|
-
if (opRead.status !== 0) {
|
|
2296
|
-
throw new Error(`op read failed: ${(opRead.stderr || opRead.stdout || '').trim().slice(0, 240)}`);
|
|
2297
|
-
}
|
|
2298
|
-
const resolved = opRead.stdout;
|
|
2299
|
-
if (!resolved) throw new Error('op read returned an empty value');
|
|
2300
|
-
|
|
2301
|
-
const { writeEnvValues, loadConstructEnv } = await import('../env-config.mjs');
|
|
2302
|
-
mkdirSync(configDir(HOME), { recursive: true });
|
|
2303
|
-
writeEnvValues(CONFIG_ENV_FILE, { [envVar]: resolved });
|
|
2304
|
-
process.env[envVar] = resolved;
|
|
2305
|
-
try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms best-effort */ }
|
|
2306
|
-
|
|
2307
|
-
const allowed = allowedCredentialEnvVars();
|
|
2308
|
-
const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
|
|
2309
|
-
for (const [k, v] of Object.entries(merged)) {
|
|
2310
|
-
if (allowed.has(k) && k !== envVar) process.env[k] = v;
|
|
2311
|
-
}
|
|
2312
|
-
|
|
2313
|
-
try {
|
|
2314
|
-
mkdirSync(doctorRoot(), { recursive: true });
|
|
2315
|
-
appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action: 'set', envVar, source: '1password', opRef }) + '\n');
|
|
2316
|
-
} catch { /* audit best-effort */ }
|
|
2317
|
-
|
|
2318
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2319
|
-
res.end(JSON.stringify({ success: true, envVar }));
|
|
2320
|
-
notifyClients();
|
|
2321
|
-
} else if (url.pathname === '/api/providers/credentials/custom') {
|
|
2322
|
-
const { action, provider, label, kind, envVars } = data;
|
|
2323
|
-
const cleanedId = typeof provider === 'string' ? provider.trim().toLowerCase() : '';
|
|
2324
|
-
if (!cleanedId || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(cleanedId)) {
|
|
2325
|
-
throw new Error('provider id required: lowercase letters, digits, hyphens (max 63 chars)');
|
|
2326
|
-
}
|
|
2327
|
-
if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === cleanedId)) {
|
|
2328
|
-
throw new Error(`provider id '${cleanedId}' is built-in; pick a different id`);
|
|
2329
|
-
}
|
|
2330
|
-
const existing = readCustomCredentials();
|
|
2331
|
-
if (action === 'delete') {
|
|
2332
|
-
const next = existing.filter((e) => e.provider !== cleanedId);
|
|
2333
|
-
writeCustomCredentials(next);
|
|
2334
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2335
|
-
res.end(JSON.stringify({ success: true, providers: next }));
|
|
2336
|
-
notifyClients();
|
|
2337
|
-
} else if (action === 'save') {
|
|
2338
|
-
if (typeof label !== 'string' || !label.trim()) throw new Error('label required');
|
|
2339
|
-
if (kind !== 'llm' && kind !== 'integration') throw new Error("kind must be 'llm' or 'integration'");
|
|
2340
|
-
if (!Array.isArray(envVars) || envVars.length === 0) throw new Error('envVars must be a non-empty array');
|
|
2341
|
-
const normalizedVars = envVars.map((v) => String(v || '').trim().toUpperCase());
|
|
2342
|
-
for (const v of normalizedVars) {
|
|
2343
|
-
if (!ENV_VAR_PATTERN.test(v)) throw new Error(`envVar '${v}' must match /^[A-Z][A-Z0-9_]{1,63}$/`);
|
|
2344
|
-
if (ENV_VAR_BLOCKLIST.has(v)) throw new Error(`envVar '${v}' is reserved by the OS shell and not allowed`);
|
|
2345
|
-
if (allowedCredentialEnvVars().has(v)) {
|
|
2346
|
-
// Only block when the existing owner is a different provider.
|
|
2347
|
-
const owner = credentialMap().find((c) => c.envVars.includes(v));
|
|
2348
|
-
if (owner && owner.provider !== cleanedId) {
|
|
2349
|
-
throw new Error(`envVar '${v}' already declared by provider '${owner.provider}'`);
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
const next = existing.filter((e) => e.provider !== cleanedId).concat([{
|
|
2354
|
-
provider: cleanedId,
|
|
2355
|
-
label: label.trim(),
|
|
2356
|
-
kind,
|
|
2357
|
-
envVars: normalizedVars,
|
|
2358
|
-
}]);
|
|
2359
|
-
writeCustomCredentials(next);
|
|
2360
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2361
|
-
res.end(JSON.stringify({ success: true, providers: next }));
|
|
2362
|
-
notifyClients();
|
|
2363
|
-
} else {
|
|
2364
|
-
throw new Error("action must be 'save' or 'delete'");
|
|
2365
|
-
}
|
|
2366
|
-
} else if (url.pathname === '/api/intake/config') {
|
|
2367
|
-
const { saveIntakeConfig } = await import('../intake/intake-config.mjs');
|
|
2368
|
-
const next = saveIntakeConfig(ROOT_DIR, {
|
|
2369
|
-
parentDirs: Array.isArray(data.parentDirs) ? data.parentDirs : undefined,
|
|
2370
|
-
maxDepth: data.maxDepth,
|
|
2371
|
-
});
|
|
2372
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2373
|
-
res.end(JSON.stringify({ success: true, config: next }));
|
|
2374
|
-
notifyClients();
|
|
2375
|
-
} else if (url.pathname === '/api/models/free/apply') {
|
|
2376
|
-
const { pollFreeModels, selectForTier } = await import('../model-free-selector.mjs');
|
|
2377
|
-
const { readOpenRouterApiKeyFromOpenCodeConfig } = await import('../model-router.mjs');
|
|
2378
|
-
const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
|
|
2379
|
-
if (!apiKey) {
|
|
2380
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2381
|
-
res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured. Set it under Providers → Credentials.' }));
|
|
2382
|
-
} else {
|
|
2383
|
-
const freeModels = await pollFreeModels(apiKey);
|
|
2384
|
-
if (!freeModels || freeModels.length === 0) {
|
|
2385
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
2386
|
-
res.end(JSON.stringify({ error: 'OpenRouter returned no free models. Check the key and try again.' }));
|
|
2387
|
-
} else {
|
|
2388
|
-
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
2389
|
-
if (!registry.models) registry.models = {};
|
|
2390
|
-
const selections = {};
|
|
2391
|
-
for (const tier of ['reasoning', 'standard', 'fast']) {
|
|
2392
|
-
const fallback = registry.models?.[tier]?.fallback ?? [];
|
|
2393
|
-
const id = selectForTier(freeModels, tier, fallback);
|
|
2394
|
-
if (id) {
|
|
2395
|
-
selections[tier] = id;
|
|
2396
|
-
registry.models[tier] = { primary: id, fallback };
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
|
|
2400
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2401
|
-
res.end(JSON.stringify({ success: true, selections, polledCount: freeModels.length }));
|
|
2402
|
-
notifyClients();
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
2405
|
-
} else if (url.pathname === '/api/providers/registry') {
|
|
2406
|
-
const { BUILT_INS } = await import('../providers/registry.mjs');
|
|
2407
|
-
const overridesPath = join(configDir(HOME), 'providers.json');
|
|
2408
|
-
const { action, id, package: pkg, options } = data;
|
|
2409
|
-
if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid provider id');
|
|
2410
|
-
if (BUILT_INS.includes(id)) throw new Error(`cannot override built-in provider '${id}'`);
|
|
2411
|
-
|
|
2412
|
-
let current = { providers: [] };
|
|
2413
|
-
if (existsSync(overridesPath)) {
|
|
2414
|
-
try { current = JSON.parse(readFileSync(overridesPath, 'utf8')); } catch { current = { providers: [] }; }
|
|
2415
|
-
if (!Array.isArray(current.providers)) current.providers = [];
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
if (action === 'delete') {
|
|
2419
|
-
current.providers = current.providers.filter(p => p?.id !== id);
|
|
2420
|
-
} else if (action === 'save') {
|
|
2421
|
-
if (!pkg || typeof pkg !== 'string') throw new Error('package required');
|
|
2422
|
-
const { validateProviderEntry } = await import('../providers/registry.mjs');
|
|
2423
|
-
const verdict = await Promise.race([
|
|
2424
|
-
validateProviderEntry({ id, package: pkg, options: options || {} }, { rootDir: ROOT_DIR, env: process.env }),
|
|
2425
|
-
new Promise((resolve) => setTimeout(() => resolve({ ok: false, error: 'provider load timeout (5s)' }), 5000)),
|
|
2426
|
-
]);
|
|
2427
|
-
if (!verdict.ok) throw new Error(`provider '${id}' failed to load: ${verdict.error}`);
|
|
2428
|
-
current.providers = current.providers.filter(p => p?.id !== id).concat([{ id, package: pkg, options: options || {} }]);
|
|
2429
|
-
} else {
|
|
2430
|
-
throw new Error('action must be save or delete');
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
mkdirSync(configDir(HOME), { recursive: true });
|
|
2434
|
-
const tmp = overridesPath + '.tmp';
|
|
2435
|
-
writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
|
|
2436
|
-
renameSync(tmp, overridesPath);
|
|
2437
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2438
|
-
res.end(JSON.stringify({ success: true }));
|
|
2439
|
-
notifyClients();
|
|
2440
|
-
} else if (url.pathname === '/api/providers/credentials') {
|
|
2441
|
-
// 127.0.0.1 already requires same-user file access to write config.env,
|
|
2442
|
-
// so no extra security comes from forcing a token on localhost. On any
|
|
2443
|
-
// non-loopback bind, require the dashboard token to gate writes.
|
|
2444
|
-
const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
|
|
2445
|
-
if (isAuthConfigured()) {
|
|
2446
|
-
if (!isAuthenticated(req)) {
|
|
2447
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
2448
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
2449
|
-
return;
|
|
2450
|
-
}
|
|
2451
|
-
} else if (!isLocal) {
|
|
2452
|
-
throw new Error('credential management on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
|
|
2453
|
-
}
|
|
2454
|
-
const { envVar, value } = data;
|
|
2455
|
-
if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
|
|
2456
|
-
const allowed = allowedCredentialEnvVars();
|
|
2457
|
-
if (!allowed.has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
|
|
2458
|
-
if (value !== '' && typeof value !== 'string') throw new Error('value must be a string');
|
|
2459
|
-
|
|
2460
|
-
const { writeEnvValues, parseEnvFile, loadConstructEnv } = await import('../env-config.mjs');
|
|
2461
|
-
mkdirSync(configDir(HOME), { recursive: true });
|
|
2462
|
-
const action = value === '' ? 'unset' : 'set';
|
|
2463
|
-
|
|
2464
|
-
if (action === 'unset') {
|
|
2465
|
-
// writeEnvValues drops empty values, so passing '' deletes the key.
|
|
2466
|
-
writeEnvValues(CONFIG_ENV_FILE, { ...parseEnvFile(CONFIG_ENV_FILE), [envVar]: '' });
|
|
2467
|
-
delete process.env[envVar];
|
|
2468
|
-
} else {
|
|
2469
|
-
writeEnvValues(CONFIG_ENV_FILE, { [envVar]: value });
|
|
2470
|
-
process.env[envVar] = value;
|
|
2471
|
-
}
|
|
2472
|
-
try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms set best-effort */ }
|
|
2473
|
-
|
|
2474
|
-
// Re-overlay so any non-targeted keys edited out-of-band also refresh.
|
|
2475
|
-
const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
|
|
2476
|
-
for (const [k, v] of Object.entries(merged)) {
|
|
2477
|
-
if (allowed.has(k) && k !== envVar) process.env[k] = v;
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
try {
|
|
2481
|
-
mkdirSync(doctorRoot(), { recursive: true });
|
|
2482
|
-
appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action, envVar }) + '\n');
|
|
2483
|
-
} catch { /* audit append best-effort */ }
|
|
2484
|
-
|
|
2485
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2486
|
-
res.end(JSON.stringify({ success: true }));
|
|
2487
|
-
notifyClients();
|
|
2488
|
-
} else if (url.pathname === '/api/providers/subscriptions') {
|
|
2489
|
-
const subsPath = join(configDir(HOME), 'provider-subscriptions.json');
|
|
2490
|
-
const { action, id, provider, name, config } = data;
|
|
2491
|
-
if (!id || typeof id !== 'string' || !/^[\w.-]+$/.test(id)) throw new Error('Invalid subscription id');
|
|
2492
|
-
|
|
2493
|
-
let current = { subscriptions: [] };
|
|
2494
|
-
if (existsSync(subsPath)) {
|
|
2495
|
-
try { current = JSON.parse(readFileSync(subsPath, 'utf8')); } catch { current = { subscriptions: [] }; }
|
|
2496
|
-
if (!Array.isArray(current.subscriptions)) current.subscriptions = [];
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
if (action === 'delete') {
|
|
2500
|
-
current.subscriptions = current.subscriptions.filter(s => s?.id !== id);
|
|
2501
|
-
} else if (action === 'save') {
|
|
2502
|
-
if (!provider || typeof provider !== 'string') throw new Error('provider required');
|
|
2503
|
-
if (config && typeof config !== 'object') throw new Error('config must be an object');
|
|
2504
|
-
const entry = { id, provider, name: name || id, config: config || {} };
|
|
2505
|
-
current.subscriptions = current.subscriptions.filter(s => s?.id !== id).concat([entry]);
|
|
2506
|
-
} else {
|
|
2507
|
-
throw new Error('action must be save or delete');
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
|
-
mkdirSync(configDir(HOME), { recursive: true });
|
|
2511
|
-
const tmp = subsPath + '.tmp';
|
|
2512
|
-
writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
|
|
2513
|
-
renameSync(tmp, subsPath);
|
|
2514
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2515
|
-
res.end(JSON.stringify({ success: true }));
|
|
2516
|
-
notifyClients();
|
|
2517
|
-
} else {
|
|
2518
|
-
res.writeHead(404);
|
|
2519
|
-
res.end('Not found');
|
|
2520
|
-
}
|
|
2521
|
-
} catch (err) {
|
|
2522
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2523
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
2524
|
-
}
|
|
2525
|
-
});
|
|
2526
|
-
return;
|
|
2527
|
-
}
|
|
2528
|
-
|
|
2529
|
-
if (url.pathname.startsWith('/demo-preview/')) {
|
|
2530
|
-
const preview = resolveDemoPreviewPath(url.pathname);
|
|
2531
|
-
if (!preview.ok) {
|
|
2532
|
-
res.writeHead(preview.status);
|
|
2533
|
-
res.end(preview.message);
|
|
2534
|
-
return;
|
|
2535
|
-
}
|
|
2536
|
-
res.writeHead(200, { 'Content-Type': demoPreviewMime(preview.ext) });
|
|
2537
|
-
res.end(readFileSync(preview.filePath));
|
|
2538
|
-
return;
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
// Static files
|
|
2542
|
-
let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
2543
|
-
let fullPath = join(STATIC_DIR, filePath);
|
|
2544
|
-
|
|
2545
|
-
if (!fullPath.startsWith(STATIC_DIR)) {
|
|
2546
|
-
res.writeHead(403);
|
|
2547
|
-
res.end('Forbidden');
|
|
2548
|
-
return;
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
if (existsSync(fullPath) && statSync(fullPath).isDirectory()) {
|
|
2552
|
-
fullPath = join(fullPath, 'index.html');
|
|
2553
|
-
} else if (!existsSync(fullPath)) {
|
|
2554
|
-
const indexCandidate = join(STATIC_DIR, filePath, 'index.html');
|
|
2555
|
-
if (existsSync(indexCandidate)) fullPath = indexCandidate;
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
if (!existsSync(fullPath) || statSync(fullPath).isDirectory()) {
|
|
2559
|
-
res.writeHead(404);
|
|
2560
|
-
res.end('Not found');
|
|
2561
|
-
return;
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
const ext = extname(fullPath);
|
|
2565
|
-
const mime = MIME[ext] ?? 'application/octet-stream';
|
|
2566
|
-
res.writeHead(200, { 'Content-Type': mime });
|
|
2567
|
-
res.end(readFileSync(fullPath));
|
|
2568
|
-
});
|
|
2569
|
-
|
|
2570
|
-
watchFiles();
|
|
2571
|
-
|
|
2572
|
-
// ── Embed scheduler ──────────────────────────────────────────────────────────
|
|
2573
|
-
// When CX_AUTO_EMBED=1, run `construct sync` at startup and on a fixed interval
|
|
2574
|
-
// to keep the knowledge base current without manual intervention.
|
|
2575
|
-
// Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
|
|
2576
|
-
function runEmbedSync() {
|
|
2577
|
-
const syncScript = join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
|
|
2578
|
-
if (!existsSync(syncScript)) return;
|
|
2579
|
-
try {
|
|
2580
|
-
spawnSync(process.execPath, [syncScript], {
|
|
2581
|
-
cwd: ROOT_DIR,
|
|
2582
|
-
stdio: 'ignore',
|
|
2583
|
-
timeout: 120_000,
|
|
2584
|
-
env: { ...process.env },
|
|
2585
|
-
});
|
|
2586
|
-
} catch {
|
|
2587
|
-
// non-fatal — next interval will retry
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
// Load user env config into process.env so dashboard API endpoints that read
|
|
2592
|
-
// from process.env (telemetry credentials, model provider keys, etc.) find them
|
|
2593
|
-
// even when the server is started directly (not through bin/construct).
|
|
2594
|
-
try {
|
|
2595
|
-
const { getUserEnvPath } = await import('../env-config.mjs');
|
|
2596
|
-
const { prepareConstructEnv } = await import('../runtime-env.mjs');
|
|
2597
|
-
const homeDir = HOME || homedir();
|
|
2598
|
-
const envPath = getUserEnvPath(homeDir);
|
|
2599
|
-
if (existsSync(envPath)) {
|
|
2600
|
-
const ENV = prepareConstructEnv({ rootDir: ROOT_DIR, homeDir, env: process.env });
|
|
2601
|
-
for (const [key, value] of Object.entries(ENV)) {
|
|
2602
|
-
if (!(key in process.env)) process.env[key] = value;
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
} catch { /* non-fatal — env loading is best-effort */ }
|
|
2606
|
-
|
|
2607
|
-
if (process.env.CX_AUTO_EMBED === '1') {
|
|
2608
|
-
const intervalMs = parseInt(process.env.CX_EMBED_INTERVAL_MS ?? '', 10) || 30 * 60 * 1000;
|
|
2609
|
-
runEmbedSync();
|
|
2610
|
-
setInterval(runEmbedSync, intervalMs).unref();
|
|
2611
|
-
console.log(`Embed scheduler active — syncing every ${intervalMs / 60_000} min`);
|
|
2612
|
-
}
|
|
2613
|
-
assertSafeBind();
|
|
2614
|
-
server.listen(PORT, BIND_HOST, () => {
|
|
2615
|
-
console.log(`Construct dashboard running at http://${BIND_HOST}:${PORT}`);
|
|
2616
|
-
});
|
|
2617
|
-
|
|
2618
|
-
// SIGTERM/SIGINT close the server cleanly so the process exits 0 instead of
|
|
2619
|
-
// 143 — keeps shell tooling and supervisors from flagging normal shutdowns
|
|
2620
|
-
// as failures.
|
|
2621
|
-
|
|
2622
|
-
let shuttingDown = false;
|
|
2623
|
-
function shutdown(signal) {
|
|
2624
|
-
if (shuttingDown) return;
|
|
2625
|
-
shuttingDown = true;
|
|
2626
|
-
console.log(`Received ${signal}, shutting down dashboard...`);
|
|
2627
|
-
closeWatchers();
|
|
2628
|
-
for (const client of sseClients) {
|
|
2629
|
-
try { client.end(); } catch { /* ignore */ }
|
|
2630
|
-
}
|
|
2631
|
-
server.close(() => process.exit(0));
|
|
2632
|
-
setTimeout(() => process.exit(0), 2000).unref();
|
|
2633
|
-
}
|
|
2634
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
2635
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
2636
|
-
|
|
2637
|
-
// ── Subscribe to embed notification bus → SSE toast events ──────────────────
|
|
2638
|
-
onEmbedNotification((event) => {
|
|
2639
|
-
notifyClients({ type: 'toast', ...event });
|
|
2640
|
-
});
|
|
2641
|
-
|