@geraldmaron/construct 1.0.0 → 1.0.2
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/.env.example +9 -6
- package/LICENSE +201 -21
- package/README.md +132 -257
- package/agents/contracts.json +387 -0
- package/agents/prompts/cx-accessibility.md +8 -0
- package/agents/prompts/cx-ai-engineer.md +92 -0
- package/agents/prompts/cx-architect.md +10 -2
- package/agents/prompts/cx-business-strategist.md +13 -0
- package/agents/prompts/cx-data-analyst.md +80 -0
- package/agents/prompts/cx-data-engineer.md +4 -0
- package/agents/prompts/cx-debugger.md +8 -0
- package/agents/prompts/cx-designer.md +19 -0
- package/agents/prompts/cx-devil-advocate.md +4 -0
- package/agents/prompts/cx-docs-keeper.md +128 -0
- package/agents/prompts/cx-engineer.md +13 -0
- package/agents/prompts/cx-evaluator.md +4 -0
- package/agents/prompts/cx-explorer.md +12 -0
- package/agents/prompts/cx-legal-compliance.md +13 -0
- package/agents/prompts/cx-operations.md +13 -0
- package/agents/prompts/cx-orchestrator.md +107 -4
- package/agents/prompts/cx-platform-engineer.md +75 -0
- package/agents/prompts/cx-product-manager.md +8 -0
- package/agents/prompts/cx-qa.md +100 -0
- package/agents/prompts/cx-rd-lead.md +13 -0
- package/agents/prompts/cx-release-manager.md +8 -0
- package/agents/prompts/cx-researcher.md +21 -1
- package/agents/prompts/cx-reviewer.md +8 -0
- package/agents/prompts/cx-security.md +104 -0
- package/agents/prompts/cx-sre.md +104 -0
- package/agents/prompts/cx-test-automation.md +4 -0
- package/agents/prompts/cx-trace-reviewer.md +7 -3
- package/agents/prompts/cx-ux-researcher.md +4 -0
- package/agents/registry.json +365 -90
- package/agents/role-manifests.json +217 -0
- package/bin/construct +3345 -141
- package/bin/construct-postinstall.mjs +87 -0
- package/commands/build/feature.md +6 -6
- package/commands/plan/feature.md +6 -5
- package/commands/plan/requirements.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/work/drive.md +3 -3
- package/commands/work/optimize-prompts.md +3 -3
- package/db/{migrations → schema}/001_init.sql +2 -0
- package/db/schema/002_pgvector.sql +182 -0
- package/db/schema/003_intake.sql +47 -0
- package/examples/README.md +85 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +30 -0
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +23 -0
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +30 -0
- package/examples/internal/roles/engineer/golden/read-before-write.md +28 -0
- package/examples/internal/roles/orchestrator/bad/everything-becomes-multi-agent.md +29 -0
- package/examples/internal/roles/orchestrator/golden/minimal-dispatch.md +22 -0
- package/examples/internal/roles/qa/bad/coverage-theater.md +30 -0
- package/examples/internal/roles/qa/golden/regression-gate.md +23 -0
- package/examples/internal/roles/reviewer/bad/lgtm-without-verification.md +29 -0
- package/examples/internal/roles/reviewer/golden/find-structural-risk-first.md +28 -0
- package/examples/personas/construct/adversarial/ignore-instruction-to-skip-approval.md +22 -0
- package/examples/personas/construct/bad/commit-without-approval.md +30 -0
- package/examples/personas/construct/boundary/blocked-needs-main-input.md +29 -0
- package/examples/personas/construct/golden/branch-approval-before-mutation.md +36 -0
- package/examples/personas/construct/golden/focused-direct-answer.md +28 -0
- package/examples/provider-plugin/README.md +34 -0
- package/examples/provider-plugin/index.mjs +74 -0
- package/examples/provider-plugin/package.json +15 -0
- package/examples/seed-observations/README.md +38 -0
- package/examples/seed-observations/anti-patterns.md +44 -0
- package/examples/seed-observations/decisions.md +36 -0
- package/examples/seed-observations/patterns.md +42 -0
- package/lib/agent-contracts-enforce.mjs +158 -0
- package/lib/agent-contracts.mjs +231 -0
- package/lib/agents/postconditions.mjs +126 -0
- package/lib/agents/schema.mjs +124 -0
- package/lib/artifact-capture.mjs +183 -0
- package/lib/audit-trail.mjs +149 -0
- package/lib/auto-docs.mjs +245 -65
- package/lib/beads/auto-close.mjs +126 -0
- package/lib/beads/drift.mjs +171 -0
- package/lib/beads-automation.mjs +542 -0
- package/lib/beads-client.mjs +518 -0
- package/lib/beads-lock.mjs +377 -0
- package/lib/beads-optimistic.mjs +365 -0
- package/lib/bootstrap/built-ins.mjs +136 -0
- package/lib/bootstrap/lazy-install.mjs +161 -0
- package/lib/bootstrap/resources.mjs +120 -0
- package/lib/bootstrap.mjs +105 -0
- package/lib/cache-governor.js +213 -0
- package/lib/cache-strategy-anthropic.js +62 -0
- package/lib/cache-strategy-google.js +79 -0
- package/lib/cache-strategy-none.js +30 -0
- package/lib/cache-strategy-openai.js +48 -0
- package/lib/cache-strategy.js +91 -0
- package/lib/claude-allow.mjs +149 -0
- package/lib/cli-commands.mjs +413 -258
- package/lib/codex-config.mjs +3 -1
- package/lib/comment-lint.mjs +55 -6
- package/lib/completions.mjs +21 -6
- package/lib/config/alias.mjs +56 -0
- package/lib/config/project-config.mjs +335 -0
- package/lib/config/schema.mjs +159 -0
- package/lib/context-router.mjs +308 -0
- package/lib/cost-ledger.mjs +177 -0
- package/lib/cost.mjs +171 -10
- package/lib/dashboard-static.mjs +158 -0
- package/lib/deployment-mode.mjs +86 -0
- package/lib/deprecate.mjs +49 -0
- package/lib/dispatch-batch.js +183 -0
- package/lib/distill.mjs +21 -8
- package/lib/doc-stamp.mjs +164 -0
- package/lib/doc-verify.mjs +119 -0
- package/lib/docs-routing.mjs +89 -0
- package/lib/docs-verify.mjs +417 -0
- package/lib/doctor/audit.mjs +71 -0
- package/lib/doctor/cli.mjs +99 -0
- package/lib/doctor/escalate.mjs +29 -0
- package/lib/doctor/index.mjs +140 -0
- package/lib/doctor/report.mjs +170 -0
- package/lib/doctor/watchers/bd-watch.mjs +117 -0
- package/lib/doctor/watchers/cost.mjs +130 -0
- package/lib/doctor/watchers/disk.mjs +122 -0
- package/lib/doctor/watchers/handoffs.mjs +33 -0
- package/lib/doctor/watchers/process-pressure.mjs +60 -0
- package/lib/doctor/watchers/service-health.mjs +188 -0
- package/lib/document-extract.mjs +288 -0
- package/lib/document-ingest.mjs +230 -0
- package/lib/drop.mjs +282 -0
- package/lib/embed/approval-queue.mjs +176 -0
- package/lib/embed/artifact.mjs +349 -0
- package/lib/embed/authority-guard.mjs +155 -0
- package/lib/embed/cli.mjs +408 -0
- package/lib/embed/config.mjs +355 -0
- package/lib/embed/conflict-detection.mjs +264 -0
- package/lib/embed/customer-profiles.mjs +480 -0
- package/lib/embed/daemon.mjs +1309 -0
- package/lib/embed/demand-fetch.mjs +449 -0
- package/lib/embed/docs-lifecycle.mjs +349 -0
- package/lib/embed/inbox-live-watcher.mjs +119 -0
- package/lib/embed/inbox.mjs +343 -0
- package/lib/embed/intake-metrics.mjs +190 -0
- package/lib/embed/jobs/vector-sync.mjs +198 -0
- package/lib/embed/notifications.mjs +75 -0
- package/lib/embed/output.mjs +79 -0
- package/lib/embed/providers/github.mjs +295 -0
- package/lib/embed/providers/jira.mjs +192 -0
- package/lib/embed/providers/linear.mjs +186 -0
- package/lib/embed/providers/registry.mjs +115 -0
- package/lib/embed/providers/slack.mjs +203 -0
- package/lib/embed/recommendation-store.mjs +378 -0
- package/lib/embed/roadmap.mjs +374 -0
- package/lib/embed/role-framing.mjs +110 -0
- package/lib/embed/scheduler.mjs +99 -0
- package/lib/embed/semantic.mjs +325 -0
- package/lib/embed/snapshot.mjs +191 -0
- package/lib/embed/supervision.mjs +235 -0
- package/lib/embed/target-resolver.mjs +186 -0
- package/lib/embed/worker.mjs +62 -0
- package/lib/embed/workspaces.mjs +297 -0
- package/lib/engine/chunker-headings.mjs +110 -0
- package/lib/engine/compressor-heuristic.mjs +100 -0
- package/lib/engine/consolidate.mjs +287 -0
- package/lib/engine/contracts.mjs +126 -0
- package/lib/engine/defaults.mjs +129 -0
- package/lib/engine/eval-retrieval.mjs +148 -0
- package/lib/engine/fuser-rrf.mjs +62 -0
- package/lib/engine/index.mjs +37 -0
- package/lib/engine/registry.mjs +146 -0
- package/lib/engine/reranker-mmr.mjs +90 -0
- package/lib/engine/tokens.mjs +77 -0
- package/lib/entity-store.mjs +280 -0
- package/lib/env-config.mjs +69 -4
- package/lib/evals/retrieval-bench.mjs +159 -0
- package/lib/evaluator-optimizer.mjs +317 -0
- package/lib/features.mjs +159 -29
- package/lib/gates-audit.mjs +236 -0
- package/lib/git-hooks/prepare-commit-msg +58 -0
- package/lib/handoffs/cleanup.mjs +159 -0
- package/lib/handoffs/contract.mjs +162 -0
- package/lib/handoffs/inventory.mjs +120 -0
- package/lib/headhunt.mjs +28 -47
- package/lib/health-check.mjs +399 -0
- package/lib/hook-health.mjs +442 -0
- package/lib/hooks/_lib/log.mjs +82 -0
- package/lib/hooks/adaptive-lint.mjs +26 -2
- package/lib/hooks/agent-tracker.mjs +171 -14
- package/lib/hooks/audit-reads.mjs +109 -0
- package/lib/hooks/audit-trail.mjs +153 -0
- package/lib/hooks/bash-output-logger.mjs +71 -0
- package/lib/hooks/block-no-verify.mjs +41 -0
- package/lib/hooks/ci-status-check.mjs +82 -0
- package/lib/hooks/comment-lint.mjs +29 -7
- package/lib/hooks/config-protection.mjs +39 -18
- package/lib/hooks/context-watch.mjs +137 -0
- package/lib/hooks/context-window-recovery.mjs +4 -20
- package/lib/hooks/dep-audit.mjs +16 -0
- package/lib/hooks/doc-coupling-check.mjs +80 -0
- package/lib/hooks/edit-accumulator.mjs +3 -0
- package/lib/hooks/edit-error-recovery.mjs +3 -0
- package/lib/hooks/edit-guard.mjs +45 -4
- package/lib/hooks/env-check.mjs +3 -0
- package/lib/hooks/guard-bash.mjs +58 -1
- package/lib/hooks/mcp-audit.mjs +18 -20
- package/lib/hooks/mcp-health-check.mjs +36 -0
- package/lib/hooks/model-fallback.mjs +42 -56
- package/lib/hooks/policy-engine.mjs +209 -0
- package/lib/hooks/post-merge-docs-check.mjs +63 -0
- package/lib/hooks/pre-compact.mjs +4 -24
- package/lib/hooks/pre-push-gate.mjs +200 -37
- package/lib/hooks/proactive-activation.mjs +284 -0
- package/lib/hooks/read-tracker.mjs +27 -4
- package/lib/hooks/readme-age-check.mjs +77 -0
- package/lib/hooks/registry-sync.mjs +12 -5
- package/lib/hooks/scan-secrets.mjs +50 -7
- package/lib/hooks/session-optimize.mjs +311 -0
- package/lib/hooks/session-start.mjs +287 -28
- package/lib/hooks/stop-notify.mjs +236 -96
- package/lib/hooks/stop-typecheck.mjs +13 -1
- package/lib/hooks/test-watch.mjs +68 -0
- package/lib/host-capabilities.mjs +31 -10
- package/lib/init-docs.mjs +731 -291
- package/lib/init-unified.mjs +1000 -0
- package/lib/init-update.mjs +168 -0
- package/lib/init.mjs +107 -0
- package/lib/install/first-invocation.mjs +119 -0
- package/lib/install/stage-project.mjs +69 -0
- package/lib/intake/classify.mjs +278 -0
- package/lib/intake/feedback.mjs +273 -0
- package/lib/intake/filesystem-queue.mjs +158 -0
- package/lib/intake/intake-config.mjs +132 -0
- package/lib/intake/postgres-queue.mjs +198 -0
- package/lib/intake/prepare.mjs +139 -0
- package/lib/intake/queue.mjs +83 -0
- package/lib/intake/session-prelude.mjs +50 -0
- package/lib/integrations/intake-integrations.mjs +740 -0
- package/lib/intent-classifier.mjs +253 -0
- package/lib/knowledge/layout.mjs +72 -0
- package/lib/knowledge/rag.mjs +331 -0
- package/lib/knowledge/search.mjs +315 -0
- package/lib/knowledge/trends.mjs +261 -0
- package/lib/logger.mjs +85 -0
- package/lib/mcp/broker.mjs +124 -0
- package/lib/mcp/server.mjs +554 -854
- package/lib/mcp/tools/document.mjs +132 -0
- package/lib/mcp/tools/memory.mjs +209 -0
- package/lib/mcp/tools/project.mjs +349 -0
- package/lib/mcp/tools/skills.mjs +409 -0
- package/lib/mcp/tools/storage.mjs +60 -0
- package/lib/mcp/tools/telemetry.mjs +315 -0
- package/lib/mcp/tools/workflow.mjs +112 -0
- package/lib/mcp-catalog.json +54 -3
- package/lib/mcp-manager.mjs +96 -48
- package/lib/mcp-platform-config.mjs +22 -22
- package/lib/memory-stats.mjs +121 -0
- package/lib/mode-commands.mjs +124 -0
- package/lib/model-free-selector.mjs +184 -0
- package/lib/model-pricing.mjs +152 -0
- package/lib/model-registry.mjs +226 -0
- package/lib/model-router.mjs +362 -386
- package/lib/observation-store.mjs +391 -0
- package/lib/ollama-manager.mjs +428 -0
- package/lib/opencode-config.mjs +13 -3
- package/lib/opencode-runtime-plugin.mjs +70 -38
- package/lib/opencode-telemetry.mjs +64 -6
- package/lib/orchestration-policy.mjs +509 -6
- package/lib/overrides/resolver.mjs +207 -0
- package/lib/parity.mjs +147 -0
- package/lib/paths.mjs +33 -0
- package/lib/performance/generate.mjs +212 -0
- package/lib/plugin-registry.mjs +268 -0
- package/lib/policy/engine.mjs +130 -0
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-detection.mjs +129 -0
- package/lib/project-init-shared.mjs +272 -0
- package/lib/project-profile.mjs +447 -0
- package/lib/prompt-composer.js +434 -0
- package/lib/prompt-metadata.mjs +1 -1
- package/lib/provider-capabilities-anthropic.js +44 -0
- package/lib/provider-capabilities-deepseek.js +37 -0
- package/lib/provider-capabilities-generic.js +26 -0
- package/lib/provider-capabilities-google.js +47 -0
- package/lib/provider-capabilities-openai.js +45 -0
- package/lib/provider-capabilities.js +142 -0
- package/lib/providers/atlassian-confluence/index.mjs +103 -0
- package/lib/providers/atlassian-jira/index.mjs +100 -0
- package/lib/providers/auth-manager.mjs +126 -0
- package/lib/providers/circuit-breaker.mjs +124 -0
- package/lib/providers/contract.mjs +93 -0
- package/lib/providers/github/index.mjs +126 -0
- package/lib/providers/registry.mjs +184 -0
- package/lib/providers/salesforce/index.mjs +100 -0
- package/lib/providers/slack/index.mjs +80 -0
- package/lib/reflect.mjs +137 -0
- package/lib/research-lint.mjs +164 -0
- package/lib/resources/budget.mjs +259 -0
- package/lib/role-preload.mjs +21 -6
- package/lib/roles/approval-surface.mjs +54 -0
- package/lib/roles/cli.mjs +118 -0
- package/lib/roles/event-bus.mjs +79 -0
- package/lib/roles/fence.mjs +84 -0
- package/lib/roles/gateway.mjs +260 -0
- package/lib/roles/hook-emit.mjs +37 -0
- package/lib/roles/manifest.mjs +48 -0
- package/lib/roles/router.mjs +27 -0
- package/lib/runtime-pressure.mjs +360 -0
- package/lib/schema-artifact.mjs +134 -0
- package/lib/schema-infer.mjs +551 -0
- package/lib/server/auth.mjs +168 -0
- package/lib/server/chat.mjs +336 -0
- package/lib/server/cors.mjs +77 -0
- package/lib/server/csrf.mjs +91 -0
- package/lib/server/index.mjs +1927 -78
- package/lib/server/insights.mjs +765 -0
- package/lib/server/rate-limit.mjs +91 -0
- package/lib/server/static/assets/index-ab25c707.js +70 -0
- package/lib/server/static/assets/index-f0c80a2b.css +1 -0
- package/lib/server/static/index.html +12 -817
- package/lib/server/telemetry-login.mjs +108 -0
- package/lib/server/webhook.mjs +510 -0
- package/lib/service-manager.mjs +522 -58
- package/lib/services/pattern-promotion-service.mjs +167 -0
- package/lib/services/telemetry-backend.mjs +178 -0
- package/lib/session-store.mjs +374 -0
- package/lib/setup-prompts.mjs +96 -0
- package/lib/setup.mjs +523 -36
- package/lib/skills-apply.mjs +280 -0
- package/lib/skills-scope.mjs +118 -0
- package/lib/status.mjs +261 -70
- package/lib/storage/admin.mjs +355 -0
- package/lib/storage/backend.mjs +2 -1
- package/lib/storage/backup.mjs +347 -0
- package/lib/storage/embeddings-engine.mjs +133 -0
- package/lib/storage/embeddings-legacy.mjs +85 -0
- package/lib/storage/embeddings-local.mjs +108 -0
- package/lib/storage/embeddings-ollama.mjs +78 -0
- package/lib/storage/embeddings-openai.mjs +85 -0
- package/lib/storage/embeddings.mjs +92 -33
- package/lib/storage/file-lock.mjs +130 -0
- package/lib/storage/fusion.mjs +95 -0
- package/lib/storage/hybrid-query.mjs +34 -27
- package/lib/storage/migrations.mjs +187 -0
- package/lib/storage/postgres-backup.mjs +124 -0
- package/lib/storage/sql-store.mjs +5 -15
- package/lib/storage/state-source.mjs +12 -13
- package/lib/storage/store-version.mjs +115 -0
- package/lib/storage/sync.mjs +144 -35
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/storage/vector-client.mjs +286 -0
- package/lib/storage/vector-store.mjs +71 -30
- package/lib/task-graph/generate.mjs +135 -0
- package/lib/task-graph/schema.mjs +81 -0
- package/lib/task-graph/store.mjs +71 -0
- package/lib/telemetry/backends/local.mjs +62 -0
- package/lib/telemetry/backends/{langfuse.mjs → remote.mjs} +27 -14
- package/lib/telemetry/backfill.mjs +180 -0
- package/lib/telemetry/eval-datasets.mjs +203 -0
- package/lib/telemetry/{langfuse-ingest.mjs → ingest.mjs} +26 -20
- package/lib/telemetry/intent-verifications.mjs +86 -0
- package/lib/telemetry/llm-judge.mjs +350 -0
- package/lib/telemetry/model-pricing-catalog.mjs +557 -0
- package/lib/telemetry/setup.mjs +151 -0
- package/lib/telemetry/skill-calls.mjs +78 -0
- package/lib/telemetry/team-rollup.mjs +4 -4
- package/lib/token-engine.js +117 -0
- package/lib/token-estimator-anthropic.js +15 -0
- package/lib/token-estimator-deepseek.js +13 -0
- package/lib/token-estimator-default.js +13 -0
- package/lib/token-estimator-google.js +13 -0
- package/lib/token-estimator-openai.js +13 -0
- package/lib/toolkit-env.mjs +1 -1
- package/lib/tty-prompts.mjs +211 -0
- package/lib/uninstall/uninstall.mjs +423 -0
- package/lib/update.mjs +115 -0
- package/lib/upgrade.mjs +141 -0
- package/lib/validator.mjs +51 -2
- package/lib/validators/skills.mjs +142 -0
- package/lib/wireframe.mjs +422 -0
- package/lib/worker/entrypoint.mjs +241 -0
- package/lib/worker/evidence.mjs +107 -0
- package/lib/worker/run.mjs +154 -0
- package/lib/worker/trace.mjs +182 -0
- package/lib/workflow-state.mjs +14 -18
- package/package.json +21 -8
- package/personas/construct.md +53 -51
- package/platforms/claude/CLAUDE.md +1 -1
- package/platforms/claude/settings.template.json +115 -68
- package/platforms/opencode/config.template.json +3 -7
- package/rules/common/agents.md +11 -38
- package/rules/common/beads-hygiene.md +75 -0
- package/rules/common/code-review.md +11 -100
- package/rules/common/coding-style.md +5 -3
- package/rules/common/comments.md +30 -111
- package/rules/common/commit-approval.md +53 -0
- package/rules/common/cx-agent-routing.md +1 -0
- package/rules/common/development-workflow.md +23 -41
- package/rules/common/doc-ownership.md +54 -0
- package/rules/common/efficiency.md +57 -0
- package/rules/common/framing.md +76 -0
- package/rules/common/git-workflow.md +2 -4
- package/rules/common/patterns.md +3 -7
- package/rules/common/performance.md +15 -31
- package/rules/common/release-gates.md +69 -0
- package/rules/common/research.md +107 -0
- package/rules/common/security.md +6 -6
- package/rules/common/skill-composition.md +67 -0
- package/rules/common/testing.md +15 -27
- package/rules/golang/hooks.md +0 -4
- package/rules/policy/bootstrap.yaml +11 -0
- package/rules/policy/drive.yaml +8 -0
- package/rules/policy/task.yaml +9 -0
- package/rules/policy/workflow.yaml +9 -0
- package/rules/python/hooks.md +0 -4
- package/rules/swift/hooks.md +0 -4
- package/rules/typescript/hooks.md +0 -4
- package/rules/web/hooks.md +0 -2
- package/{sync-agents.mjs → scripts/sync-agents.mjs} +306 -61
- package/skills/ai/prompt-optimizer.md +7 -7
- package/skills/compliance/ai-disclosure.md +58 -0
- package/skills/compliance/data-privacy.md +46 -0
- package/skills/compliance/license-audit.md +40 -0
- package/skills/compliance/regulatory-review.md +61 -0
- package/skills/docs/document-ingest-workflow.md +52 -0
- package/skills/docs/evidence-ingest-workflow.md +9 -0
- package/skills/docs/init-docs.md +51 -18
- package/skills/docs/product-intelligence-workflow.md +12 -0
- package/skills/docs/product-signal-workflow.md +4 -0
- package/skills/docs/research-workflow.md +23 -8
- package/skills/docs/runbook-workflow.md +1 -1
- package/skills/operating/orchestration-reference.md +151 -0
- package/skills/roles/architect.md +5 -0
- package/skills/roles/engineer.md +32 -0
- package/skills/roles/operator.md +12 -0
- package/skills/roles/reviewer.md +23 -0
- package/skills/routing.md +1 -1
- package/templates/devcontainer/Dockerfile.devcontainer +38 -0
- package/templates/devcontainer/devcontainer.json +31 -0
- package/templates/distribution/bootstrap.ps1 +142 -0
- package/templates/distribution/bootstrap.sh +196 -0
- package/templates/distribution/run.mjs +187 -0
- package/templates/docs/adr.md +44 -8
- package/templates/docs/changelog-entry.md +43 -0
- package/templates/docs/construct_guide.md +149 -0
- package/templates/docs/evidence-brief.md +2 -2
- package/templates/docs/meta-prd.md +140 -19
- package/templates/docs/onboarding.md +57 -0
- package/templates/docs/prd.md +159 -15
- package/templates/docs/research-brief.md +4 -4
- package/templates/homebrew/construct.rb +67 -0
- package/langfuse/docker-compose.yml +0 -82
- package/lib/eval-harness.mjs +0 -59
- package/lib/hooks/bootstrap-guard.mjs +0 -90
- package/lib/hooks/console-warn.mjs +0 -43
- package/lib/hooks/continuation-enforcer.mjs +0 -72
- package/lib/hooks/drive-guard.mjs +0 -89
- package/lib/hooks/mcp-task-scope.mjs +0 -47
- package/lib/hooks/task-completed-guard.mjs +0 -43
- package/lib/hooks/teammate-idle-guard.mjs +0 -54
- package/lib/hooks/workflow-guard.mjs +0 -62
- package/lib/prompt-composer.mjs +0 -196
- package/lib/review.mjs +0 -429
- package/lib/server/static/app.js +0 -841
- package/lib/telemetry/langfuse-model-sync.mjs +0 -270
- package/rules/common/hooks.md +0 -35
- package/rules/zh/README.md +0 -113
- package/rules/zh/agents.md +0 -55
- package/rules/zh/code-review.md +0 -129
- package/rules/zh/coding-style.md +0 -53
- package/rules/zh/development-workflow.md +0 -49
- package/rules/zh/git-workflow.md +0 -29
- package/rules/zh/hooks.md +0 -35
- package/rules/zh/patterns.md +0 -36
- package/rules/zh/performance.md +0 -60
- package/rules/zh/security.md +0 -34
- package/rules/zh/testing.md +0 -34
package/lib/server/index.mjs
CHANGED
|
@@ -1,26 +1,249 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/server/index.mjs —
|
|
2
|
+
* lib/server/index.mjs — Construct dashboard HTTP server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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, and SSE-streamed chat via the claude CLI.
|
|
8
|
+
* Runs on port 4242 (overridable via PORT env var), bound to 127.0.0.1.
|
|
5
9
|
*/
|
|
6
10
|
import { createServer } from 'http';
|
|
7
|
-
import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync } from 'fs';
|
|
8
|
-
import {
|
|
11
|
+
import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync, mkdirSync, renameSync, chmodSync, appendFileSync } from 'fs';
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import { join, extname, relative, normalize, dirname } from 'path';
|
|
9
14
|
import { homedir } from 'os';
|
|
10
15
|
import { fileURLToPath } from 'url';
|
|
16
|
+
import { spawn } from 'child_process';
|
|
11
17
|
import { buildStatus as buildSharedStatus } from '../status.mjs';
|
|
18
|
+
import { generateArtifact, listArtifacts } from '../embed/artifact.mjs';
|
|
19
|
+
import { ApprovalQueue } from '../embed/approval-queue.mjs';
|
|
20
|
+
import { resolveEmbedStatus } from '../embed/cli.mjs';
|
|
21
|
+
import { loadConstructEnv } from '../env-config.mjs';
|
|
22
|
+
import {
|
|
23
|
+
isAuthConfigured, isAuthenticated, rejectUnauthorized,
|
|
24
|
+
validateToken, createSession, sessionCookieHeader, clearSessionCookieHeader,
|
|
25
|
+
getDashboardToken, getAuthConfig,
|
|
26
|
+
} from './auth.mjs';
|
|
27
|
+
import { handleChatStream, handleChat, handleChatHistory } from './chat.mjs';
|
|
28
|
+
import { createWebhookHandler, createSlackCommandHandler } from './webhook.mjs';
|
|
29
|
+
import { onEmbedNotification } from '../embed/notifications.mjs';
|
|
12
30
|
|
|
13
31
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
14
32
|
const ROOT_DIR = join(__dirname, '..', '..');
|
|
15
33
|
const HOME = homedir();
|
|
16
34
|
const PORT = parseInt(process.env.PORT ?? '4242', 10);
|
|
35
|
+
const BIND_HOST = process.env.BIND_HOST ?? (process.env.NODE_ENV === 'production' ? '0.0.0.0' : '127.0.0.1');
|
|
17
36
|
|
|
18
37
|
const STATIC_DIR = join(__dirname, 'static');
|
|
19
38
|
const REGISTRY_FILE = join(ROOT_DIR, 'agents', 'registry.json');
|
|
20
39
|
const FEATURES_FILE = join(HOME, '.construct', 'features.json');
|
|
21
|
-
const WORKFLOW_FILE = join(
|
|
40
|
+
const WORKFLOW_FILE = join(ROOT_DIR, 'plan.md');
|
|
22
41
|
const SKILLS_DIR = join(ROOT_DIR, 'skills');
|
|
23
42
|
const COMMANDS_DIR = join(ROOT_DIR, 'commands');
|
|
43
|
+
const SNAPSHOTS_FILE = join(HOME, '.cx', 'snapshots.jsonl');
|
|
44
|
+
const APPROVAL_QUEUE_FILE = join(HOME, '.cx', 'approval-queue.jsonl');
|
|
45
|
+
const CONFIG_ENV_FILE = join(HOME, '.construct', 'config.env');
|
|
46
|
+
const EMBED_YAML_FILE = join(HOME, '.construct', 'embed.yaml');
|
|
47
|
+
const CREDENTIAL_AUDIT_FILE = join(HOME, '.cx', 'credential-audit.jsonl');
|
|
48
|
+
|
|
49
|
+
// Provider → expected env vars. Source of truth for the credentials surface,
|
|
50
|
+
// the per-provider health classification ("not configured" vs "unhealthy"),
|
|
51
|
+
// and the write-side allowlist — POST refuses env vars not listed here.
|
|
52
|
+
|
|
53
|
+
const BUILTIN_CREDENTIAL_MAP = [
|
|
54
|
+
{ provider: 'anthropic', label: 'Anthropic', kind: 'llm', envVars: ['ANTHROPIC_API_KEY'] },
|
|
55
|
+
{ provider: 'openai', label: 'OpenAI', kind: 'llm', envVars: ['OPENAI_API_KEY'] },
|
|
56
|
+
{ provider: 'openrouter', label: 'OpenRouter', kind: 'llm', envVars: ['OPENROUTER_API_KEY'] },
|
|
57
|
+
{ provider: 'gemini', label: 'Google Gemini', kind: 'llm', envVars: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] },
|
|
58
|
+
{ provider: 'github', label: 'GitHub', kind: 'integration', envVars: ['GITHUB_TOKEN', 'GH_TOKEN'] },
|
|
59
|
+
{ provider: 'atlassian-jira', label: 'Jira', kind: 'integration', envVars: ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'] },
|
|
60
|
+
{ provider: 'atlassian-confluence',label: 'Confluence', kind: 'integration', envVars: ['CONFLUENCE_BASE_URL', 'CONFLUENCE_EMAIL', 'CONFLUENCE_API_TOKEN'] },
|
|
61
|
+
{ provider: 'slack', label: 'Slack', kind: 'integration', envVars: ['SLACK_BOT_TOKEN', 'SLACK_USER_TOKEN'] },
|
|
62
|
+
{ provider: 'salesforce', label: 'Salesforce', kind: 'integration', envVars: ['SALESFORCE_INSTANCE_URL', 'SALESFORCE_ACCESS_TOKEN'] },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const CUSTOM_CREDENTIALS_FILE = join(HOME, '.construct', 'custom-credentials.json');
|
|
66
|
+
|
|
67
|
+
// Env-var name validator: must match POSIX shell variable syntax and avoid
|
|
68
|
+
// stomping built-in entries. Refusing $PATH / $HOME / OS-special prefixes
|
|
69
|
+
// keeps a custom provider from being a foot-gun for the shell.
|
|
70
|
+
const ENV_VAR_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
71
|
+
const ENV_VAR_BLOCKLIST = new Set(['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'PWD', 'TERM']);
|
|
72
|
+
|
|
73
|
+
function readCustomCredentials() {
|
|
74
|
+
if (!existsSync(CUSTOM_CREDENTIALS_FILE)) return [];
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(readFileSync(CUSTOM_CREDENTIALS_FILE, 'utf8'));
|
|
77
|
+
const list = Array.isArray(raw?.providers) ? raw.providers : [];
|
|
78
|
+
return list.filter((entry) => {
|
|
79
|
+
if (!entry || typeof entry.provider !== 'string') return false;
|
|
80
|
+
if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === entry.provider)) return false;
|
|
81
|
+
if (!Array.isArray(entry.envVars)) return false;
|
|
82
|
+
return entry.envVars.every((name) => ENV_VAR_PATTERN.test(name) && !ENV_VAR_BLOCKLIST.has(name));
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeCustomCredentials(list) {
|
|
90
|
+
mkdirSync(dirname(CUSTOM_CREDENTIALS_FILE), { recursive: true });
|
|
91
|
+
writeFileSync(CUSTOM_CREDENTIALS_FILE, JSON.stringify({ providers: list }, null, 2) + '\n');
|
|
92
|
+
try { chmodSync(CUSTOM_CREDENTIALS_FILE, 0o600); } catch { /* perms best-effort */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function credentialMap() {
|
|
96
|
+
return [...BUILTIN_CREDENTIAL_MAP, ...readCustomCredentials()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function allowedCredentialEnvVars() {
|
|
100
|
+
return new Set(credentialMap().flatMap((c) => c.envVars));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function maskValue(value) {
|
|
104
|
+
if (!value) return null;
|
|
105
|
+
if (value.length <= 10) return `${value.slice(0, 2)}…${value.slice(-2)}`;
|
|
106
|
+
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildCredentialsView(env = process.env) {
|
|
110
|
+
return credentialMap().map(c => {
|
|
111
|
+
const vars = c.envVars.map(name => {
|
|
112
|
+
const val = resolveCredential(name, env);
|
|
113
|
+
const set = Boolean(val);
|
|
114
|
+
return { envVar: name, set, preview: set ? maskValue(val) : null };
|
|
115
|
+
});
|
|
116
|
+
const setCount = vars.filter(v => v.set).length;
|
|
117
|
+
let configured;
|
|
118
|
+
if (setCount === 0) configured = 'none';
|
|
119
|
+
else if (setCount === c.envVars.length) configured = 'full';
|
|
120
|
+
else configured = 'partial';
|
|
121
|
+
return { provider: c.provider, label: c.label, kind: c.kind, vars, configured };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A provider with no required env vars set is "not_configured" regardless of
|
|
126
|
+
// whether the probe succeeds — some probes (e.g. GitHub anonymous rate_limit)
|
|
127
|
+
// return ok:true even with zero auth, which would otherwise read as a green
|
|
128
|
+
// "Healthy" while the credentials column shows everything blank.
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a credential from env, config.env, ~/.env, and shell rc files.
|
|
132
|
+
* Mirrors the logic in model-router.mjs isProviderConfigured.
|
|
133
|
+
*/
|
|
134
|
+
function resolveCredential(varName, env) {
|
|
135
|
+
if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
|
|
136
|
+
const homeDir = HOME || homedir();
|
|
137
|
+
|
|
138
|
+
// Special case: Ollama on default port
|
|
139
|
+
if (varName === 'OLLAMA_BASE_URL') {
|
|
140
|
+
try {
|
|
141
|
+
const r = spawnSync('curl', ['-s', '--connect-timeout', '1', '-o', '/dev/null', '-w', '%{http_code}', 'http://localhost:11434/api/tags'], { encoding: 'utf8', timeout: 3000 });
|
|
142
|
+
if (r.status === 0 && r.stdout?.trim() === '200') return 'http://localhost:11434';
|
|
143
|
+
} catch { /* not available */ }
|
|
144
|
+
try {
|
|
145
|
+
const r = spawnSync('ollama', ['--version'], { encoding: 'utf8', timeout: 2000 });
|
|
146
|
+
if (r.status === 0) return 'found-ollama-binary';
|
|
147
|
+
} catch { /* not available */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const paths = [join(homeDir, '.construct', 'config.env'), join(homeDir, '.env')];
|
|
151
|
+
for (const p of paths) {
|
|
152
|
+
try {
|
|
153
|
+
if (existsSync(p)) {
|
|
154
|
+
const content = readFileSync(p, 'utf8');
|
|
155
|
+
const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
156
|
+
if (m && m[1]) return m[1].trim();
|
|
157
|
+
}
|
|
158
|
+
} catch { /* skip */ }
|
|
159
|
+
}
|
|
160
|
+
// Check shell rc files for op:// refs
|
|
161
|
+
const shellFiles = [join(homeDir, '.zshrc'), join(homeDir, '.bashrc'), join(homeDir, '.bash_profile'), join(homeDir, '.profile')];
|
|
162
|
+
for (const rc of shellFiles) {
|
|
163
|
+
if (!existsSync(rc)) continue;
|
|
164
|
+
try {
|
|
165
|
+
const content = readFileSync(rc, 'utf8');
|
|
166
|
+
const directRe = new RegExp(`^\\s*export\\s+${varName}=`, 'm');
|
|
167
|
+
if (directRe.test(content)) return 'found-in-rc';
|
|
168
|
+
const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
|
|
169
|
+
const m = content.match(opRe);
|
|
170
|
+
if (m) {
|
|
171
|
+
const r = spawnSync('op', ['read', m[1]], { encoding: 'utf8', timeout: 5000 });
|
|
172
|
+
if (r.status === 0 && r.stdout?.trim()) return r.stdout.trim();
|
|
173
|
+
}
|
|
174
|
+
} catch { /* skip */ }
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function classifyProviderStatus(providerId, healthOk, env = process.env) {
|
|
180
|
+
const entry = credentialMap().find(c => c.provider === providerId);
|
|
181
|
+
const anySet = entry ? entry.envVars.some(name => Boolean(resolveCredential(name, env))) : false;
|
|
182
|
+
if (!anySet) return 'not_configured';
|
|
183
|
+
return healthOk ? 'healthy' : 'unhealthy';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function describeMissingCredentials(providerId, env = process.env) {
|
|
187
|
+
const entry = credentialMap().find(c => c.provider === providerId);
|
|
188
|
+
if (!entry) return null;
|
|
189
|
+
const missing = entry.envVars.filter(name => !resolveCredential(name, env));
|
|
190
|
+
if (missing.length === 0) return null;
|
|
191
|
+
return `missing: ${missing.join(', ')}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const approvalQueue = new ApprovalQueue({ path: APPROVAL_QUEUE_FILE });
|
|
195
|
+
const sseClients = new Set();
|
|
196
|
+
|
|
197
|
+
// ── Terraform config ───────────────────────────────────────────────────────
|
|
198
|
+
const TERRAFORM_DIR = join(ROOT_DIR, 'deploy', 'terraform');
|
|
199
|
+
const TERRAFORM_ALLOWED_EXTS = new Set(['.tf', '.tfvars', '.json']);
|
|
200
|
+
|
|
201
|
+
function terraformFiles(dir, base) {
|
|
202
|
+
const results = [];
|
|
203
|
+
if (!existsSync(dir)) return results;
|
|
204
|
+
for (const entry of readdirSync(dir)) {
|
|
205
|
+
const full = join(dir, entry);
|
|
206
|
+
let stat;
|
|
207
|
+
try { stat = statSync(full); } catch { continue; }
|
|
208
|
+
if (stat.isDirectory()) {
|
|
209
|
+
results.push(...terraformFiles(full, base));
|
|
210
|
+
} else {
|
|
211
|
+
const ext = extname(entry);
|
|
212
|
+
if (TERRAFORM_ALLOWED_EXTS.has(ext)) {
|
|
213
|
+
results.push(relative(base, full));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return results.sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function assertTerraformPath(relPath) {
|
|
221
|
+
const abs = normalize(join(TERRAFORM_DIR, relPath));
|
|
222
|
+
if (!abs.startsWith(TERRAFORM_DIR + '/') && abs !== TERRAFORM_DIR) {
|
|
223
|
+
throw new Error('Path traversal not allowed');
|
|
224
|
+
}
|
|
225
|
+
if (!TERRAFORM_ALLOWED_EXTS.has(extname(abs))) {
|
|
226
|
+
throw new Error('Only .tf, .tfvars, and .json files are editable');
|
|
227
|
+
}
|
|
228
|
+
return abs;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Webhook handler — created after approvalQueue so it can share the instance
|
|
232
|
+
let _webhookHandler = null;
|
|
233
|
+
function getWebhookHandler() {
|
|
234
|
+
if (!_webhookHandler) {
|
|
235
|
+
_webhookHandler = createWebhookHandler({ approvalQueue, notifyClients });
|
|
236
|
+
}
|
|
237
|
+
return _webhookHandler;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let _slackCommandHandler = null;
|
|
241
|
+
function getSlackCommandHandler() {
|
|
242
|
+
if (!_slackCommandHandler) {
|
|
243
|
+
_slackCommandHandler = createSlackCommandHandler();
|
|
244
|
+
}
|
|
245
|
+
return _slackCommandHandler;
|
|
246
|
+
}
|
|
24
247
|
|
|
25
248
|
const MIME = {
|
|
26
249
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -72,6 +295,10 @@ async function buildStatus() {
|
|
|
72
295
|
rootDir: ROOT_DIR,
|
|
73
296
|
cwd: process.cwd(),
|
|
74
297
|
homeDir: HOME,
|
|
298
|
+
// The dashboard is long-lived and `construct up` rewrites managed values in
|
|
299
|
+
// ~/.construct/config.env. Read fresh config on every request instead of
|
|
300
|
+
// letting inherited process env shadow updated ports/credentials.
|
|
301
|
+
env: {},
|
|
75
302
|
dashboardPort: PORT,
|
|
76
303
|
selfDashboard: true,
|
|
77
304
|
});
|
|
@@ -89,6 +316,7 @@ async function handleSessionUsage(_req, res) {
|
|
|
89
316
|
rootDir: ROOT_DIR,
|
|
90
317
|
cwd: process.cwd(),
|
|
91
318
|
homeDir: HOME,
|
|
319
|
+
env: {},
|
|
92
320
|
});
|
|
93
321
|
|
|
94
322
|
const payload = {
|
|
@@ -108,13 +336,160 @@ async function handleSessionUsage(_req, res) {
|
|
|
108
336
|
}
|
|
109
337
|
}
|
|
110
338
|
|
|
111
|
-
|
|
339
|
+
// ── Artifacts handler ─────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async function handleArtifacts(req, res) {
|
|
342
|
+
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
343
|
+
|
|
344
|
+
if (req.method === 'GET') {
|
|
345
|
+
const type = url.searchParams.get('type') || undefined;
|
|
346
|
+
try {
|
|
347
|
+
const artifacts = listArtifacts({ type, rootDir: ROOT_DIR });
|
|
348
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
349
|
+
res.end(JSON.stringify({ artifacts }));
|
|
350
|
+
} catch (err) {
|
|
351
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
352
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (req.method === 'POST') {
|
|
358
|
+
let body = '';
|
|
359
|
+
req.on('data', chunk => { body += chunk; });
|
|
360
|
+
req.on('end', () => {
|
|
361
|
+
try {
|
|
362
|
+
const data = JSON.parse(body || '{}');
|
|
363
|
+
const result = generateArtifact({
|
|
364
|
+
type: data.type,
|
|
365
|
+
title: data.title,
|
|
366
|
+
rootDir: ROOT_DIR,
|
|
367
|
+
fields: data.fields || {},
|
|
368
|
+
dryRun: data.dryRun === true,
|
|
369
|
+
});
|
|
370
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
371
|
+
res.end(JSON.stringify({ success: true, ...result }));
|
|
372
|
+
if (!data.dryRun) notifyClients();
|
|
373
|
+
} catch (err) {
|
|
374
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
375
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
res.writeHead(405);
|
|
382
|
+
res.end('Method Not Allowed');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Approval queue handler ────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
async function handleApprovals(req, res) {
|
|
388
|
+
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
389
|
+
|
|
390
|
+
if (req.method === 'GET') {
|
|
391
|
+
const pending = approvalQueue.list('pending');
|
|
392
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
393
|
+
res.end(JSON.stringify({ items: pending }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (req.method === 'POST') {
|
|
398
|
+
let body = '';
|
|
399
|
+
req.on('data', chunk => { body += chunk; });
|
|
400
|
+
req.on('end', () => {
|
|
401
|
+
try {
|
|
402
|
+
const data = JSON.parse(body || '{}');
|
|
403
|
+
const { action, id, note } = data;
|
|
404
|
+
if (!id) throw new Error('Missing id');
|
|
405
|
+
if (action === 'approve') {
|
|
406
|
+
approvalQueue.approve(id);
|
|
407
|
+
} else if (action === 'reject') {
|
|
408
|
+
approvalQueue.reject(id, { reason: note || 'Rejected via dashboard' });
|
|
409
|
+
} else {
|
|
410
|
+
throw new Error('action must be approve or reject');
|
|
411
|
+
}
|
|
412
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
413
|
+
res.end(JSON.stringify({ success: true }));
|
|
414
|
+
notifyClients();
|
|
415
|
+
} catch (err) {
|
|
416
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
417
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
res.writeHead(405);
|
|
424
|
+
res.end('Method Not Allowed');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Config handler ────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
function handleConfig(req, res) {
|
|
430
|
+
if (req.method === 'GET') {
|
|
431
|
+
const env = existsSync(CONFIG_ENV_FILE) ? readFileSync(CONFIG_ENV_FILE, 'utf8') : '';
|
|
432
|
+
const embed = existsSync(EMBED_YAML_FILE) ? readFileSync(EMBED_YAML_FILE, 'utf8') : '';
|
|
433
|
+
// Extract roles from embed YAML (simple regex — works for flat keys)
|
|
434
|
+
let roles = { primary: null, secondary: null };
|
|
435
|
+
const primaryMatch = embed.match(/^\s*primary:\s*(.+)$/m);
|
|
436
|
+
const secondaryMatch = embed.match(/^\s*secondary:\s*(.+)$/m);
|
|
437
|
+
if (primaryMatch) roles.primary = primaryMatch[1].trim() || null;
|
|
438
|
+
if (secondaryMatch) roles.secondary = secondaryMatch[1].trim() || null;
|
|
439
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
440
|
+
res.end(JSON.stringify({ env, embed, roles }));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (req.method === 'POST') {
|
|
445
|
+
let body = '';
|
|
446
|
+
req.on('data', chunk => { body += chunk; });
|
|
447
|
+
req.on('end', () => {
|
|
448
|
+
try {
|
|
449
|
+
const { type, content } = JSON.parse(body || '{}');
|
|
450
|
+
if (type !== 'env' && type !== 'embed') throw new Error('type must be env or embed');
|
|
451
|
+
if (typeof content !== 'string') throw new Error('content must be a string');
|
|
452
|
+
mkdirSync(join(HOME, '.construct'), { recursive: true });
|
|
453
|
+
const target = type === 'env' ? CONFIG_ENV_FILE : EMBED_YAML_FILE;
|
|
454
|
+
writeFileSync(target, content, 'utf8');
|
|
455
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
456
|
+
res.end(JSON.stringify({ success: true }));
|
|
457
|
+
} catch (err) {
|
|
458
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
459
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
res.writeHead(405);
|
|
466
|
+
res.end('Method Not Allowed');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Snapshots handler ─────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
function handleSnapshots(req, res) {
|
|
472
|
+
if (req.method !== 'GET') { res.writeHead(405); res.end('Method Not Allowed'); return; }
|
|
473
|
+
|
|
474
|
+
const snapshots = [];
|
|
475
|
+
if (existsSync(SNAPSHOTS_FILE)) {
|
|
476
|
+
const lines = readFileSync(SNAPSHOTS_FILE, 'utf8').split('\n').filter(Boolean);
|
|
477
|
+
// Return last 20 snapshots most-recent-first
|
|
478
|
+
for (const line of lines.slice(-20).reverse()) {
|
|
479
|
+
try { snapshots.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
484
|
+
res.end(JSON.stringify({ snapshots }));
|
|
485
|
+
}
|
|
112
486
|
const activeWatchers = [];
|
|
113
487
|
let watchRefreshTimer = null;
|
|
114
488
|
|
|
115
|
-
function notifyClients() {
|
|
489
|
+
function notifyClients(event) {
|
|
490
|
+
const payload = event ? `data: ${JSON.stringify(event)}\n\n` : 'data: refresh\n\n';
|
|
116
491
|
for (const res of sseClients) {
|
|
117
|
-
try { res.write(
|
|
492
|
+
try { res.write(payload); }
|
|
118
493
|
catch { sseClients.delete(res); }
|
|
119
494
|
}
|
|
120
495
|
}
|
|
@@ -169,7 +544,109 @@ function watchFiles() {
|
|
|
169
544
|
}
|
|
170
545
|
|
|
171
546
|
const server = createServer(async (req, res) => {
|
|
172
|
-
const url = new URL(req.url, `http
|
|
547
|
+
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
548
|
+
|
|
549
|
+
// ── Security middleware ────────────────────────────────────────────────
|
|
550
|
+
// Imported lazily so a missing module never breaks the server boot.
|
|
551
|
+
let applyCors, ensureCsrfCookie, verifyCsrf, csrfDefaultSkip, checkRateLimit, logger;
|
|
552
|
+
try {
|
|
553
|
+
({ applyCors } = await import('./cors.mjs'));
|
|
554
|
+
({ ensureCsrfCookie, verifyCsrf, defaultSkip: csrfDefaultSkip } = await import('./csrf.mjs'));
|
|
555
|
+
({ checkRateLimit } = await import('./rate-limit.mjs'));
|
|
556
|
+
({ logger } = await import('../logger.mjs'));
|
|
557
|
+
} catch { /* run without the middleware if any module is unavailable */ }
|
|
558
|
+
|
|
559
|
+
if (applyCors) {
|
|
560
|
+
if (applyCors(req, res, { env: process.env })) return;
|
|
561
|
+
}
|
|
562
|
+
if (ensureCsrfCookie && req.method === 'GET') {
|
|
563
|
+
ensureCsrfCookie(req, res);
|
|
564
|
+
}
|
|
565
|
+
const rateTier = url.pathname === '/api/chat/stream' || url.pathname === '/api/chat'
|
|
566
|
+
? 'chat'
|
|
567
|
+
: (req.method !== 'GET' && req.method !== 'HEAD' ? 'write' : 'read');
|
|
568
|
+
if (checkRateLimit) {
|
|
569
|
+
const limit = checkRateLimit(req, rateTier, { env: process.env });
|
|
570
|
+
if (!limit.allowed) {
|
|
571
|
+
res.writeHead(429, {
|
|
572
|
+
'Content-Type': 'application/json',
|
|
573
|
+
'Retry-After': Math.ceil(limit.retryAfterMs / 1000),
|
|
574
|
+
});
|
|
575
|
+
res.end(JSON.stringify({ error: 'rate_limited', retryAfterMs: limit.retryAfterMs }));
|
|
576
|
+
logger?.().warn('http.rate_limited', { route: url.pathname, tier: rateTier });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (verifyCsrf && !verifyCsrf(req, { skip: csrfDefaultSkip })) {
|
|
581
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
582
|
+
res.end(JSON.stringify({ error: 'csrf_token_missing_or_invalid' }));
|
|
583
|
+
logger?.().warn('http.csrf_blocked', { route: url.pathname });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Auth endpoints (always public) ──────────────────────────────────────
|
|
588
|
+
if (url.pathname === '/api/auth/status' && req.method === 'GET') {
|
|
589
|
+
const auth = getAuthConfig();
|
|
590
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
591
|
+
res.end(JSON.stringify({
|
|
592
|
+
configured: auth.tokenConfigured,
|
|
593
|
+
authenticated: isAuthenticated(req),
|
|
594
|
+
auth,
|
|
595
|
+
}));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
|
600
|
+
let body = '';
|
|
601
|
+
req.on('data', c => { body += c; });
|
|
602
|
+
req.on('end', () => {
|
|
603
|
+
try {
|
|
604
|
+
const { token } = JSON.parse(body || '{}');
|
|
605
|
+
if (!validateToken(token)) {
|
|
606
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
607
|
+
res.end(JSON.stringify({ error: 'Invalid token' }));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const sessionToken = createSession();
|
|
611
|
+
res.writeHead(200, {
|
|
612
|
+
'Content-Type': 'application/json',
|
|
613
|
+
'Set-Cookie': sessionCookieHeader(sessionToken),
|
|
614
|
+
});
|
|
615
|
+
res.end(JSON.stringify({ success: true }));
|
|
616
|
+
} catch {
|
|
617
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
618
|
+
res.end(JSON.stringify({ error: 'Bad request' }));
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
|
|
625
|
+
res.writeHead(200, {
|
|
626
|
+
'Content-Type': 'application/json',
|
|
627
|
+
'Set-Cookie': clearSessionCookieHeader(),
|
|
628
|
+
});
|
|
629
|
+
res.end(JSON.stringify({ success: true }));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Webhook ingestion (public — signature-verified per provider) ─────────
|
|
634
|
+
if (url.pathname.startsWith('/api/webhooks/') && req.method === 'POST') {
|
|
635
|
+
await getWebhookHandler()(req, res);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Slack slash commands (public — HMAC-verified) ────────────────────────
|
|
640
|
+
if (url.pathname === '/api/slack/commands' && req.method === 'POST') {
|
|
641
|
+
await getSlackCommandHandler()(req, res);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Auth gate for all /api/* routes ─────────────────────────────────────
|
|
646
|
+
if (url.pathname.startsWith('/api/') && !isAuthenticated(req)) {
|
|
647
|
+
rejectUnauthorized(res);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
173
650
|
|
|
174
651
|
if (url.pathname === '/events') {
|
|
175
652
|
res.writeHead(200, {
|
|
@@ -208,80 +685,1104 @@ const server = createServer(async (req, res) => {
|
|
|
208
685
|
return;
|
|
209
686
|
}
|
|
210
687
|
|
|
211
|
-
if (url.pathname === '/api/
|
|
212
|
-
|
|
688
|
+
if (url.pathname === '/api/doctor') {
|
|
689
|
+
try {
|
|
690
|
+
const { readState } = await import('../doctor/index.mjs');
|
|
691
|
+
const { recent } = await import('../doctor/audit.mjs');
|
|
692
|
+
const { getTotalDailySpend, getDailySpend, totalBudget, personaBudget, dayKey } = await import('../cost-ledger.mjs');
|
|
693
|
+
const { listOnboardedPersonas } = await import('../roles/manifest.mjs');
|
|
694
|
+
const { listPending } = await import('../roles/gateway.mjs');
|
|
695
|
+
const { existsSync: fsExists, readFileSync: fsRead } = await import('node:fs');
|
|
696
|
+
const { join: pathJoin } = await import('node:path');
|
|
697
|
+
const { homedir: osHome } = await import('node:os');
|
|
698
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
699
|
+
const watcher = url.searchParams.get('watcher') || undefined;
|
|
700
|
+
const personas = listOnboardedPersonas();
|
|
701
|
+
const costsByPersona = {};
|
|
702
|
+
for (const p of personas) {
|
|
703
|
+
const spend = getDailySpend({ personaId: p });
|
|
704
|
+
if (spend.invocations > 0 || spend.costUsd > 0) {
|
|
705
|
+
costsByPersona[p] = { spent: spend.costUsd, cap: personaBudget(p), invocations: spend.invocations };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const total = getTotalDailySpend();
|
|
709
|
+
const approvalPath = pathJoin(osHome(), '.cx', 'approval-pending.jsonl');
|
|
710
|
+
let approvals = [];
|
|
711
|
+
if (fsExists(approvalPath)) {
|
|
712
|
+
approvals = fsRead(approvalPath, 'utf8').split('\n').filter(Boolean)
|
|
713
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
714
|
+
.filter(Boolean)
|
|
715
|
+
.slice(-25).reverse();
|
|
716
|
+
}
|
|
717
|
+
const pendingRoleInvocations = listPending({ unresolved: true }).slice(-25).reverse();
|
|
718
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
719
|
+
res.end(JSON.stringify({
|
|
720
|
+
daemon: readState(),
|
|
721
|
+
audit: recent({ watcher, limit }),
|
|
722
|
+
cost: { dayKey: dayKey(), total: { spent: total.costUsd, cap: totalBudget(), invocations: total.invocations }, byPersona: costsByPersona },
|
|
723
|
+
approvals,
|
|
724
|
+
pendingRoleInvocations,
|
|
725
|
+
onboardedPersonas: personas,
|
|
726
|
+
}));
|
|
727
|
+
} catch (err) {
|
|
728
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
729
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
730
|
+
}
|
|
213
731
|
return;
|
|
214
732
|
}
|
|
215
733
|
|
|
216
|
-
if (req.method === '
|
|
734
|
+
if (url.pathname.startsWith('/api/overrides/') && req.method === 'GET') {
|
|
735
|
+
try {
|
|
736
|
+
const { describeOverrides, resolveOverride, readResolved, listBackups, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
|
|
737
|
+
const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
|
|
738
|
+
const category = segs[0];
|
|
739
|
+
if (!category || !SUPPORTED_CATEGORIES.includes(category)) {
|
|
740
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
741
|
+
res.end(JSON.stringify({ error: `unknown category: ${category}. Use ${SUPPORTED_CATEGORIES.join(', ')}` }));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (segs.length === 1) {
|
|
745
|
+
const root = ROOT_DIR;
|
|
746
|
+
const overrides = describeOverrides(root)[category] || [];
|
|
747
|
+
const cwdOverrides = describeOverrides(process.cwd())[category] || [];
|
|
748
|
+
let originals = [];
|
|
749
|
+
try {
|
|
750
|
+
const dir =
|
|
751
|
+
category === 'contracts' || category === 'role-manifests'
|
|
752
|
+
? join(root, 'agents')
|
|
753
|
+
: category === 'agents'
|
|
754
|
+
? join(root, 'agents', 'prompts')
|
|
755
|
+
: join(root, category);
|
|
756
|
+
if (existsSync(dir)) {
|
|
757
|
+
const walk = (rel = '') => {
|
|
758
|
+
const cur = join(dir, rel);
|
|
759
|
+
for (const name of readdirSync(cur)) {
|
|
760
|
+
const full = join(cur, name);
|
|
761
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
762
|
+
if (statSync(full).isDirectory()) { walk(relPath); continue; }
|
|
763
|
+
if (!name.endsWith('.md') && !name.endsWith('.json')) continue;
|
|
764
|
+
originals.push(relPath);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
walk();
|
|
768
|
+
}
|
|
769
|
+
} catch { /* best effort */ }
|
|
770
|
+
const items = originals.map((name) => ({
|
|
771
|
+
name,
|
|
772
|
+
hasOverride: overrides.includes(name) || cwdOverrides.includes(name),
|
|
773
|
+
source: overrides.includes(name) || cwdOverrides.includes(name) ? 'override' : 'original',
|
|
774
|
+
}));
|
|
775
|
+
for (const ov of [...overrides, ...cwdOverrides]) {
|
|
776
|
+
if (!originals.includes(ov)) items.push({ name: ov, hasOverride: true, source: 'override', custom: true });
|
|
777
|
+
}
|
|
778
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
779
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
780
|
+
res.end(JSON.stringify({ category, items }));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
|
|
784
|
+
const action = url.searchParams.get('action');
|
|
785
|
+
if (action === 'backups') {
|
|
786
|
+
const backups = listBackups(process.cwd(), category, name);
|
|
787
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
788
|
+
res.end(JSON.stringify({ category, name, backups: backups.map((b) => ({ filename: b.filename, mtimeMs: b.mtimeMs, size: b.size })) }));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const tryRoot = (root) => {
|
|
792
|
+
const r = resolveOverride(root, category, name);
|
|
793
|
+
if (r.path) return { ...r, content: readFileSync(r.path, 'utf8') };
|
|
794
|
+
return null;
|
|
795
|
+
};
|
|
796
|
+
const result = tryRoot(process.cwd()) || tryRoot(ROOT_DIR);
|
|
797
|
+
if (!result) {
|
|
798
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
799
|
+
res.end(JSON.stringify({ error: 'not found', category, name }));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
803
|
+
res.end(JSON.stringify({ category, name, source: result.source, content: result.content, path: result.path }));
|
|
804
|
+
} catch (err) {
|
|
805
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
806
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (url.pathname.startsWith('/api/overrides/') && (req.method === 'PUT' || req.method === 'POST')) {
|
|
217
812
|
let body = '';
|
|
218
|
-
req.on('data', chunk => { body += chunk; });
|
|
813
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
219
814
|
req.on('end', async () => {
|
|
220
815
|
try {
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
res.writeHead(
|
|
234
|
-
|
|
235
|
-
notifyClients();
|
|
236
|
-
} else if (url.pathname === '/api/headhunt/promote') {
|
|
237
|
-
if (!data.id) throw new Error('Missing overlay id');
|
|
238
|
-
const request = promoteHeadhunt(data.id, { cwd: process.cwd(), owner: data.owner || null });
|
|
239
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
240
|
-
res.end(JSON.stringify({ success: true, request }));
|
|
241
|
-
notifyClients();
|
|
242
|
-
} else if (url.pathname === '/api/headhunt/challenge') {
|
|
243
|
-
if (!data.id) throw new Error('Missing overlay id');
|
|
244
|
-
if (!data.status) throw new Error('Missing challenge status');
|
|
245
|
-
const request = updatePromotionChallenge(data.id, {
|
|
246
|
-
cwd: process.cwd(),
|
|
247
|
-
status: data.status,
|
|
248
|
-
note: data.note || null,
|
|
249
|
-
});
|
|
250
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
251
|
-
res.end(JSON.stringify({ success: true, request }));
|
|
252
|
-
notifyClients();
|
|
253
|
-
} else if (url.pathname === '/api/registry/mcp') {
|
|
254
|
-
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
255
|
-
if (!registry.mcpServers) registry.mcpServers = {};
|
|
256
|
-
const { action, id, server } = data;
|
|
257
|
-
if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid MCP server id');
|
|
258
|
-
if (action === 'delete') {
|
|
259
|
-
delete registry.mcpServers[id];
|
|
260
|
-
} else if (action === 'save' && server && typeof server === 'object') {
|
|
261
|
-
const entry = {};
|
|
262
|
-
if (server.type === 'url') {
|
|
263
|
-
if (!server.url) throw new Error('URL required for type=url');
|
|
264
|
-
entry.type = 'url';
|
|
265
|
-
entry.url = String(server.url);
|
|
266
|
-
if (server.headers && typeof server.headers === 'object') entry.headers = server.headers;
|
|
267
|
-
} else {
|
|
268
|
-
if (!server.command) throw new Error('command required');
|
|
269
|
-
entry.command = String(server.command);
|
|
270
|
-
entry.args = Array.isArray(server.args) ? server.args.map(String) : [];
|
|
271
|
-
}
|
|
272
|
-
if (server.description) entry.description = String(server.description);
|
|
273
|
-
registry.mcpServers[id] = entry;
|
|
274
|
-
} else {
|
|
275
|
-
throw new Error('action must be save or delete');
|
|
276
|
-
}
|
|
277
|
-
writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
|
|
816
|
+
const { applyEdit, restoreFromBackup, SUPPORTED_CATEGORIES } = await import('../overrides/resolver.mjs');
|
|
817
|
+
const segs = url.pathname.replace(/^\/api\/overrides\//, '').split('/');
|
|
818
|
+
const category = segs[0];
|
|
819
|
+
if (!SUPPORTED_CATEGORIES.includes(category)) {
|
|
820
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
821
|
+
res.end(JSON.stringify({ error: `unknown category: ${category}` }));
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const name = segs.slice(1).join('/').replace(/\.[a-zA-Z0-9]+$/, '');
|
|
825
|
+
const action = url.searchParams.get('action');
|
|
826
|
+
const payload = JSON.parse(body || '{}');
|
|
827
|
+
if (action === 'restore') {
|
|
828
|
+
if (!payload.backupFilename) { res.writeHead(400); res.end(JSON.stringify({ error: 'backupFilename required' })); return; }
|
|
829
|
+
const result = restoreFromBackup(process.cwd(), category, name, payload.backupFilename);
|
|
278
830
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
279
|
-
res.end(JSON.stringify({
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
831
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (typeof payload.content !== 'string') {
|
|
835
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
836
|
+
res.end(JSON.stringify({ error: 'content (string) required' }));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const result = applyEdit(process.cwd(), category, name, payload.content);
|
|
840
|
+
try {
|
|
841
|
+
const auditDir = join(process.cwd(), '.cx');
|
|
842
|
+
mkdirSync(auditDir, { recursive: true });
|
|
843
|
+
appendFileSync(join(auditDir, 'audit.jsonl'), JSON.stringify({
|
|
844
|
+
ts: new Date().toISOString(),
|
|
845
|
+
action: `override.${category}.${name}.write`,
|
|
846
|
+
backupPath: result.backupPath,
|
|
847
|
+
bytes: result.wrote,
|
|
848
|
+
}) + '\n');
|
|
849
|
+
} catch { /* audit best-effort */ }
|
|
850
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
851
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
852
|
+
} catch (err) {
|
|
853
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
854
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (url.pathname === '/api/project-config' && req.method === 'GET') {
|
|
861
|
+
try {
|
|
862
|
+
const { loadProjectConfig } = await import('../config/project-config.mjs');
|
|
863
|
+
const result = loadProjectConfig(process.cwd(), process.env);
|
|
864
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
865
|
+
res.end(JSON.stringify(result));
|
|
866
|
+
} catch (err) {
|
|
867
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
868
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
869
|
+
}
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (url.pathname === '/api/project-config' && (req.method === 'PUT' || req.method === 'PATCH')) {
|
|
874
|
+
let body = '';
|
|
875
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
876
|
+
req.on('end', async () => {
|
|
877
|
+
try {
|
|
878
|
+
const payload = JSON.parse(body || '{}');
|
|
879
|
+
const { writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
|
|
880
|
+
const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
881
|
+
writeProjectConfig(cfgPath, payload);
|
|
882
|
+
try {
|
|
883
|
+
const auditDir = join(process.cwd(), '.cx');
|
|
884
|
+
mkdirSync(auditDir, { recursive: true });
|
|
885
|
+
const entry = JSON.stringify({
|
|
886
|
+
ts: new Date().toISOString(),
|
|
887
|
+
action: 'project-config.write',
|
|
888
|
+
path: cfgPath,
|
|
889
|
+
keys: Object.keys(payload || {}),
|
|
890
|
+
}) + '\n';
|
|
891
|
+
writeFileSync(join(auditDir, 'audit.jsonl'), entry, { flag: 'a' });
|
|
892
|
+
} catch { /* audit is best-effort */ }
|
|
893
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
894
|
+
res.end(JSON.stringify({ ok: true, path: cfgPath }));
|
|
895
|
+
} catch (err) {
|
|
896
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
897
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (url.pathname === '/api/performance/reviews' && req.method === 'GET') {
|
|
904
|
+
try {
|
|
905
|
+
const dir = join(HOME, '.cx', 'performance-reviews');
|
|
906
|
+
if (!existsSync(dir)) {
|
|
907
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
908
|
+
res.end(JSON.stringify({ reviews: [], total: 0, mockFiltered: 0, generatorImplemented: false }));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const allFiles = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
912
|
+
// Filter out test/mock fixtures — anything matching `test-*-mock.json` or
|
|
913
|
+
// `*-mock.json` is a seeded fixture, not a real session-end review.
|
|
914
|
+
// The real generator isn't wired yet; until it is, this endpoint should
|
|
915
|
+
// return an honest empty list rather than parade fake data.
|
|
916
|
+
const realFiles = allFiles.filter((f) => !/(^test-|-mock\.json$)/i.test(f));
|
|
917
|
+
const files = realFiles
|
|
918
|
+
.map((f) => {
|
|
919
|
+
try {
|
|
920
|
+
const full = join(dir, f);
|
|
921
|
+
const stat = statSync(full);
|
|
922
|
+
const body = JSON.parse(readFileSync(full, 'utf8'));
|
|
923
|
+
return { filename: f, mtimeMs: stat.mtimeMs, ...body };
|
|
924
|
+
} catch { return null; }
|
|
925
|
+
})
|
|
926
|
+
.filter(Boolean)
|
|
927
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
928
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
929
|
+
res.end(JSON.stringify({
|
|
930
|
+
reviews: files,
|
|
931
|
+
total: files.length,
|
|
932
|
+
mockFiltered: allFiles.length - realFiles.length,
|
|
933
|
+
generatorImplemented: true,
|
|
934
|
+
}));
|
|
935
|
+
} catch (err) {
|
|
936
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
937
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
938
|
+
}
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (url.pathname === '/api/performance/feedback' && req.method === 'POST') {
|
|
943
|
+
let body = '';
|
|
944
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
945
|
+
req.on('end', () => {
|
|
946
|
+
try {
|
|
947
|
+
const payload = JSON.parse(body || '{}');
|
|
948
|
+
if (!payload.agent || !payload.text) {
|
|
949
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
950
|
+
res.end(JSON.stringify({ error: 'agent + text required' }));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const dir = join(process.cwd(), '.cx', 'feedback');
|
|
954
|
+
mkdirSync(dir, { recursive: true });
|
|
955
|
+
const ts = new Date().toISOString();
|
|
956
|
+
const filename = `${ts.replace(/[:.]/g, '-')}-${payload.agent}.json`;
|
|
957
|
+
writeFileSync(join(dir, filename), JSON.stringify({
|
|
958
|
+
ts,
|
|
959
|
+
agent: payload.agent,
|
|
960
|
+
text: payload.text,
|
|
961
|
+
proposedChanges: payload.proposedChanges || null,
|
|
962
|
+
}, null, 2));
|
|
963
|
+
appendFileSync(join(process.cwd(), '.cx', 'audit.jsonl'), JSON.stringify({
|
|
964
|
+
ts,
|
|
965
|
+
action: 'performance.feedback.submitted',
|
|
966
|
+
agent: payload.agent,
|
|
967
|
+
filename,
|
|
968
|
+
}) + '\n');
|
|
969
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
970
|
+
res.end(JSON.stringify({ ok: true, filename }));
|
|
971
|
+
} catch (err) {
|
|
972
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
973
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (url.pathname === '/api/audit' && req.method === 'GET') {
|
|
980
|
+
try {
|
|
981
|
+
const auditPath = join(process.cwd(), '.cx', 'audit.jsonl');
|
|
982
|
+
if (!existsSync(auditPath)) {
|
|
983
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
984
|
+
res.end(JSON.stringify({ entries: [], total: 0 }));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const limit = Math.min(500, Number(url.searchParams.get('limit')) || 200);
|
|
988
|
+
const raw = readFileSync(auditPath, 'utf8');
|
|
989
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
990
|
+
const entries = [];
|
|
991
|
+
for (let i = Math.max(0, lines.length - limit); i < lines.length; i++) {
|
|
992
|
+
try { entries.push(JSON.parse(lines[i])); } catch { /* skip malformed */ }
|
|
993
|
+
}
|
|
994
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
995
|
+
res.end(JSON.stringify({ entries: entries.reverse(), total: lines.length }));
|
|
996
|
+
} catch (err) {
|
|
997
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
998
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
999
|
+
}
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (url.pathname === '/api/insights' && req.method === 'GET') {
|
|
1004
|
+
try {
|
|
1005
|
+
const { buildInsights } = await import('./insights.mjs');
|
|
1006
|
+
const data = await buildInsights({ env: process.env, cwd: process.cwd(), rootDir: ROOT_DIR });
|
|
1007
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1008
|
+
res.end(JSON.stringify(data));
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1011
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (url.pathname === '/api/alias' && req.method === 'GET') {
|
|
1017
|
+
try {
|
|
1018
|
+
const { resolveAlias } = await import('../config/alias.mjs');
|
|
1019
|
+
const r = resolveAlias({ cwd: process.cwd(), env: process.env });
|
|
1020
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1021
|
+
res.end(JSON.stringify(r));
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1024
|
+
res.end(JSON.stringify({ error: err.message, value: 'Construct', source: 'default' }));
|
|
1025
|
+
}
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (url.pathname === '/api/mode' && req.method === 'GET') {
|
|
1030
|
+
try {
|
|
1031
|
+
const env = loadConstructEnv();
|
|
1032
|
+
const embedYamlPath = join(HOME, '.construct', 'embed.yaml');
|
|
1033
|
+
const embedStatus = resolveEmbedStatus(env);
|
|
1034
|
+
|
|
1035
|
+
// Determine mode based on embed status and configuration
|
|
1036
|
+
let mode = 'init';
|
|
1037
|
+
if (embedStatus.level === 'running') {
|
|
1038
|
+
mode = 'embed';
|
|
1039
|
+
} else if (existsSync(embedYamlPath)) {
|
|
1040
|
+
mode = 'live';
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Get instance ID if set
|
|
1044
|
+
const instanceId = env.CONSTRUCT_INSTANCE_ID || null;
|
|
1045
|
+
|
|
1046
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1047
|
+
res.end(JSON.stringify({
|
|
1048
|
+
mode,
|
|
1049
|
+
instanceId,
|
|
1050
|
+
embedStatus: embedStatus.level,
|
|
1051
|
+
embedYamlExists: existsSync(embedYamlPath),
|
|
1052
|
+
embedYamlPath
|
|
1053
|
+
}));
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1056
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1057
|
+
}
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
|
|
1062
|
+
try {
|
|
1063
|
+
const env = loadConstructEnv();
|
|
1064
|
+
const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
|
|
1065
|
+
|
|
1066
|
+
// Check if we're running inside another Construct instance
|
|
1067
|
+
const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
|
|
1068
|
+
const parentUrl = env.CONSTRUCT_PARENT_URL || null;
|
|
1069
|
+
|
|
1070
|
+
// Determine embedding boundary status
|
|
1071
|
+
const isEmbedded = !!parentConstruct;
|
|
1072
|
+
const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
|
|
1073
|
+
|
|
1074
|
+
// Get current embed status
|
|
1075
|
+
const embedStatus = resolveEmbedStatus(env);
|
|
1076
|
+
|
|
1077
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1078
|
+
res.end(JSON.stringify({
|
|
1079
|
+
boundaryStatus,
|
|
1080
|
+
instanceId,
|
|
1081
|
+
parentConstruct,
|
|
1082
|
+
parentUrl,
|
|
1083
|
+
isEmbedded,
|
|
1084
|
+
embedStatus: embedStatus.level,
|
|
1085
|
+
embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
|
|
1086
|
+
// Boundary capabilities that could be exposed to parent
|
|
1087
|
+
capabilities: {
|
|
1088
|
+
modeDetection: true,
|
|
1089
|
+
snapshotStatus: true,
|
|
1090
|
+
approvalQueue: true,
|
|
1091
|
+
configManagement: true
|
|
1092
|
+
}
|
|
1093
|
+
}));
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1096
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1097
|
+
}
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (url.pathname === '/api/embed/boundary' && req.method === 'GET') {
|
|
1102
|
+
try {
|
|
1103
|
+
const env = loadConstructEnv();
|
|
1104
|
+
const instanceId = env.CONSTRUCT_INSTANCE_ID || 'default';
|
|
1105
|
+
|
|
1106
|
+
// Check if we're running inside another Construct instance
|
|
1107
|
+
const parentConstruct = env.CONSTRUCT_PARENT_INSTANCE || null;
|
|
1108
|
+
const parentUrl = env.CONSTRUCT_PARENT_URL || null;
|
|
1109
|
+
|
|
1110
|
+
// Determine embedding boundary status
|
|
1111
|
+
const isEmbedded = !!parentConstruct;
|
|
1112
|
+
const boundaryStatus = isEmbedded ? 'embedded' : 'standalone';
|
|
1113
|
+
|
|
1114
|
+
// Get current embed status
|
|
1115
|
+
const embedStatus = resolveEmbedStatus(env);
|
|
1116
|
+
|
|
1117
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1118
|
+
res.end(JSON.stringify({
|
|
1119
|
+
boundaryStatus,
|
|
1120
|
+
instanceId,
|
|
1121
|
+
parentConstruct,
|
|
1122
|
+
parentUrl,
|
|
1123
|
+
isEmbedded,
|
|
1124
|
+
embedStatus: embedStatus.level,
|
|
1125
|
+
embedConfigExists: existsSync(join(HOME, '.construct', 'embed.yaml')),
|
|
1126
|
+
// Boundary capabilities that could be exposed to parent
|
|
1127
|
+
capabilities: {
|
|
1128
|
+
modeDetection: true,
|
|
1129
|
+
snapshotStatus: true,
|
|
1130
|
+
approvalQueue: true,
|
|
1131
|
+
configManagement: true
|
|
1132
|
+
}
|
|
1133
|
+
}));
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1136
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1137
|
+
}
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (url.pathname === '/api/embed/boundary/register' && req.method === 'POST') {
|
|
1142
|
+
try {
|
|
1143
|
+
const chunks = [];
|
|
1144
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1145
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
1146
|
+
|
|
1147
|
+
const { parentInstance, parentUrl, childInstanceId } = body;
|
|
1148
|
+
|
|
1149
|
+
if (!parentInstance || !parentUrl) {
|
|
1150
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1151
|
+
res.end(JSON.stringify({ error: 'parentInstance and parentUrl are required' }));
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// In a real implementation, this would:
|
|
1156
|
+
// - Validate the parent is a legitimate Construct instance
|
|
1157
|
+
// - Store the parent registration in a boundary configuration
|
|
1158
|
+
// - Set up communication channels between parent and child
|
|
1159
|
+
// - Configure isolation boundaries
|
|
1160
|
+
|
|
1161
|
+
// For now, just acknowledge the registration
|
|
1162
|
+
const configDir = join(HOME, '.construct');
|
|
1163
|
+
if (!existsSync(configDir)) {
|
|
1164
|
+
mkdirSync(configDir, { recursive: true });
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const boundaryConfig = {
|
|
1168
|
+
parentInstance,
|
|
1169
|
+
parentUrl,
|
|
1170
|
+
childInstanceId: childInstanceId || env.CONSTRUCT_INSTANCE_ID || 'default',
|
|
1171
|
+
registeredAt: new Date().toISOString(),
|
|
1172
|
+
boundaryVersion: '1.0'
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
const boundaryConfigPath = join(configDir, 'boundary-config.json');
|
|
1176
|
+
writeFileSync(boundaryConfigPath, JSON.stringify(boundaryConfig, null, 2));
|
|
1177
|
+
|
|
1178
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1179
|
+
res.end(JSON.stringify({
|
|
1180
|
+
success: true,
|
|
1181
|
+
message: 'Boundary registration accepted',
|
|
1182
|
+
boundaryConfig,
|
|
1183
|
+
boundaryConfigPath
|
|
1184
|
+
}));
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1187
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1188
|
+
}
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (url.pathname === '/api/embed/status' && req.method === 'GET') {
|
|
1193
|
+
try {
|
|
1194
|
+
const env = loadConstructEnv();
|
|
1195
|
+
const status = resolveEmbedStatus(env);
|
|
1196
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1197
|
+
res.end(JSON.stringify(status));
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1200
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1201
|
+
}
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (url.pathname === '/api/models/providers' && req.method === 'GET') {
|
|
1206
|
+
try {
|
|
1207
|
+
const { getProviderModelCatalog } = await import('../model-router.mjs');
|
|
1208
|
+
const catalog = getProviderModelCatalog({ env: process.env });
|
|
1209
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1210
|
+
res.end(JSON.stringify(catalog));
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1213
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1214
|
+
}
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (url.pathname === '/api/models/pricing' && req.method === 'GET') {
|
|
1219
|
+
try {
|
|
1220
|
+
const idsParam = url.searchParams.get('ids') || '';
|
|
1221
|
+
const ids = idsParam.split(',').map((s) => s.trim()).filter(Boolean);
|
|
1222
|
+
const { getPricingForModels } = await import('../model-pricing.mjs');
|
|
1223
|
+
const pricing = await getPricingForModels(ids);
|
|
1224
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1225
|
+
res.end(JSON.stringify({ pricing }));
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1228
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1229
|
+
}
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (url.pathname === '/api/providers/credentials/custom' && req.method === 'GET') {
|
|
1234
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1235
|
+
res.end(JSON.stringify({ providers: readCustomCredentials(), path: CUSTOM_CREDENTIALS_FILE }));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (url.pathname === '/api/providers/billing' && req.method === 'GET') {
|
|
1240
|
+
try {
|
|
1241
|
+
const { loadProjectConfig } = await import('../config/project-config.mjs');
|
|
1242
|
+
const cfg = loadProjectConfig(process.cwd(), process.env);
|
|
1243
|
+
const costsCfg = cfg?.config?.costs ?? {};
|
|
1244
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1245
|
+
res.end(JSON.stringify({
|
|
1246
|
+
global: costsCfg.billingMode ?? 'metered',
|
|
1247
|
+
providers: costsCfg.providers || {},
|
|
1248
|
+
}));
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1251
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1252
|
+
}
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (url.pathname === '/api/providers/credentials/op-status' && req.method === 'GET') {
|
|
1257
|
+
// Tells the dashboard whether the 1Password CLI is installed and an account
|
|
1258
|
+
// is signed in. No vault content is read here; this is just the availability
|
|
1259
|
+
// probe. The pull endpoint below is the only thing that touches secret data.
|
|
1260
|
+
try {
|
|
1261
|
+
const which = spawnSync('which', ['op'], { encoding: 'utf8' });
|
|
1262
|
+
if (which.status !== 0) {
|
|
1263
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1264
|
+
res.end(JSON.stringify({ available: false, signedIn: false, version: null, accounts: [] }));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const versionProc = spawnSync('op', ['--version'], { encoding: 'utf8', timeout: 2000 });
|
|
1268
|
+
const version = versionProc.status === 0 ? versionProc.stdout.trim() : null;
|
|
1269
|
+
const accountsProc = spawnSync('op', ['account', 'list', '--format=json'], { encoding: 'utf8', timeout: 3000 });
|
|
1270
|
+
let accounts = [];
|
|
1271
|
+
if (accountsProc.status === 0) {
|
|
1272
|
+
try {
|
|
1273
|
+
const parsed = JSON.parse(accountsProc.stdout || '[]');
|
|
1274
|
+
accounts = (Array.isArray(parsed) ? parsed : []).map((a) => ({
|
|
1275
|
+
url: a.url || null,
|
|
1276
|
+
email: a.email || null,
|
|
1277
|
+
user_uuid: a.user_uuid || null,
|
|
1278
|
+
}));
|
|
1279
|
+
} catch { accounts = []; }
|
|
1280
|
+
}
|
|
1281
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1282
|
+
res.end(JSON.stringify({ available: true, signedIn: accounts.length > 0, version, accounts }));
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1285
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1286
|
+
}
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (url.pathname === '/api/fs/browse' && req.method === 'GET') {
|
|
1291
|
+
try {
|
|
1292
|
+
const { readdirSync, statSync } = await import('node:fs');
|
|
1293
|
+
const { resolve: pathResolve, dirname: pathDirname, join: pathJoin, sep: pathSep } = await import('node:path');
|
|
1294
|
+
const os = await import('node:os');
|
|
1295
|
+
const HOME_DIR = os.homedir();
|
|
1296
|
+
// Only roots the dashboard is allowed to expose. Loopback + same-user
|
|
1297
|
+
// already gates file access at the OS level, but cap the reach to the
|
|
1298
|
+
// user's HOME and the active project root anyway — surprise reads
|
|
1299
|
+
// outside those are almost certainly a typo.
|
|
1300
|
+
const allowedRoots = [HOME_DIR, ROOT_DIR];
|
|
1301
|
+
const rawPath = url.searchParams.get('path') || HOME_DIR;
|
|
1302
|
+
const target = pathResolve(rawPath.startsWith('~') ? pathJoin(HOME_DIR, rawPath.slice(1)) : rawPath);
|
|
1303
|
+
const inAllowed = allowedRoots.some((root) => target === root || target.startsWith(root + pathSep));
|
|
1304
|
+
if (!inAllowed) {
|
|
1305
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1306
|
+
res.end(JSON.stringify({ error: `path outside allowed roots (${allowedRoots.join(', ')})` }));
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
let entries = [];
|
|
1310
|
+
try {
|
|
1311
|
+
entries = readdirSync(target, { withFileTypes: true })
|
|
1312
|
+
.filter((e) => !e.name.startsWith('.'))
|
|
1313
|
+
.map((e) => ({
|
|
1314
|
+
name: e.name,
|
|
1315
|
+
type: e.isDirectory() ? 'dir' : e.isFile() ? 'file' : 'other',
|
|
1316
|
+
}))
|
|
1317
|
+
.sort((a, b) => {
|
|
1318
|
+
if (a.type === b.type) return a.name.localeCompare(b.name);
|
|
1319
|
+
return a.type === 'dir' ? -1 : 1;
|
|
1320
|
+
});
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1323
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const parent = pathDirname(target);
|
|
1327
|
+
const parentAllowed = allowedRoots.some((root) => parent === root || parent.startsWith(root + pathSep));
|
|
1328
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1329
|
+
res.end(JSON.stringify({
|
|
1330
|
+
path: target,
|
|
1331
|
+
parent: parentAllowed && parent !== target ? parent : null,
|
|
1332
|
+
roots: allowedRoots,
|
|
1333
|
+
entries,
|
|
1334
|
+
}));
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1337
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1338
|
+
}
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (url.pathname === '/api/intake/config' && req.method === 'GET') {
|
|
1343
|
+
try {
|
|
1344
|
+
const { loadIntakeConfig, INTAKE_DEPTH_GUIDANCE, INTAKE_HARD_MAX_DEPTH } = await import('../intake/intake-config.mjs');
|
|
1345
|
+
const config = loadIntakeConfig(ROOT_DIR, process.env);
|
|
1346
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1347
|
+
res.end(JSON.stringify({
|
|
1348
|
+
config,
|
|
1349
|
+
rootDir: ROOT_DIR,
|
|
1350
|
+
guidance: INTAKE_DEPTH_GUIDANCE,
|
|
1351
|
+
hardMaxDepth: INTAKE_HARD_MAX_DEPTH,
|
|
1352
|
+
defaults: { projectInbox: '.cx/inbox', docsIntake: 'docs/intake' },
|
|
1353
|
+
}));
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1356
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1357
|
+
}
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
|
1362
|
+
try {
|
|
1363
|
+
const probe = url.searchParams.get('probe') === '1';
|
|
1364
|
+
const { resolveProviders } = await import('../providers/registry.mjs');
|
|
1365
|
+
const { providers, sources, errors } = await resolveProviders({ rootDir: ROOT_DIR, env: process.env });
|
|
1366
|
+
const summary = await Promise.all(
|
|
1367
|
+
Object.entries(providers).map(async ([id, p]) => {
|
|
1368
|
+
const base = {
|
|
1369
|
+
id,
|
|
1370
|
+
displayName: p.meta.displayName,
|
|
1371
|
+
description: p.meta.description || null,
|
|
1372
|
+
capabilities: [...p.meta.capabilities],
|
|
1373
|
+
source: sources[id],
|
|
1374
|
+
configSchema: p.configSchema || null,
|
|
1375
|
+
};
|
|
1376
|
+
if (!probe) {
|
|
1377
|
+
return { ...base, health: null, status: 'unknown' };
|
|
1378
|
+
}
|
|
1379
|
+
let health = { ok: false, detail: 'health probe failed' };
|
|
1380
|
+
try { health = await p.health({}); } catch (err) { health = { ok: false, detail: err.message }; }
|
|
1381
|
+
const status = classifyProviderStatus(id, health.ok, process.env);
|
|
1382
|
+
// For not_configured, surface what's missing instead of the probe detail
|
|
1383
|
+
// (which may incorrectly read as healthy from an anonymous code path).
|
|
1384
|
+
const missing = status === 'not_configured' ? describeMissingCredentials(id, process.env) : null;
|
|
1385
|
+
const finalHealth = missing ? { ...health, ok: false, detail: missing } : health;
|
|
1386
|
+
return { ...base, health: finalHealth, status };
|
|
1387
|
+
})
|
|
1388
|
+
);
|
|
1389
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1390
|
+
res.end(JSON.stringify({ summary, errors }));
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1393
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1394
|
+
}
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (url.pathname === '/api/beads' && req.method === 'GET') {
|
|
1399
|
+
try {
|
|
1400
|
+
// Resolve to the main repo when running from a worktree — the bd
|
|
1401
|
+
// database lives next to the canonical .git/, not the worktree's
|
|
1402
|
+
// private .git/ stub. git rev-parse --git-common-dir returns the
|
|
1403
|
+
// shared .git for both main and worktrees; its parent is the
|
|
1404
|
+
// main repo root.
|
|
1405
|
+
let bdCwd = ROOT_DIR;
|
|
1406
|
+
try {
|
|
1407
|
+
const commonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
1408
|
+
cwd: ROOT_DIR,
|
|
1409
|
+
encoding: 'utf8',
|
|
1410
|
+
});
|
|
1411
|
+
if (commonDir.status === 0) {
|
|
1412
|
+
const trimmed = commonDir.stdout.trim();
|
|
1413
|
+
const resolved = trimmed.startsWith('/') ? trimmed : join(ROOT_DIR, trimmed);
|
|
1414
|
+
const candidate = dirname(resolved);
|
|
1415
|
+
if (existsSync(join(candidate, '.beads'))) bdCwd = candidate;
|
|
1416
|
+
}
|
|
1417
|
+
} catch { /* fall back to ROOT_DIR */ }
|
|
1418
|
+
const result = spawnSync('bd', ['list', '--status', 'all', '--json'], {
|
|
1419
|
+
cwd: bdCwd,
|
|
1420
|
+
encoding: 'utf8',
|
|
1421
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
1422
|
+
timeout: 5000,
|
|
1423
|
+
});
|
|
1424
|
+
if (result.status !== 0) {
|
|
1425
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1426
|
+
res.end(JSON.stringify({ error: `bd list failed: ${(result.stderr || result.stdout || '').slice(0, 300)}` }));
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
let raw = [];
|
|
1430
|
+
try { raw = JSON.parse(result.stdout); } catch (parseErr) {
|
|
1431
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1432
|
+
res.end(JSON.stringify({ error: `bd list JSON parse failed: ${parseErr.message}` }));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const issues = raw.map(obj => ({
|
|
1436
|
+
id: obj.id,
|
|
1437
|
+
title: obj.title,
|
|
1438
|
+
description: obj.description || null,
|
|
1439
|
+
status: obj.status,
|
|
1440
|
+
priority: obj.priority,
|
|
1441
|
+
issue_type: obj.issue_type || null,
|
|
1442
|
+
owner: obj.owner || null,
|
|
1443
|
+
created_at: obj.created_at,
|
|
1444
|
+
updated_at: obj.updated_at,
|
|
1445
|
+
dependency_count: obj.dependency_count || 0,
|
|
1446
|
+
dependent_count: obj.dependent_count || 0,
|
|
1447
|
+
comment_count: obj.comment_count || 0,
|
|
1448
|
+
labels: Array.isArray(obj.labels) ? obj.labels : [],
|
|
1449
|
+
}));
|
|
1450
|
+
const byStatus = {};
|
|
1451
|
+
const byPriority = {};
|
|
1452
|
+
for (const it of issues) {
|
|
1453
|
+
byStatus[it.status] = (byStatus[it.status] || 0) + 1;
|
|
1454
|
+
byPriority[`P${it.priority}`] = (byPriority[`P${it.priority}`] || 0) + 1;
|
|
1455
|
+
}
|
|
1456
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1457
|
+
res.end(JSON.stringify({ issues, counts: { total: issues.length, byStatus, byPriority } }));
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1460
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1461
|
+
}
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (url.pathname === '/api/providers/subscriptions' && req.method === 'GET') {
|
|
1466
|
+
try {
|
|
1467
|
+
const file = join(HOME, '.construct', 'provider-subscriptions.json');
|
|
1468
|
+
let data = { subscriptions: [] };
|
|
1469
|
+
if (existsSync(file)) {
|
|
1470
|
+
try { data = JSON.parse(readFileSync(file, 'utf8')); } catch { /* fall back to empty */ }
|
|
1471
|
+
if (!Array.isArray(data.subscriptions)) data.subscriptions = [];
|
|
1472
|
+
}
|
|
1473
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1474
|
+
res.end(JSON.stringify(data));
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1477
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1478
|
+
}
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (url.pathname === '/api/providers/credentials' && req.method === 'GET') {
|
|
1483
|
+
try {
|
|
1484
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1485
|
+
res.end(JSON.stringify({ credentials: buildCredentialsView(process.env) }));
|
|
1486
|
+
} catch (err) {
|
|
1487
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1488
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1489
|
+
}
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (url.pathname === '/api/providers/config-path' && req.method === 'GET') {
|
|
1494
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1495
|
+
res.end(JSON.stringify({
|
|
1496
|
+
envPath: CONFIG_ENV_FILE,
|
|
1497
|
+
overridesPath: join(HOME, '.construct', 'providers.json'),
|
|
1498
|
+
}));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
if (url.pathname === '/api/session-usage') {
|
|
1503
|
+
await handleSessionUsage(req, res);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (url.pathname === '/api/artifacts') {
|
|
1508
|
+
await handleArtifacts(req, res);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (url.pathname === '/api/approvals') {
|
|
1513
|
+
await handleApprovals(req, res);
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (url.pathname === '/api/snapshots') {
|
|
1518
|
+
handleSnapshots(req, res);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// ── Knowledge API ──────────────────────────────────────────────────────
|
|
1523
|
+
if (url.pathname === '/api/knowledge/trends') {
|
|
1524
|
+
const { buildTrendReport } = await import('../knowledge/trends.mjs');
|
|
1525
|
+
const report = buildTrendReport(ROOT_DIR);
|
|
1526
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1527
|
+
res.end(JSON.stringify(report));
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (url.pathname === '/api/knowledge/index') {
|
|
1532
|
+
const { buildCorpus } = await import('../knowledge/rag.mjs');
|
|
1533
|
+
const corpus = buildCorpus(ROOT_DIR);
|
|
1534
|
+
const sources = {};
|
|
1535
|
+
for (const c of corpus) sources[c.source] = (sources[c.source] || 0) + 1;
|
|
1536
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1537
|
+
res.end(JSON.stringify({ total: corpus.length, sources }));
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (url.pathname === '/api/knowledge/ask' && req.method === 'POST') {
|
|
1542
|
+
const chunks = [];
|
|
1543
|
+
await new Promise((resolve, reject) => {
|
|
1544
|
+
req.on('data', (c) => chunks.push(c));
|
|
1545
|
+
req.on('end', resolve);
|
|
1546
|
+
req.on('error', reject);
|
|
1547
|
+
});
|
|
1548
|
+
let body;
|
|
1549
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1550
|
+
const question = (body.question || '').trim();
|
|
1551
|
+
if (!question) {
|
|
1552
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1553
|
+
res.end(JSON.stringify({ error: 'question is required' }));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
const { ask } = await import('../knowledge/rag.mjs');
|
|
1557
|
+
const result = await ask(question, { rootDir: ROOT_DIR });
|
|
1558
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1559
|
+
res.end(JSON.stringify(result));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// ── Workflow API ──────────────────────────────────────────────────────────
|
|
1564
|
+
if (url.pathname === '/api/workflow' && req.method === 'GET') {
|
|
1565
|
+
try {
|
|
1566
|
+
const planMd = existsSync(WORKFLOW_FILE) ? readFileSync(WORKFLOW_FILE, 'utf8') : '';
|
|
1567
|
+
const { loadWorkflow } = await import('../workflow-state.mjs');
|
|
1568
|
+
const wf = loadWorkflow(ROOT_DIR);
|
|
1569
|
+
const tasks = wf?.tasks ?? [];
|
|
1570
|
+
const phase = wf?.phase ?? null;
|
|
1571
|
+
const phases = wf?.phases ?? {};
|
|
1572
|
+
const status = wf?.status ?? null;
|
|
1573
|
+
const summary = planMd
|
|
1574
|
+
? planMd.split('\n').slice(0, 30).filter(l => l.trim()).slice(0, 8).join('\n')
|
|
1575
|
+
: '';
|
|
1576
|
+
|
|
1577
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1578
|
+
res.end(JSON.stringify({
|
|
1579
|
+
hasPlan: Boolean(planMd),
|
|
1580
|
+
planSummary: summary,
|
|
1581
|
+
planPath: WORKFLOW_FILE,
|
|
1582
|
+
workflowState: wf ? { status, phase, phases, tasks, currentTaskKey: wf.currentTaskKey } : null,
|
|
1583
|
+
taskCount: tasks.length,
|
|
1584
|
+
taskStatusCounts: {
|
|
1585
|
+
todo: tasks.filter(t => t.status === 'todo' || !t.status).length,
|
|
1586
|
+
inProgress: tasks.filter(t => t.status === 'in-progress').length,
|
|
1587
|
+
blocked: tasks.filter(t => t.status?.startsWith('blocked')).length,
|
|
1588
|
+
done: tasks.filter(t => t.status === 'done').length,
|
|
1589
|
+
skipped: tasks.filter(t => t.status === 'skipped').length,
|
|
1590
|
+
},
|
|
1591
|
+
}));
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1594
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1595
|
+
}
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (url.pathname === '/api/config') {
|
|
1600
|
+
handleConfig(req, res);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ── Terraform API ─────────────────────────────────────────────────────────
|
|
1605
|
+
if (url.pathname === '/api/terraform/files' && req.method === 'GET') {
|
|
1606
|
+
try {
|
|
1607
|
+
const files = terraformFiles(TERRAFORM_DIR, TERRAFORM_DIR);
|
|
1608
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1609
|
+
res.end(JSON.stringify({ files, terraformDir: TERRAFORM_DIR }));
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1612
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1613
|
+
}
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (url.pathname === '/api/terraform/file') {
|
|
1618
|
+
if (req.method === 'GET') {
|
|
1619
|
+
const relPath = url.searchParams.get('path');
|
|
1620
|
+
if (!relPath) { res.writeHead(400); res.end(JSON.stringify({ error: 'path required' })); return; }
|
|
1621
|
+
try {
|
|
1622
|
+
const abs = assertTerraformPath(relPath);
|
|
1623
|
+
const content = readFileSync(abs, 'utf8');
|
|
1624
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1625
|
+
res.end(JSON.stringify({ path: relPath, content }));
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1628
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1629
|
+
}
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (req.method === 'POST') {
|
|
1634
|
+
const chunks = [];
|
|
1635
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1636
|
+
let body;
|
|
1637
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1638
|
+
try {
|
|
1639
|
+
const abs = assertTerraformPath(body.path || '');
|
|
1640
|
+
writeFileSync(abs, body.content ?? '', 'utf8');
|
|
1641
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1642
|
+
res.end(JSON.stringify({ success: true }));
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1645
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (url.pathname === '/api/terraform/run' && req.method === 'POST') {
|
|
1652
|
+
const chunks = [];
|
|
1653
|
+
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1654
|
+
let body;
|
|
1655
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = {}; }
|
|
1656
|
+
|
|
1657
|
+
const subcommand = body.subcommand; // 'plan' | 'apply'
|
|
1658
|
+
const environment = body.environment || 'staging'; // 'staging' | 'production'
|
|
1659
|
+
|
|
1660
|
+
if (!['plan', 'apply', 'validate', 'output'].includes(subcommand)) {
|
|
1661
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1662
|
+
res.end(JSON.stringify({ error: "subcommand must be 'plan', 'apply', 'validate', or 'output'" }));
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const envDir = join(TERRAFORM_DIR, 'environments', environment);
|
|
1667
|
+
if (!existsSync(envDir)) {
|
|
1668
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1669
|
+
res.end(JSON.stringify({ error: `Environment '${environment}' not found at ${envDir}` }));
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Stream output via SSE-style chunked response
|
|
1674
|
+
res.writeHead(200, {
|
|
1675
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
1676
|
+
'Transfer-Encoding': 'chunked',
|
|
1677
|
+
'Cache-Control': 'no-cache',
|
|
1678
|
+
'X-Accel-Buffering': 'no',
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
const args = subcommand === 'plan'
|
|
1682
|
+
? ['plan', `-chdir=${envDir}`, '-no-color']
|
|
1683
|
+
: subcommand === 'apply'
|
|
1684
|
+
? ['apply', `-chdir=${envDir}`, '-no-color', '-auto-approve']
|
|
1685
|
+
: subcommand === 'validate'
|
|
1686
|
+
? ['validate', `-chdir=${envDir}`, '-no-color']
|
|
1687
|
+
: ['output', `-chdir=${envDir}`, '-no-color'];
|
|
1688
|
+
|
|
1689
|
+
res.write(`\u001b[90m$ terraform ${args.join(' ')}\u001b[0m\n\n`);
|
|
1690
|
+
|
|
1691
|
+
const tf = spawn('terraform', args, {
|
|
1692
|
+
cwd: envDir,
|
|
1693
|
+
env: { ...process.env, TF_IN_AUTOMATION: '1' },
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
tf.stdout.on('data', chunk => { try { res.write(chunk); } catch {} });
|
|
1697
|
+
tf.stderr.on('data', chunk => { try { res.write(chunk); } catch {} });
|
|
1698
|
+
|
|
1699
|
+
tf.on('close', (code) => {
|
|
1700
|
+
try {
|
|
1701
|
+
res.write(`\n\u001b[${code === 0 ? '32' : '31'}m\nExit code: ${code}\u001b[0m\n`);
|
|
1702
|
+
res.end();
|
|
1703
|
+
} catch {}
|
|
1704
|
+
notifyClients();
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
tf.on('error', (err) => {
|
|
1708
|
+
try { res.write(`\n❌ Failed to run terraform: ${err.message}\n`); res.end(); } catch {}
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (url.pathname === '/api/chat/stream' && req.method === 'GET') {
|
|
1715
|
+
handleChatStream(req, res, { rootDir: ROOT_DIR });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (url.pathname === '/api/chat/history' && req.method === 'GET') {
|
|
1720
|
+
handleChatHistory(req, res);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
if (url.pathname === '/api/chat' && req.method === 'POST') {
|
|
1725
|
+
handleChat(req, res, { rootDir: ROOT_DIR });
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (req.method === 'POST') {
|
|
1730
|
+
let body = '';
|
|
1731
|
+
req.on('data', chunk => { body += chunk; });
|
|
1732
|
+
req.on('end', async () => {
|
|
1733
|
+
try {
|
|
1734
|
+
const data = JSON.parse(body || '{}');
|
|
1735
|
+
const { promoteHeadhunt, updatePromotionChallenge } = await import('../headhunt.mjs');
|
|
1736
|
+
|
|
1737
|
+
if (url.pathname === '/api/headhunt/promote') {
|
|
1738
|
+
if (!data.id) throw new Error('Missing overlay id');
|
|
1739
|
+
const request = promoteHeadhunt(data.id, { cwd: process.cwd(), owner: data.owner || null });
|
|
1740
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1741
|
+
res.end(JSON.stringify({ success: true, request }));
|
|
1742
|
+
notifyClients();
|
|
1743
|
+
} else if (url.pathname === '/api/headhunt/challenge') {
|
|
1744
|
+
if (!data.id) throw new Error('Missing overlay id');
|
|
1745
|
+
if (!data.status) throw new Error('Missing challenge status');
|
|
1746
|
+
const request = updatePromotionChallenge(data.id, {
|
|
1747
|
+
cwd: process.cwd(),
|
|
1748
|
+
status: data.status,
|
|
1749
|
+
note: data.note || null,
|
|
1750
|
+
});
|
|
1751
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1752
|
+
res.end(JSON.stringify({ success: true, request }));
|
|
1753
|
+
notifyClients();
|
|
1754
|
+
} else if (url.pathname === '/api/registry/mcp') {
|
|
1755
|
+
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
1756
|
+
if (!registry.mcpServers) registry.mcpServers = {};
|
|
1757
|
+
const { action, id, server } = data;
|
|
1758
|
+
if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid MCP server id');
|
|
1759
|
+
if (action === 'delete') {
|
|
1760
|
+
delete registry.mcpServers[id];
|
|
1761
|
+
} else if (action === 'save' && server && typeof server === 'object') {
|
|
1762
|
+
const entry = {};
|
|
1763
|
+
if (server.type === 'url') {
|
|
1764
|
+
if (!server.url) throw new Error('URL required for type=url');
|
|
1765
|
+
entry.type = 'url';
|
|
1766
|
+
entry.url = String(server.url);
|
|
1767
|
+
if (server.headers && typeof server.headers === 'object') entry.headers = server.headers;
|
|
1768
|
+
} else {
|
|
1769
|
+
if (!server.command) throw new Error('command required');
|
|
1770
|
+
entry.command = String(server.command);
|
|
1771
|
+
entry.args = Array.isArray(server.args) ? server.args.map(String) : [];
|
|
1772
|
+
}
|
|
1773
|
+
if (server.description) entry.description = String(server.description);
|
|
1774
|
+
registry.mcpServers[id] = entry;
|
|
1775
|
+
} else {
|
|
1776
|
+
throw new Error('action must be save or delete');
|
|
1777
|
+
}
|
|
1778
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
|
|
1779
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1780
|
+
res.end(JSON.stringify({ success: true }));
|
|
1781
|
+
notifyClients();
|
|
1782
|
+
} else if (url.pathname === '/api/registry/models') {
|
|
1783
|
+
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
1784
|
+
const { tier, primary, fallback } = data;
|
|
1785
|
+
const VALID_TIERS = ['reasoning', 'standard', 'fast'];
|
|
285
1786
|
if (!VALID_TIERS.includes(tier)) throw new Error('tier must be reasoning, standard, or fast');
|
|
286
1787
|
if (!primary || typeof primary !== 'string') throw new Error('primary model required');
|
|
287
1788
|
if (!registry.models) registry.models = {};
|
|
@@ -293,6 +1794,290 @@ const server = createServer(async (req, res) => {
|
|
|
293
1794
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
294
1795
|
res.end(JSON.stringify({ success: true }));
|
|
295
1796
|
notifyClients();
|
|
1797
|
+
} else if (url.pathname === '/api/providers/billing') {
|
|
1798
|
+
// Persists per-provider billing mode into construct.config.json under
|
|
1799
|
+
// costs.providers.<id>.billingMode. Validates ids against the known
|
|
1800
|
+
// CREDENTIAL_MAP (built-in + custom) so we don't accept arbitrary
|
|
1801
|
+
// strings into the schema.
|
|
1802
|
+
const { provider, billingMode } = data;
|
|
1803
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider id required');
|
|
1804
|
+
const validProviders = new Set([
|
|
1805
|
+
...credentialMap().map((c) => c.provider),
|
|
1806
|
+
'ollama', 'local', 'mistral', 'groq',
|
|
1807
|
+
]);
|
|
1808
|
+
if (!validProviders.has(provider)) {
|
|
1809
|
+
throw new Error(`unknown provider '${provider}'. Add a custom provider first or use one of: ${[...validProviders].join(', ')}`);
|
|
1810
|
+
}
|
|
1811
|
+
if (!['metered', 'subscription', 'mixed'].includes(billingMode)) {
|
|
1812
|
+
throw new Error("billingMode must be 'metered', 'subscription', or 'mixed'");
|
|
1813
|
+
}
|
|
1814
|
+
const { loadProjectConfig, writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } = await import('../config/project-config.mjs');
|
|
1815
|
+
const cfgPath = findProjectConfigPath(process.cwd()) || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
1816
|
+
const { config } = loadProjectConfig(process.cwd(), process.env);
|
|
1817
|
+
const next = {
|
|
1818
|
+
...config,
|
|
1819
|
+
costs: {
|
|
1820
|
+
...(config.costs || {}),
|
|
1821
|
+
providers: {
|
|
1822
|
+
...(config.costs?.providers || {}),
|
|
1823
|
+
[provider]: { billingMode },
|
|
1824
|
+
},
|
|
1825
|
+
},
|
|
1826
|
+
};
|
|
1827
|
+
writeProjectConfig(cfgPath, next);
|
|
1828
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1829
|
+
res.end(JSON.stringify({ success: true, providers: next.costs.providers }));
|
|
1830
|
+
notifyClients();
|
|
1831
|
+
} else if (url.pathname === '/api/providers/credentials/op-pull') {
|
|
1832
|
+
// Resolve a 1Password secret reference (op://vault/item/field) and
|
|
1833
|
+
// store the result into ~/.construct/config.env via the same write
|
|
1834
|
+
// path as the manual editor. The op:// URI never leaves the machine
|
|
1835
|
+
// and the resolved value flows secret server-side → config.env
|
|
1836
|
+
// (chmod 0600) → process.env, identical to a manual paste.
|
|
1837
|
+
const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
|
|
1838
|
+
if (isAuthConfigured()) {
|
|
1839
|
+
if (!isAuthenticated(req)) {
|
|
1840
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1841
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
} else if (!isLocal) {
|
|
1845
|
+
throw new Error('op-pull on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
|
|
1846
|
+
}
|
|
1847
|
+
const { envVar, opRef } = data;
|
|
1848
|
+
if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
|
|
1849
|
+
if (!allowedCredentialEnvVars().has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
|
|
1850
|
+
if (typeof opRef !== 'string' || !opRef.startsWith('op://')) {
|
|
1851
|
+
throw new Error("opRef must be a 1Password secret reference starting with 'op://'");
|
|
1852
|
+
}
|
|
1853
|
+
if (!/^op:\/\/[\w\s\-._]+\/[\w\s\-._]+\/[\w\s\-._]+$/.test(opRef)) {
|
|
1854
|
+
throw new Error("opRef must look like 'op://vault/item/field' (letters, digits, spaces, _-.)");
|
|
1855
|
+
}
|
|
1856
|
+
const opRead = spawnSync('op', ['read', '--no-newline', opRef], { encoding: 'utf8', timeout: 10000 });
|
|
1857
|
+
if (opRead.status !== 0) {
|
|
1858
|
+
throw new Error(`op read failed: ${(opRead.stderr || opRead.stdout || '').trim().slice(0, 240)}`);
|
|
1859
|
+
}
|
|
1860
|
+
const resolved = opRead.stdout;
|
|
1861
|
+
if (!resolved) throw new Error('op read returned an empty value');
|
|
1862
|
+
|
|
1863
|
+
const { writeEnvValues, loadConstructEnv } = await import('../env-config.mjs');
|
|
1864
|
+
mkdirSync(join(HOME, '.construct'), { recursive: true });
|
|
1865
|
+
writeEnvValues(CONFIG_ENV_FILE, { [envVar]: resolved });
|
|
1866
|
+
process.env[envVar] = resolved;
|
|
1867
|
+
try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms best-effort */ }
|
|
1868
|
+
|
|
1869
|
+
const allowed = allowedCredentialEnvVars();
|
|
1870
|
+
const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
|
|
1871
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
1872
|
+
if (allowed.has(k) && k !== envVar) process.env[k] = v;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
try {
|
|
1876
|
+
mkdirSync(join(HOME, '.cx'), { recursive: true });
|
|
1877
|
+
appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action: 'set', envVar, source: '1password', opRef }) + '\n');
|
|
1878
|
+
} catch { /* audit best-effort */ }
|
|
1879
|
+
|
|
1880
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1881
|
+
res.end(JSON.stringify({ success: true, envVar }));
|
|
1882
|
+
notifyClients();
|
|
1883
|
+
} else if (url.pathname === '/api/providers/credentials/custom') {
|
|
1884
|
+
const { action, provider, label, kind, envVars } = data;
|
|
1885
|
+
const cleanedId = typeof provider === 'string' ? provider.trim().toLowerCase() : '';
|
|
1886
|
+
if (!cleanedId || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(cleanedId)) {
|
|
1887
|
+
throw new Error('provider id required: lowercase letters, digits, hyphens (max 63 chars)');
|
|
1888
|
+
}
|
|
1889
|
+
if (BUILTIN_CREDENTIAL_MAP.some((b) => b.provider === cleanedId)) {
|
|
1890
|
+
throw new Error(`provider id '${cleanedId}' is built-in; pick a different id`);
|
|
1891
|
+
}
|
|
1892
|
+
const existing = readCustomCredentials();
|
|
1893
|
+
if (action === 'delete') {
|
|
1894
|
+
const next = existing.filter((e) => e.provider !== cleanedId);
|
|
1895
|
+
writeCustomCredentials(next);
|
|
1896
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1897
|
+
res.end(JSON.stringify({ success: true, providers: next }));
|
|
1898
|
+
notifyClients();
|
|
1899
|
+
} else if (action === 'save') {
|
|
1900
|
+
if (typeof label !== 'string' || !label.trim()) throw new Error('label required');
|
|
1901
|
+
if (kind !== 'llm' && kind !== 'integration') throw new Error("kind must be 'llm' or 'integration'");
|
|
1902
|
+
if (!Array.isArray(envVars) || envVars.length === 0) throw new Error('envVars must be a non-empty array');
|
|
1903
|
+
const normalizedVars = envVars.map((v) => String(v || '').trim().toUpperCase());
|
|
1904
|
+
for (const v of normalizedVars) {
|
|
1905
|
+
if (!ENV_VAR_PATTERN.test(v)) throw new Error(`envVar '${v}' must match /^[A-Z][A-Z0-9_]{1,63}$/`);
|
|
1906
|
+
if (ENV_VAR_BLOCKLIST.has(v)) throw new Error(`envVar '${v}' is reserved by the OS shell and not allowed`);
|
|
1907
|
+
if (allowedCredentialEnvVars().has(v)) {
|
|
1908
|
+
// Only block when the existing owner is a different provider.
|
|
1909
|
+
const owner = credentialMap().find((c) => c.envVars.includes(v));
|
|
1910
|
+
if (owner && owner.provider !== cleanedId) {
|
|
1911
|
+
throw new Error(`envVar '${v}' already declared by provider '${owner.provider}'`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const next = existing.filter((e) => e.provider !== cleanedId).concat([{
|
|
1916
|
+
provider: cleanedId,
|
|
1917
|
+
label: label.trim(),
|
|
1918
|
+
kind,
|
|
1919
|
+
envVars: normalizedVars,
|
|
1920
|
+
}]);
|
|
1921
|
+
writeCustomCredentials(next);
|
|
1922
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1923
|
+
res.end(JSON.stringify({ success: true, providers: next }));
|
|
1924
|
+
notifyClients();
|
|
1925
|
+
} else {
|
|
1926
|
+
throw new Error("action must be 'save' or 'delete'");
|
|
1927
|
+
}
|
|
1928
|
+
} else if (url.pathname === '/api/intake/config') {
|
|
1929
|
+
const { saveIntakeConfig } = await import('../intake/intake-config.mjs');
|
|
1930
|
+
const next = saveIntakeConfig(ROOT_DIR, {
|
|
1931
|
+
parentDirs: Array.isArray(data.parentDirs) ? data.parentDirs : undefined,
|
|
1932
|
+
maxDepth: data.maxDepth,
|
|
1933
|
+
includeProjectInbox: data.includeProjectInbox,
|
|
1934
|
+
includeDocsIntake: data.includeDocsIntake,
|
|
1935
|
+
});
|
|
1936
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1937
|
+
res.end(JSON.stringify({ success: true, config: next }));
|
|
1938
|
+
notifyClients();
|
|
1939
|
+
} else if (url.pathname === '/api/models/free/apply') {
|
|
1940
|
+
const { pollFreeModels, selectForTier } = await import('../model-free-selector.mjs');
|
|
1941
|
+
const { readOpenRouterApiKeyFromOpenCodeConfig } = await import('../model-router.mjs');
|
|
1942
|
+
const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
|
|
1943
|
+
if (!apiKey) {
|
|
1944
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1945
|
+
res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured. Set it under Providers → Credentials.' }));
|
|
1946
|
+
} else {
|
|
1947
|
+
const freeModels = await pollFreeModels(apiKey);
|
|
1948
|
+
if (!freeModels || freeModels.length === 0) {
|
|
1949
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1950
|
+
res.end(JSON.stringify({ error: 'OpenRouter returned no free models. Check the key and try again.' }));
|
|
1951
|
+
} else {
|
|
1952
|
+
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
1953
|
+
if (!registry.models) registry.models = {};
|
|
1954
|
+
const selections = {};
|
|
1955
|
+
for (const tier of ['reasoning', 'standard', 'fast']) {
|
|
1956
|
+
const fallback = registry.models?.[tier]?.fallback ?? [];
|
|
1957
|
+
const id = selectForTier(freeModels, tier, fallback);
|
|
1958
|
+
if (id) {
|
|
1959
|
+
selections[tier] = id;
|
|
1960
|
+
registry.models[tier] = { primary: id, fallback };
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2) + '\n');
|
|
1964
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1965
|
+
res.end(JSON.stringify({ success: true, selections, polledCount: freeModels.length }));
|
|
1966
|
+
notifyClients();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
} else if (url.pathname === '/api/providers/registry') {
|
|
1970
|
+
const { BUILT_INS } = await import('../providers/registry.mjs');
|
|
1971
|
+
const overridesPath = join(HOME, '.construct', 'providers.json');
|
|
1972
|
+
const { action, id, package: pkg, options } = data;
|
|
1973
|
+
if (!id || typeof id !== 'string' || !/^[\w-]+$/.test(id)) throw new Error('Invalid provider id');
|
|
1974
|
+
if (BUILT_INS.includes(id)) throw new Error(`cannot override built-in provider '${id}'`);
|
|
1975
|
+
|
|
1976
|
+
let current = { providers: [] };
|
|
1977
|
+
if (existsSync(overridesPath)) {
|
|
1978
|
+
try { current = JSON.parse(readFileSync(overridesPath, 'utf8')); } catch { current = { providers: [] }; }
|
|
1979
|
+
if (!Array.isArray(current.providers)) current.providers = [];
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
if (action === 'delete') {
|
|
1983
|
+
current.providers = current.providers.filter(p => p?.id !== id);
|
|
1984
|
+
} else if (action === 'save') {
|
|
1985
|
+
if (!pkg || typeof pkg !== 'string') throw new Error('package required');
|
|
1986
|
+
const { validateProviderEntry } = await import('../providers/registry.mjs');
|
|
1987
|
+
const verdict = await Promise.race([
|
|
1988
|
+
validateProviderEntry({ id, package: pkg, options: options || {} }, { rootDir: ROOT_DIR, env: process.env }),
|
|
1989
|
+
new Promise((resolve) => setTimeout(() => resolve({ ok: false, error: 'provider load timeout (5s)' }), 5000)),
|
|
1990
|
+
]);
|
|
1991
|
+
if (!verdict.ok) throw new Error(`provider '${id}' failed to load: ${verdict.error}`);
|
|
1992
|
+
current.providers = current.providers.filter(p => p?.id !== id).concat([{ id, package: pkg, options: options || {} }]);
|
|
1993
|
+
} else {
|
|
1994
|
+
throw new Error('action must be save or delete');
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
mkdirSync(join(HOME, '.construct'), { recursive: true });
|
|
1998
|
+
const tmp = overridesPath + '.tmp';
|
|
1999
|
+
writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
|
|
2000
|
+
renameSync(tmp, overridesPath);
|
|
2001
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2002
|
+
res.end(JSON.stringify({ success: true }));
|
|
2003
|
+
notifyClients();
|
|
2004
|
+
} else if (url.pathname === '/api/providers/credentials') {
|
|
2005
|
+
// 127.0.0.1 already requires same-user file access to write config.env,
|
|
2006
|
+
// so no extra security comes from forcing a token on localhost. On any
|
|
2007
|
+
// non-loopback bind, require the dashboard token to gate writes.
|
|
2008
|
+
const isLocal = BIND_HOST === '127.0.0.1' || BIND_HOST === 'localhost' || BIND_HOST === '::1';
|
|
2009
|
+
if (isAuthConfigured()) {
|
|
2010
|
+
if (!isAuthenticated(req)) {
|
|
2011
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
2012
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
} else if (!isLocal) {
|
|
2016
|
+
throw new Error('credential management on non-localhost binds requires CONSTRUCT_DASHBOARD_TOKEN to be set');
|
|
2017
|
+
}
|
|
2018
|
+
const { envVar, value } = data;
|
|
2019
|
+
if (typeof envVar !== 'string' || !envVar) throw new Error('envVar required');
|
|
2020
|
+
const allowed = allowedCredentialEnvVars();
|
|
2021
|
+
if (!allowed.has(envVar)) throw new Error(`envVar '${envVar}' is not in the credential allowlist`);
|
|
2022
|
+
if (value !== '' && typeof value !== 'string') throw new Error('value must be a string');
|
|
2023
|
+
|
|
2024
|
+
const { writeEnvValues, parseEnvFile, loadConstructEnv } = await import('../env-config.mjs');
|
|
2025
|
+
mkdirSync(join(HOME, '.construct'), { recursive: true });
|
|
2026
|
+
const action = value === '' ? 'unset' : 'set';
|
|
2027
|
+
|
|
2028
|
+
if (action === 'unset') {
|
|
2029
|
+
// writeEnvValues drops empty values, so passing '' deletes the key.
|
|
2030
|
+
writeEnvValues(CONFIG_ENV_FILE, { ...parseEnvFile(CONFIG_ENV_FILE), [envVar]: '' });
|
|
2031
|
+
delete process.env[envVar];
|
|
2032
|
+
} else {
|
|
2033
|
+
writeEnvValues(CONFIG_ENV_FILE, { [envVar]: value });
|
|
2034
|
+
process.env[envVar] = value;
|
|
2035
|
+
}
|
|
2036
|
+
try { chmodSync(CONFIG_ENV_FILE, 0o600); } catch { /* perms set best-effort */ }
|
|
2037
|
+
|
|
2038
|
+
// Re-overlay so any non-targeted keys edited out-of-band also refresh.
|
|
2039
|
+
const merged = loadConstructEnv({ rootDir: ROOT_DIR, warn: false });
|
|
2040
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
2041
|
+
if (allowed.has(k) && k !== envVar) process.env[k] = v;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
try {
|
|
2045
|
+
mkdirSync(join(HOME, '.cx'), { recursive: true });
|
|
2046
|
+
appendFileSync(CREDENTIAL_AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), action, envVar }) + '\n');
|
|
2047
|
+
} catch { /* audit append best-effort */ }
|
|
2048
|
+
|
|
2049
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2050
|
+
res.end(JSON.stringify({ success: true }));
|
|
2051
|
+
notifyClients();
|
|
2052
|
+
} else if (url.pathname === '/api/providers/subscriptions') {
|
|
2053
|
+
const subsPath = join(HOME, '.construct', 'provider-subscriptions.json');
|
|
2054
|
+
const { action, id, provider, name, config } = data;
|
|
2055
|
+
if (!id || typeof id !== 'string' || !/^[\w.-]+$/.test(id)) throw new Error('Invalid subscription id');
|
|
2056
|
+
|
|
2057
|
+
let current = { subscriptions: [] };
|
|
2058
|
+
if (existsSync(subsPath)) {
|
|
2059
|
+
try { current = JSON.parse(readFileSync(subsPath, 'utf8')); } catch { current = { subscriptions: [] }; }
|
|
2060
|
+
if (!Array.isArray(current.subscriptions)) current.subscriptions = [];
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
if (action === 'delete') {
|
|
2064
|
+
current.subscriptions = current.subscriptions.filter(s => s?.id !== id);
|
|
2065
|
+
} else if (action === 'save') {
|
|
2066
|
+
if (!provider || typeof provider !== 'string') throw new Error('provider required');
|
|
2067
|
+
if (config && typeof config !== 'object') throw new Error('config must be an object');
|
|
2068
|
+
const entry = { id, provider, name: name || id, config: config || {} };
|
|
2069
|
+
current.subscriptions = current.subscriptions.filter(s => s?.id !== id).concat([entry]);
|
|
2070
|
+
} else {
|
|
2071
|
+
throw new Error('action must be save or delete');
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
mkdirSync(join(HOME, '.construct'), { recursive: true });
|
|
2075
|
+
const tmp = subsPath + '.tmp';
|
|
2076
|
+
writeFileSync(tmp, JSON.stringify(current, null, 2) + '\n');
|
|
2077
|
+
renameSync(tmp, subsPath);
|
|
2078
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2079
|
+
res.end(JSON.stringify({ success: true }));
|
|
2080
|
+
notifyClients();
|
|
296
2081
|
} else {
|
|
297
2082
|
res.writeHead(404);
|
|
298
2083
|
res.end('Not found');
|
|
@@ -329,6 +2114,70 @@ const server = createServer(async (req, res) => {
|
|
|
329
2114
|
|
|
330
2115
|
watchFiles();
|
|
331
2116
|
|
|
332
|
-
|
|
333
|
-
|
|
2117
|
+
// ── Embed scheduler ──────────────────────────────────────────────────────────
|
|
2118
|
+
// When CX_AUTO_EMBED=1, run `construct sync` at startup and on a fixed interval
|
|
2119
|
+
// to keep the knowledge base current without manual intervention.
|
|
2120
|
+
// Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
|
|
2121
|
+
function runEmbedSync() {
|
|
2122
|
+
const syncScript = join(ROOT_DIR, 'scripts', 'sync-agents.mjs');
|
|
2123
|
+
if (!existsSync(syncScript)) return;
|
|
2124
|
+
try {
|
|
2125
|
+
spawnSync(process.execPath, [syncScript], {
|
|
2126
|
+
cwd: ROOT_DIR,
|
|
2127
|
+
stdio: 'ignore',
|
|
2128
|
+
timeout: 120_000,
|
|
2129
|
+
env: { ...process.env },
|
|
2130
|
+
});
|
|
2131
|
+
} catch {
|
|
2132
|
+
// non-fatal — next interval will retry
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Load user env config into process.env so dashboard API endpoints that read
|
|
2137
|
+
// from process.env (telemetry credentials, model provider keys, etc.) find them
|
|
2138
|
+
// even when the server is started directly (not through bin/construct).
|
|
2139
|
+
try {
|
|
2140
|
+
const { loadConstructEnv, getUserEnvPath } = await import('../env-config.mjs');
|
|
2141
|
+
const homeDir = HOME || homedir();
|
|
2142
|
+
const envPath = getUserEnvPath(homeDir);
|
|
2143
|
+
if (existsSync(envPath)) {
|
|
2144
|
+
const ENV = loadConstructEnv({ rootDir: ROOT_DIR, homeDir, env: process.env });
|
|
2145
|
+
for (const [key, value] of Object.entries(ENV)) {
|
|
2146
|
+
if (!(key in process.env)) process.env[key] = value;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
} catch { /* non-fatal — env loading is best-effort */ }
|
|
2150
|
+
|
|
2151
|
+
if (process.env.CX_AUTO_EMBED === '1') {
|
|
2152
|
+
const intervalMs = parseInt(process.env.CX_EMBED_INTERVAL_MS ?? '', 10) || 30 * 60 * 1000;
|
|
2153
|
+
runEmbedSync();
|
|
2154
|
+
setInterval(runEmbedSync, intervalMs).unref();
|
|
2155
|
+
console.log(`Embed scheduler active — syncing every ${intervalMs / 60_000} min`);
|
|
2156
|
+
}
|
|
2157
|
+
server.listen(PORT, BIND_HOST, () => {
|
|
2158
|
+
console.log(`Construct dashboard running at http://${BIND_HOST}:${PORT}`);
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
// SIGTERM/SIGINT close the server cleanly so the process exits 0 instead of
|
|
2162
|
+
// 143 — keeps shell tooling and supervisors from flagging normal shutdowns
|
|
2163
|
+
// as failures.
|
|
2164
|
+
|
|
2165
|
+
let shuttingDown = false;
|
|
2166
|
+
function shutdown(signal) {
|
|
2167
|
+
if (shuttingDown) return;
|
|
2168
|
+
shuttingDown = true;
|
|
2169
|
+
console.log(`Received ${signal}, shutting down dashboard...`);
|
|
2170
|
+
closeWatchers();
|
|
2171
|
+
for (const client of sseClients) {
|
|
2172
|
+
try { client.end(); } catch { /* ignore */ }
|
|
2173
|
+
}
|
|
2174
|
+
server.close(() => process.exit(0));
|
|
2175
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
2176
|
+
}
|
|
2177
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
2178
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
2179
|
+
|
|
2180
|
+
// ── Subscribe to embed notification bus → SSE toast events ──────────────────
|
|
2181
|
+
onEmbedNotification((event) => {
|
|
2182
|
+
notifyClients({ type: 'toast', ...event });
|
|
334
2183
|
});
|