@geraldmaron/construct 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -19
- package/bin/construct +487 -87
- package/bin/construct-postinstall.mjs +30 -2
- package/examples/distribution/README.md +42 -0
- package/examples/distribution/manifest.json +53 -0
- package/examples/distribution/sources/adr.md +84 -0
- package/examples/distribution/sources/deck-one-pager.md +65 -0
- package/examples/distribution/sources/prd-platform.md +161 -0
- package/examples/distribution/sources/research-brief.md +80 -0
- package/examples/distribution/sources/rfc-platform.md +82 -0
- package/examples/distribution/sources/runbook.md +103 -0
- package/examples/distribution/sources/strategy.md +88 -0
- package/lib/adapters-sync.mjs +67 -0
- package/lib/artifact-gate-notice.mjs +38 -0
- package/lib/artifact-manifest.mjs +258 -0
- package/lib/artifact-release-gate.mjs +171 -0
- package/lib/artifact-reviewers.mjs +68 -0
- package/lib/artifact-type-from-path.mjs +79 -0
- package/lib/artifact-workflow.mjs +252 -0
- package/lib/audit-skills.mjs +4 -0
- package/lib/audit-specialists.mjs +285 -0
- package/lib/audit-trail.mjs +1 -1
- package/lib/auto-docs.mjs +90 -19
- package/lib/beads/drift.mjs +27 -6
- package/lib/beads-client.mjs +49 -121
- package/lib/beads-optimistic.mjs +5 -12
- package/lib/brand-fonts.mjs +93 -0
- package/lib/brand-prose.mjs +214 -0
- package/lib/brand-tokens.mjs +92 -0
- package/lib/bridges/copilot-proxy.mjs +13 -26
- package/lib/capability-ledger.mjs +156 -0
- package/lib/certification/artifact-fixtures.mjs +132 -0
- package/lib/certification/artifact-gates.mjs +63 -0
- package/lib/certification/artifact-provenance.mjs +97 -0
- package/lib/certification/canonical-scenarios.mjs +78 -0
- package/lib/certification/cli.mjs +191 -0
- package/lib/certification/dashboard-api.mjs +71 -0
- package/lib/certification/demo-parity.mjs +116 -0
- package/lib/certification/document-io-fixtures.mjs +246 -0
- package/lib/certification/document-workflow.mjs +97 -0
- package/lib/certification/eval-bridge.mjs +77 -0
- package/lib/certification/model-routing.mjs +149 -0
- package/lib/certification/prompt-budget.mjs +119 -0
- package/lib/certification/rc-gate.mjs +206 -0
- package/lib/certification/real-llm-scenarios.mjs +303 -0
- package/lib/certification/role-cards.mjs +113 -0
- package/lib/certification/role-overlays.mjs +117 -0
- package/lib/certification/run.mjs +122 -0
- package/lib/certification/runner.mjs +323 -0
- package/lib/certification/scenarios.mjs +51 -0
- package/lib/certification/skill-inventory.mjs +289 -0
- package/lib/certification/skill-scenarios.mjs +147 -0
- package/lib/certification/specialist-contracts.mjs +85 -0
- package/lib/certification/specialist-scenarios.mjs +175 -0
- package/lib/certification/stale-impact.mjs +146 -0
- package/lib/certification/status.mjs +252 -0
- package/lib/certification/store.mjs +77 -0
- package/lib/chat/cli.mjs +333 -0
- package/lib/chat/command-suggest.mjs +161 -0
- package/lib/chat/commands.mjs +215 -0
- package/lib/chat/config.mjs +142 -0
- package/lib/chat/context-compactor.mjs +250 -0
- package/lib/chat/context-continuation.mjs +253 -0
- package/lib/chat/continuation-source.mjs +58 -0
- package/lib/chat/demo-guide.mjs +61 -0
- package/lib/chat/design-tokens.mjs +91 -0
- package/lib/chat/desktop-binary.mjs +79 -0
- package/lib/chat/desktop-build.mjs +130 -0
- package/lib/chat/desktop-launcher.mjs +133 -0
- package/lib/chat/evidence.mjs +145 -0
- package/lib/chat/export.mjs +74 -0
- package/lib/chat/harness/driver.mjs +91 -0
- package/lib/chat/list-picker.mjs +112 -0
- package/lib/chat/model-picker.mjs +356 -0
- package/lib/chat/openrouter-fallback.mjs +151 -0
- package/lib/chat/permission-prompt.mjs +33 -0
- package/lib/chat/picker-catalog.mjs +45 -0
- package/lib/chat/policy-telemetry.mjs +34 -0
- package/lib/chat/present.mjs +246 -0
- package/lib/chat/session-context.mjs +39 -0
- package/lib/chat/session-persist.mjs +73 -0
- package/lib/chat/session-restore.mjs +71 -0
- package/lib/chat/session-settings.mjs +53 -0
- package/lib/chat/system-prompt.mjs +52 -0
- package/lib/chat/transparency.mjs +93 -0
- package/lib/chat/tui/color-scheme.mjs +42 -0
- package/lib/chat/tui/markdown.mjs +123 -0
- package/lib/chat/tui/presentation.mjs +100 -0
- package/lib/chat/tui/render.mjs +500 -0
- package/lib/chat/tui/turn-block.mjs +284 -0
- package/lib/chat/tui/turn-present.mjs +18 -0
- package/lib/chat/tui/turn-state.mjs +88 -0
- package/lib/chat/tui/usage.mjs +122 -0
- package/lib/chat/web-commands.mjs +146 -0
- package/lib/chat/web-launcher.mjs +63 -0
- package/lib/chat/web-picker-keys.mjs +46 -0
- package/lib/chat/web-session.mjs +159 -0
- package/lib/cli-commands.mjs +146 -15
- package/lib/cli-service-inventory.mjs +42 -0
- package/lib/comment-lint.mjs +6 -4
- package/lib/config/intake-policy.mjs +209 -0
- package/lib/config/project-config.mjs +11 -2
- package/lib/config/schema.mjs +86 -6
- package/lib/config/source-targets.mjs +311 -0
- package/lib/contract-schemas/decision.json +50 -0
- package/lib/contract-schemas/implementation.json +51 -0
- package/lib/contract-schemas/review-report.json +32 -0
- package/lib/contract-schemas/test-report.json +43 -0
- package/lib/contracts/construct-handoff.mjs +60 -0
- package/lib/contracts/validate.mjs +32 -3
- package/lib/contracts/violation-log.mjs +58 -5
- package/lib/dashboard-demo.mjs +71 -0
- package/lib/dashboard-static.mjs +19 -5
- package/lib/decisions/registry.mjs +5 -3
- package/lib/deck-export-pptx.mjs +1152 -0
- package/lib/demo-recording.mjs +142 -0
- package/lib/demo-script.mjs +114 -0
- package/lib/demo-surface.mjs +249 -0
- package/lib/demo.mjs +598 -140
- package/lib/diagram-export.mjs +192 -0
- package/lib/diagram.mjs +13 -11
- package/lib/docs-verify.mjs +25 -7
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/project-adapters.mjs +44 -0
- package/lib/doctor/source-checkout.mjs +16 -0
- package/lib/doctor/watchers/cx-budget.mjs +98 -0
- package/lib/doctor/watchers/graph-staleness.mjs +52 -0
- package/lib/doctor/watchers/process-pressure.mjs +4 -3
- package/lib/document-export.mjs +384 -38
- package/lib/document-extract.mjs +44 -4
- package/lib/document-ingest.mjs +29 -20
- package/lib/embed/auto-sources.mjs +44 -0
- package/lib/embed/cli.mjs +4 -3
- package/lib/embed/daemon.mjs +18 -46
- package/lib/embed/demand-fetch.mjs +50 -19
- package/lib/embed/inbox.mjs +6 -10
- package/lib/embed/worker.mjs +4 -3
- package/lib/embedded-contract/index.mjs +11 -0
- package/lib/embedded-contract/model-resolve.mjs +10 -9
- package/lib/env-config.mjs +26 -0
- package/lib/evals/dataset.mjs +137 -0
- package/lib/evals/gates.mjs +175 -0
- package/lib/export-branding.mjs +32 -0
- package/lib/graph/build-co-change.mjs +54 -0
- package/lib/graph/build-from-registry.mjs +136 -0
- package/lib/graph/build-import-graph.mjs +153 -0
- package/lib/graph/cli.mjs +110 -0
- package/lib/graph/impact-cli.mjs +89 -0
- package/lib/graph/impact.mjs +108 -0
- package/lib/graph/staleness.mjs +44 -0
- package/lib/graph/store.mjs +172 -0
- package/lib/health-check.mjs +90 -76
- package/lib/hooks/artifact-release-gate.mjs +43 -0
- package/lib/hooks/brand-prose-lint.mjs +38 -0
- package/lib/hooks/graph-impact-advisory.mjs +62 -0
- package/lib/hooks/session-optimize.mjs +84 -207
- package/lib/hooks/session-start.mjs +18 -16
- package/lib/host-disposition.mjs +7 -0
- package/lib/improvement/cli.mjs +189 -0
- package/lib/improvement/controller.mjs +137 -0
- package/lib/improvement/proposal.mjs +120 -0
- package/lib/improvement/specialist-loop.mjs +192 -0
- package/lib/improvement/store.mjs +89 -0
- package/lib/improvement/surface.mjs +219 -0
- package/lib/ingest-tooling.mjs +97 -0
- package/lib/init/doc-lanes.mjs +165 -0
- package/lib/init-docs.mjs +31 -178
- package/lib/init-unified.mjs +50 -119
- package/lib/init-update-guide.mjs +102 -0
- package/lib/init-update.mjs +32 -1
- package/lib/init.mjs +8 -0
- package/lib/install/desktop-binary-download.mjs +85 -0
- package/lib/install/legacy-global-cleanup.mjs +189 -0
- package/lib/intake/constants.mjs +29 -0
- package/lib/intake/daemon.mjs +2 -0
- package/lib/intake/git-queue.mjs +9 -8
- package/lib/intake/intake-config.mjs +52 -105
- package/lib/intake/legacy-paths.mjs +5 -0
- package/lib/intake/queue.mjs +2 -1
- package/lib/intake/session-prelude.mjs +90 -1
- package/lib/libreoffice-export.mjs +97 -0
- package/lib/logging/rotate.mjs +9 -0
- package/lib/maintenance/docker-reclaim.mjs +206 -0
- package/lib/mcp/external-schema-cost.mjs +74 -0
- package/lib/mcp/server.mjs +103 -10
- package/lib/mcp/stdio-mcp-probe.mjs +188 -0
- package/lib/mcp/tool-budget.mjs +55 -4
- package/lib/mcp/tools/document.mjs +18 -4
- package/lib/mcp/tools/embedded-contract.mjs +14 -0
- package/lib/mcp/tools/skills.mjs +32 -3
- package/lib/mcp/tools/workflow.mjs +13 -1
- package/lib/model-free-selector.mjs +18 -0
- package/lib/model-registry.mjs +13 -229
- package/lib/model-router.mjs +373 -92
- package/lib/models/behavior-matrix.mjs +289 -0
- package/lib/models/catalog.mjs +209 -0
- package/lib/models/execution-capability-profile.mjs +196 -0
- package/lib/models/execution-policy.mjs +307 -0
- package/lib/models/provider-poll.mjs +383 -0
- package/lib/npm-spawn-env.mjs +17 -0
- package/lib/ollama/installed-models.mjs +129 -0
- package/lib/oracle/actions.mjs +187 -0
- package/lib/oracle/artifact-gate.mjs +99 -0
- package/lib/oracle/cli.mjs +204 -0
- package/lib/oracle/daemon-entry.mjs +14 -0
- package/lib/oracle/dispatch.mjs +81 -0
- package/lib/oracle/execute.mjs +143 -0
- package/lib/oracle/gaps.mjs +76 -0
- package/lib/oracle/index.mjs +84 -0
- package/lib/oracle/issues.mjs +164 -0
- package/lib/oracle/org-graph.mjs +170 -0
- package/lib/oracle/policy.mjs +80 -0
- package/lib/oracle/read-model.mjs +398 -0
- package/lib/oracle/reconcile.mjs +191 -0
- package/lib/oracle/routing.mjs +89 -0
- package/lib/oracle/synthesize.mjs +384 -0
- package/lib/oracle/verdicts.mjs +51 -0
- package/lib/orchestration/worker.mjs +88 -27
- package/lib/orchestration-policy.mjs +50 -0
- package/lib/parity.mjs +96 -2
- package/lib/playwright-demo.mjs +254 -0
- package/lib/project-init-shared.mjs +4 -1
- package/lib/prompt-composer.js +7 -3
- package/lib/prompt-validation-contract.mjs +24 -0
- package/lib/provider-capabilities.js +57 -11
- package/lib/providers/contract/adapters/confluence/index.mjs +181 -0
- package/lib/providers/contract/adapters/git/index.mjs +115 -0
- package/lib/providers/contract/adapters/github/index.mjs +166 -0
- package/lib/providers/contract/adapters/jira/index.mjs +187 -0
- package/lib/providers/contract/adapters/slack/index.mjs +175 -0
- package/lib/providers/contract/contract-tests.mjs +57 -0
- package/lib/providers/contract/errors.mjs +48 -0
- package/lib/providers/contract/interface.mjs +50 -0
- package/lib/providers/contract/registry.mjs +102 -0
- package/lib/providers/copilot-auth.mjs +297 -0
- package/lib/providers/credential-bootstrap.mjs +178 -0
- package/lib/providers/credential-catalog.mjs +46 -0
- package/lib/providers/credential-sources.mjs +63 -0
- package/lib/providers/creds.mjs +5 -2
- package/lib/providers/op-run.mjs +59 -0
- package/lib/providers/secret-resolver.mjs +159 -0
- package/lib/publish-template.mjs +163 -0
- package/lib/publish-tooling.mjs +119 -0
- package/lib/publish.mjs +305 -0
- package/lib/registry/cli.mjs +82 -0
- package/lib/registry/consolidation.mjs +147 -0
- package/lib/registry/generate-docs.mjs +75 -0
- package/lib/registry/skill-verification.mjs +57 -0
- package/lib/registry/surface-map.mjs +76 -0
- package/lib/registry/validate.mjs +135 -0
- package/lib/resources/budget.mjs +82 -0
- package/lib/resources/process-budget.mjs +45 -0
- package/lib/rules-delivery.mjs +9 -2
- package/lib/rules-read.mjs +26 -0
- package/lib/runtime-env.mjs +23 -0
- package/lib/runtime-pressure.mjs +51 -7
- package/lib/schema-infer.mjs +13 -2
- package/lib/server/chat-loop.mjs +622 -0
- package/lib/server/demo-preview.mjs +63 -0
- package/lib/server/index.mjs +259 -9
- package/lib/server/rate-limit.mjs +5 -3
- package/lib/server/webhook.mjs +3 -2
- package/lib/service-manager.mjs +60 -8
- package/lib/setup.mjs +70 -7
- package/lib/specialists/prompt-schema.mjs +19 -10
- package/lib/specialists/roster.mjs +43 -0
- package/lib/specialists/scaffold.mjs +56 -0
- package/lib/storage/backend.mjs +6 -6
- package/lib/storage/hybrid-query.mjs +7 -4
- package/lib/storage/state-source.mjs +5 -5
- package/lib/storage/sync.mjs +2 -3
- package/lib/telemetry/rule-calls.mjs +52 -0
- package/lib/template-registry.mjs +2 -2
- package/lib/templates/visual-requirements.mjs +27 -51
- package/lib/test-corpus-inventory.mjs +313 -0
- package/lib/uninstall/uninstall.mjs +19 -7
- package/lib/update.mjs +12 -0
- package/lib/upgrade.mjs +14 -0
- package/lib/wireframe.mjs +20 -14
- package/lib/worker/run.mjs +17 -4
- package/lib/worker/trace.mjs +11 -3
- package/package.json +28 -14
- package/personas/construct.md +2 -2
- package/platforms/claude/settings.template.json +36 -0
- package/rules/common/patterns.md +1 -1
- package/rules/common/release-gates.md +4 -3
- package/scripts/sync-specialists.mjs +41 -28
- package/skills/devops/data-engineering.md +1 -1
- package/skills/docs/adr-workflow.md +1 -0
- package/skills/docs/backlog-proposal-workflow.md +1 -0
- package/skills/docs/codebase-research-workflow.md +40 -0
- package/skills/docs/customer-profile-workflow.md +1 -0
- package/skills/docs/document-ingest-workflow.md +1 -0
- package/skills/docs/evidence-ingest-workflow.md +1 -0
- package/skills/docs/init-docs.md +2 -2
- package/skills/docs/init-project.md +7 -2
- package/skills/docs/prd-workflow.md +24 -1
- package/skills/docs/prfaq-workflow.md +1 -0
- package/skills/docs/product-intelligence-workflow.md +1 -0
- package/skills/docs/product-signal-workflow.md +1 -0
- package/skills/docs/research-workflow.md +54 -37
- package/skills/docs/runbook-workflow.md +1 -0
- package/skills/docs/strategy-workflow.md +1 -0
- package/skills/docs/user-research-workflow.md +40 -0
- package/skills/operating/orchestration-reference.md +1 -1
- package/skills/roles/architect.md +5 -0
- package/skills/roles/operator.docs.md +4 -0
- package/skills/routing.md +4 -2
- package/specialists/artifact-manifest.json +489 -0
- package/specialists/artifact-manifest.schema.json +83 -0
- package/specialists/audit-enrichments.json +454 -0
- package/specialists/contracts.json +37 -25
- package/specialists/prompts/_shared/validation-contract.md +26 -0
- package/specialists/prompts/cx-accessibility.md +21 -2
- package/specialists/prompts/cx-ai-engineer.md +24 -1
- package/specialists/prompts/cx-architect.md +4 -1
- package/specialists/prompts/cx-business-strategist.md +23 -2
- package/specialists/prompts/cx-data-analyst.md +22 -1
- package/specialists/prompts/cx-data-engineer.md +23 -2
- package/specialists/prompts/cx-debugger.md +21 -2
- package/specialists/prompts/cx-designer.md +26 -3
- package/specialists/prompts/cx-devil-advocate.md +19 -2
- package/specialists/prompts/cx-docs-keeper.md +28 -1
- package/specialists/prompts/cx-engineer.md +22 -1
- package/specialists/prompts/cx-evaluator.md +18 -1
- package/specialists/prompts/cx-explorer.md +21 -2
- package/specialists/prompts/cx-legal-compliance.md +21 -2
- package/specialists/prompts/cx-operations.md +21 -2
- package/specialists/prompts/cx-oracle.md +94 -0
- package/specialists/prompts/cx-orchestrator.md +20 -1
- package/specialists/prompts/cx-platform-engineer.md +25 -2
- package/specialists/prompts/cx-product-manager.md +32 -1
- package/specialists/prompts/cx-qa.md +24 -2
- package/specialists/prompts/cx-rd-lead.md +23 -2
- package/specialists/prompts/cx-release-manager.md +23 -2
- package/specialists/prompts/cx-researcher.md +22 -2
- package/specialists/prompts/cx-reviewer.md +18 -1
- package/specialists/prompts/cx-security.md +22 -2
- package/specialists/prompts/cx-sre.md +30 -3
- package/specialists/prompts/cx-test-automation.md +7 -1
- package/specialists/prompts/cx-trace-reviewer.md +22 -3
- package/specialists/prompts/cx-ux-researcher.md +21 -2
- package/specialists/registry.json +52 -173
- package/specialists/tone-profiles.json +42 -0
- package/templates/demos/playwright/demo-recording.config.mjs +47 -0
- package/templates/demos/recordings/agentic-platforms-prd.json +19 -0
- package/templates/demos/scripts/agentic-platforms-prd.json +44 -0
- package/templates/demos/specs/_helpers/scroll-artifact.ts +89 -0
- package/templates/demos/tapes/agentic-platforms-prd.tape +49 -0
- package/templates/demos/tapes/resource-guard-rails.tape +49 -0
- package/templates/demos/vhs/construct-cockpit.json +24 -0
- package/templates/distribution/construct-brand.typ +446 -0
- package/templates/distribution/construct-decision.typ +38 -0
- package/templates/distribution/construct-deck.html +95 -0
- package/templates/distribution/construct-pdf.typ +38 -0
- package/templates/distribution/construct-prd.typ +38 -0
- package/templates/distribution/construct-reference.docx +0 -0
- package/templates/distribution/construct-research.typ +38 -0
- package/templates/distribution/construct-web.html +92 -0
- package/templates/distribution/fonts/Geist-Bold.ttf +0 -0
- package/templates/distribution/fonts/Geist-Medium.ttf +0 -0
- package/templates/distribution/fonts/Geist-Regular.ttf +0 -0
- package/templates/distribution/fonts/Geist-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-Medium.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-Regular.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/IBMPlexMono-Regular.otf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-Medium.ttf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Bold.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Medium.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Regular.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/README.md +36 -0
- package/templates/distribution/fonts/SpaceGrotesk-Variable.ttf +0 -0
- package/templates/distribution/fonts/handwritten/Caveat.ttf +0 -0
- package/templates/distribution/fonts/legacy/IBMPlexMono-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-Medium.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-SemiBold.otf +0 -0
- package/templates/distribution/fonts/legacy/InterDisplay-SemiBold.otf +0 -0
- package/templates/distribution/fonts/legacy/SourceSerif4-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/SourceSerif4-Semibold.otf +0 -0
- package/templates/distribution/icons/activity.svg +15 -0
- package/templates/distribution/icons/alert-triangle.svg +17 -0
- package/templates/distribution/icons/book-open.svg +16 -0
- package/templates/distribution/icons/bot.svg +20 -0
- package/templates/distribution/icons/brain.svg +22 -0
- package/templates/distribution/icons/circle-check.svg +16 -0
- package/templates/distribution/icons/clipboard-check.svg +17 -0
- package/templates/distribution/icons/cpu.svg +28 -0
- package/templates/distribution/icons/database.svg +17 -0
- package/templates/distribution/icons/eye.svg +16 -0
- package/templates/distribution/icons/file-text.svg +19 -0
- package/templates/distribution/icons/gauge.svg +16 -0
- package/templates/distribution/icons/git-branch.svg +17 -0
- package/templates/distribution/icons/key.svg +17 -0
- package/templates/distribution/icons/layers.svg +17 -0
- package/templates/distribution/icons/list-checks.svg +19 -0
- package/templates/distribution/icons/lock.svg +16 -0
- package/templates/distribution/icons/message-square.svg +15 -0
- package/templates/distribution/icons/network.svg +19 -0
- package/templates/distribution/icons/route.svg +17 -0
- package/templates/distribution/icons/scale.svg +19 -0
- package/templates/distribution/icons/search.svg +16 -0
- package/templates/distribution/icons/send.svg +16 -0
- package/templates/distribution/icons/server.svg +18 -0
- package/templates/distribution/icons/shield-check.svg +16 -0
- package/templates/distribution/icons/users.svg +18 -0
- package/templates/distribution/icons/webhook.svg +17 -0
- package/templates/distribution/icons/workflow.svg +17 -0
- package/templates/distribution/icons/wrench.svg +15 -0
- package/templates/distribution/run.mjs +18 -1
- package/templates/docs/adr.md +7 -0
- package/templates/docs/construct_guide.md +8 -8
- package/templates/docs/customer-profile.md +4 -0
- package/templates/docs/one-pager.md +4 -0
- package/templates/docs/postmortem.md +31 -0
- package/templates/docs/prd-platform.md +19 -0
- package/templates/docs/prd.md +15 -0
- package/templates/docs/prfaq.md +7 -0
- package/templates/docs/qa-strategy.md +103 -0
- package/templates/docs/research-brief.md +8 -0
- package/templates/docs/rfc-platform.md +12 -0
- package/templates/docs/test-plan.md +7 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/copilot-auth.mjs — GitHub Copilot OAuth device flow + token exchange.
|
|
3
|
+
*
|
|
4
|
+
* Copilot is authenticated the way the editor plugins and community CLIs do it:
|
|
5
|
+
* an OAuth device flow against the public Copilot GitHub App
|
|
6
|
+
* (client Iv1.b507a08c87ecfe98), which mints a user-to-server access token
|
|
7
|
+
* (ghu_) plus a long-lived refresh token (ghr_). The access token is exchanged
|
|
8
|
+
* at copilot_internal/v2/token for a short-lived Copilot session token used as
|
|
9
|
+
* the bearer against api.githubcopilot.com. This deliberately replaces the
|
|
10
|
+
* `gh auth token` path: the GitHub CLI's OAuth app is not Copilot-entitled and
|
|
11
|
+
* its token is rejected by the exchange endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Credentials persist to two places: Construct's own store
|
|
14
|
+
* (~/.construct/auth/github-copilot.json) and the de-facto standard
|
|
15
|
+
* ~/.config/github-copilot/apps.json that other tools read and write, so a login
|
|
16
|
+
* here is reusable elsewhere and an existing login is reused here. The access
|
|
17
|
+
* token is refreshed from the refresh token when it expires; the session token
|
|
18
|
+
* is cached in-process until shortly before its own expiry. Network and clock
|
|
19
|
+
* are injectable so the flow is exercised end to end without a live account.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
|
|
26
|
+
const CLIENT_ID = 'Iv1.b507a08c87ecfe98';
|
|
27
|
+
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
|
28
|
+
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
29
|
+
const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token';
|
|
30
|
+
|
|
31
|
+
export const COPILOT_API_BASE = 'https://api.githubcopilot.com';
|
|
32
|
+
const SESSION_REFRESH_BUFFER_S = 300;
|
|
33
|
+
|
|
34
|
+
const EDITOR_HEADERS = {
|
|
35
|
+
'Editor-Version': 'vscode/1.90.0',
|
|
36
|
+
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
|
|
37
|
+
'Copilot-Integration-Id': 'vscode-chat',
|
|
38
|
+
'User-Agent': 'GitHubCopilotChat/0.26.7',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let sessionCache = null;
|
|
42
|
+
|
|
43
|
+
// Copilot tokens must be a single line — stray newlines from dotenv, JSON, or the
|
|
44
|
+
// exchange response trigger "invalid whitespace" from api.githubcopilot.com.
|
|
45
|
+
|
|
46
|
+
function normalizeToken(value) {
|
|
47
|
+
if (!value || typeof value !== 'string') return '';
|
|
48
|
+
return value.trim().replace(/\s+/g, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function homeDir() {
|
|
52
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function constructStorePath() {
|
|
56
|
+
return path.join(homeDir(), '.construct', 'auth', 'github-copilot.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appsStorePath() {
|
|
60
|
+
return path.join(homeDir(), '.config', 'github-copilot', 'apps.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hostsStorePath() {
|
|
64
|
+
return path.join(homeDir(), '.config', 'github-copilot', 'hosts.json');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJson(file) {
|
|
68
|
+
try {
|
|
69
|
+
if (!fs.existsSync(file)) return null;
|
|
70
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeJson(file, data, mode) {
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
78
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Read a stored OAuth credential from Construct's store first, then the shared
|
|
82
|
+
// github-copilot store (apps.json/hosts.json) that editor plugins maintain. The
|
|
83
|
+
// oauth_token field carries the ghu_ access token in the shared format.
|
|
84
|
+
|
|
85
|
+
export function loadStoredOAuth() {
|
|
86
|
+
const own = readJson(constructStorePath());
|
|
87
|
+
if (own && own.oauth_token) {
|
|
88
|
+
return {
|
|
89
|
+
oauthToken: normalizeToken(own.oauth_token),
|
|
90
|
+
refreshToken: normalizeToken(own.refresh_token) || null,
|
|
91
|
+
oauthExpiresAt: own.oauth_expires_at || null,
|
|
92
|
+
user: own.user || null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
for (const file of [appsStorePath(), hostsStorePath()]) {
|
|
96
|
+
const data = readJson(file);
|
|
97
|
+
if (!data) continue;
|
|
98
|
+
for (const entry of Object.values(data)) {
|
|
99
|
+
const token = normalizeToken(entry?.oauth_token || entry?.token);
|
|
100
|
+
if (token) return { oauthToken: token, refreshToken: null, oauthExpiresAt: null, user: entry?.user || null };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function hasStoredCredential() {
|
|
107
|
+
return loadStoredOAuth() != null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Persist to both stores so a login made here is reusable by other tools and a
|
|
111
|
+
// login made elsewhere is honored here. The shared store is keyed by host and
|
|
112
|
+
// app id, matching the format opencode/Neovim copilot read.
|
|
113
|
+
|
|
114
|
+
export function persistOAuth({ accessToken, refreshToken, expiresAt, user }) {
|
|
115
|
+
const cleanAccess = normalizeToken(accessToken);
|
|
116
|
+
const cleanRefresh = normalizeToken(refreshToken) || null;
|
|
117
|
+
const own = readJson(constructStorePath()) || {};
|
|
118
|
+
writeJson(constructStorePath(), {
|
|
119
|
+
...own,
|
|
120
|
+
type: 'oauth',
|
|
121
|
+
oauth_token: cleanAccess,
|
|
122
|
+
refresh_token: cleanRefresh || own.refresh_token || null,
|
|
123
|
+
oauth_expires_at: expiresAt || null,
|
|
124
|
+
user: user || own.user || null,
|
|
125
|
+
rotated_at: new Date().toISOString(),
|
|
126
|
+
}, 0o600);
|
|
127
|
+
|
|
128
|
+
const apps = readJson(appsStorePath()) || {};
|
|
129
|
+
apps[`github.com:${CLIENT_ID}`] = { user: user || apps[`github.com:${CLIENT_ID}`]?.user || 'unknown', oauth_token: cleanAccess, githubAppId: CLIENT_ID };
|
|
130
|
+
writeJson(appsStorePath(), apps, 0o600);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function postJson(url, body, fetchImpl) {
|
|
134
|
+
const res = await fetchImpl(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...EDITOR_HEADERS },
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
});
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
let json = null;
|
|
141
|
+
try { json = JSON.parse(text); } catch { /* non-JSON error body */ }
|
|
142
|
+
return { ok: res.ok, status: res.status, json, text };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Step 1 of the device flow: request a device + user code the operator enters at
|
|
146
|
+
// the verification URL.
|
|
147
|
+
|
|
148
|
+
export async function requestDeviceCode({ fetchImpl = fetch } = {}) {
|
|
149
|
+
const { ok, json, status, text } = await postJson(DEVICE_CODE_URL, { client_id: CLIENT_ID, scope: 'read:user' }, fetchImpl);
|
|
150
|
+
if (!ok || !json?.device_code) {
|
|
151
|
+
throw new Error(`Copilot device-code request failed (HTTP ${status}): ${(text || '').slice(0, 160)}`);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
deviceCode: json.device_code,
|
|
155
|
+
userCode: json.user_code,
|
|
156
|
+
verificationUri: json.verification_uri,
|
|
157
|
+
interval: json.interval || 5,
|
|
158
|
+
expiresIn: json.expires_in || 900,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
163
|
+
|
|
164
|
+
// Step 2: poll until the operator authorizes, then capture the access token, the
|
|
165
|
+
// refresh token, and the access-token expiry (when the app enables expiration).
|
|
166
|
+
|
|
167
|
+
export async function pollForAccessToken({ deviceCode, interval = 5, expiresIn = 900, fetchImpl = fetch, now = Date.now, onPending } = {}) {
|
|
168
|
+
const deadline = now() + expiresIn * 1000;
|
|
169
|
+
let waitMs = interval * 1000;
|
|
170
|
+
while (now() < deadline) {
|
|
171
|
+
const { json } = await postJson(ACCESS_TOKEN_URL, {
|
|
172
|
+
client_id: CLIENT_ID,
|
|
173
|
+
device_code: deviceCode,
|
|
174
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
175
|
+
}, fetchImpl);
|
|
176
|
+
if (json?.access_token) {
|
|
177
|
+
return {
|
|
178
|
+
accessToken: normalizeToken(json.access_token),
|
|
179
|
+
refreshToken: normalizeToken(json.refresh_token) || null,
|
|
180
|
+
expiresAt: json.expires_in ? Math.floor(now() / 1000) + json.expires_in : null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (json?.error === 'slow_down') waitMs += 5000;
|
|
184
|
+
else if (json?.error && json.error !== 'authorization_pending') {
|
|
185
|
+
throw new Error(`Copilot authorization failed: ${json.error_description || json.error}`);
|
|
186
|
+
}
|
|
187
|
+
if (typeof onPending === 'function') onPending();
|
|
188
|
+
await sleep(waitMs);
|
|
189
|
+
}
|
|
190
|
+
throw new Error('Copilot authorization timed out — the device code expired before approval.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function refreshAccessToken(refreshToken, { fetchImpl, now }) {
|
|
194
|
+
const { json } = await postJson(ACCESS_TOKEN_URL, {
|
|
195
|
+
client_id: CLIENT_ID,
|
|
196
|
+
grant_type: 'refresh_token',
|
|
197
|
+
refresh_token: refreshToken,
|
|
198
|
+
}, fetchImpl);
|
|
199
|
+
if (!json?.access_token) throw new Error('Copilot token refresh failed — re-run `construct creds login copilot`.');
|
|
200
|
+
return {
|
|
201
|
+
accessToken: normalizeToken(json.access_token),
|
|
202
|
+
refreshToken: normalizeToken(json.refresh_token) || refreshToken,
|
|
203
|
+
expiresAt: json.expires_in ? Math.floor(now() / 1000) + json.expires_in : null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function exchangeForSessionToken(accessToken, { fetchImpl }) {
|
|
208
|
+
const res = await fetchImpl(COPILOT_TOKEN_URL, {
|
|
209
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, ...EDITOR_HEADERS },
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
const body = await res.text().catch(() => '');
|
|
213
|
+
const err = new Error(`Copilot token exchange failed (HTTP ${res.status}). Your GitHub account may lack an active Copilot subscription, or the login expired — re-run \`construct creds login copilot\`. ${(body || '').slice(0, 160)}`);
|
|
214
|
+
err.code = 'COPILOT_EXCHANGE_FAILED';
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
return res.json();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Return a valid short-lived Copilot session token, doing the minimum work:
|
|
221
|
+
// reuse the in-process cache, else exchange the stored access token, first
|
|
222
|
+
// refreshing that token from the refresh token when it has expired.
|
|
223
|
+
|
|
224
|
+
export async function getCopilotToken({ fetchImpl = fetch, now = Date.now } = {}) {
|
|
225
|
+
const nowS = Math.floor(now() / 1000);
|
|
226
|
+
if (sessionCache && sessionCache.expiresAt > nowS + SESSION_REFRESH_BUFFER_S) {
|
|
227
|
+
return sessionCache.token;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const stored = loadStoredOAuth();
|
|
231
|
+
if (!stored?.oauthToken) {
|
|
232
|
+
const err = new Error('GitHub Copilot is not authenticated — run `construct creds login copilot`.');
|
|
233
|
+
err.code = 'COPILOT_NOT_AUTHENTICATED';
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let accessToken = stored.oauthToken;
|
|
238
|
+
if (stored.oauthExpiresAt && stored.oauthExpiresAt <= nowS && stored.refreshToken) {
|
|
239
|
+
const refreshed = await refreshAccessToken(stored.refreshToken, { fetchImpl, now });
|
|
240
|
+
accessToken = refreshed.accessToken;
|
|
241
|
+
persistOAuth({ accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt, user: stored.user });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const session = await exchangeForSessionToken(accessToken, { fetchImpl });
|
|
245
|
+
if (!session?.token) {
|
|
246
|
+
const err = new Error('Copilot token exchange returned no token.');
|
|
247
|
+
err.code = 'COPILOT_EXCHANGE_EMPTY';
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
const sessionToken = normalizeToken(session.token);
|
|
251
|
+
if (!sessionToken) {
|
|
252
|
+
const err = new Error('Copilot token exchange returned an empty token.');
|
|
253
|
+
err.code = 'COPILOT_EXCHANGE_EMPTY';
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
sessionCache = { token: sessionToken, expiresAt: session.expires_at || (nowS + 1500) };
|
|
257
|
+
return sessionCache.token;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Headers every api.githubcopilot.com call must carry. The integration id and
|
|
261
|
+
// editor version are what the endpoint checks to accept a session-scoped token.
|
|
262
|
+
|
|
263
|
+
export function copilotApiHeaders() {
|
|
264
|
+
return { ...EDITOR_HEADERS, 'X-Github-Api-Version': '2023-07-07' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The model ids the account can actually use. Lets callers validate a configured
|
|
268
|
+
// id (for example github-copilot/gpt-5.4) instead of assuming a name is valid.
|
|
269
|
+
|
|
270
|
+
export async function listCopilotModels({ fetchImpl = fetch } = {}) {
|
|
271
|
+
const token = await getCopilotToken({ fetchImpl });
|
|
272
|
+
const res = await fetchImpl(`${COPILOT_API_BASE}/models`, {
|
|
273
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...copilotApiHeaders() },
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) throw new Error(`Copilot models request failed (HTTP ${res.status}).`);
|
|
276
|
+
const data = await res.json();
|
|
277
|
+
const items = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []);
|
|
278
|
+
return items.map((m) => m?.id || m?.model).filter(Boolean);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function __resetCopilotCache() {
|
|
282
|
+
sessionCache = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Exchange probe used at chat launch — a configured Copilot pin is not enough if
|
|
286
|
+
// the session token cannot be minted (stale oauth, whitespace-corrupt store).
|
|
287
|
+
|
|
288
|
+
export async function preflightCopilotSession({ fetchImpl = fetch } = {}) {
|
|
289
|
+
__resetCopilotCache();
|
|
290
|
+
try {
|
|
291
|
+
await getCopilotToken({ fetchImpl });
|
|
292
|
+
return { ok: true };
|
|
293
|
+
} catch (err) {
|
|
294
|
+
__resetCopilotCache();
|
|
295
|
+
return { ok: false, message: err.message || String(err) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/credential-bootstrap.mjs — lazy credential linking for Construct.
|
|
3
|
+
*
|
|
4
|
+
* Before any LLM surface runs, missing API keys can be satisfied from existing
|
|
5
|
+
* stores or a one-time 1Password link into ~/.construct/config.env. Runs at
|
|
6
|
+
* the construct CLI entrypoint in presence-check mode; op item list runs only
|
|
7
|
+
* when autoLink:true (scripts/setup-credentials.mjs).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { hasAnySecret } from './secret-resolver.mjs';
|
|
14
|
+
import { API_KEY_CREDENTIALS, primaryEnvVar } from './credential-catalog.mjs';
|
|
15
|
+
import {
|
|
16
|
+
discoverAlternateRawForCredential,
|
|
17
|
+
discoverAlternateRawForVar,
|
|
18
|
+
readRawFromOpenCodeProvider,
|
|
19
|
+
readRawFromCredsStore,
|
|
20
|
+
openCodeConfigPath,
|
|
21
|
+
} from './credential-sources.mjs';
|
|
22
|
+
import { writeEnvValues, getUserEnvPath, ensureUserConfigDir } from '../env-config.mjs';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
discoverAlternateRawForVar,
|
|
26
|
+
readRawFromOpenCodeProvider,
|
|
27
|
+
readRawFromCredsStore,
|
|
28
|
+
openCodeConfigPath,
|
|
29
|
+
} from './credential-sources.mjs';
|
|
30
|
+
|
|
31
|
+
export function commandExists(command) {
|
|
32
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'sh', process.platform === 'win32'
|
|
33
|
+
? [command]
|
|
34
|
+
: ['-lc', `command -v ${command}`], { encoding: 'utf8', timeout: 3000 });
|
|
35
|
+
return r.status === 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultOpRun(args) {
|
|
39
|
+
return spawnSync('op', args, { encoding: 'utf8', timeout: 12_000 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listOpItems({ opRun = defaultOpRun } = {}) {
|
|
43
|
+
const result = opRun(['item', 'list', '--format', 'json']);
|
|
44
|
+
if (result.status !== 0 || !result.stdout) return [];
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(result.stdout);
|
|
47
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function titleMatches(item, titles = []) {
|
|
54
|
+
const title = String(item?.title || '').trim().toLowerCase();
|
|
55
|
+
return titles.some((candidate) => title === candidate || title.includes(candidate));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pickOpItem(items = [], { titles = [] } = {}) {
|
|
59
|
+
const normalized = items.filter((item) => item?.id && item?.vault?.name);
|
|
60
|
+
const exact = normalized.filter((item) => {
|
|
61
|
+
const title = String(item.title || '').trim().toLowerCase();
|
|
62
|
+
return titles.some((candidate) => title === candidate);
|
|
63
|
+
});
|
|
64
|
+
const pool = exact.length
|
|
65
|
+
? exact
|
|
66
|
+
: normalized.filter((item) => titleMatches(item, titles));
|
|
67
|
+
if (!pool.length) return null;
|
|
68
|
+
const dev = pool.find((item) => String(item.vault.name).toLowerCase().includes('development'));
|
|
69
|
+
return dev || pool[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function opRefFromItem(item, field = 'credential') {
|
|
73
|
+
if (!item?.id || !item?.vault?.name) return null;
|
|
74
|
+
const resolvedField = item.fields?.find((f) => f.label === field || f.purpose === 'API_KEY')?.label || field;
|
|
75
|
+
return `op://${item.vault.name}/${item.id}/${resolvedField}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function credentialPresent(entry, { env, cwd, home }) {
|
|
79
|
+
if (hasAnySecret(entry.envVars, { env, cwd })) return true;
|
|
80
|
+
return Boolean(discoverAlternateRawForCredential(entry, { home }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function linkCredentialFromOnePassword(entry, { home, persist, opItems }) {
|
|
84
|
+
const item = pickOpItem(opItems, { titles: entry.opTitles });
|
|
85
|
+
const ref = item ? opRefFromItem(item, entry.opField) : null;
|
|
86
|
+
if (!ref) return null;
|
|
87
|
+
if (persist) {
|
|
88
|
+
ensureUserConfigDir(home);
|
|
89
|
+
const file = getUserEnvPath(home);
|
|
90
|
+
writeEnvValues(file, { [primaryEnvVar(entry)]: ref });
|
|
91
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
|
|
92
|
+
}
|
|
93
|
+
return { id: entry.id, envVar: primaryEnvVar(entry), ref };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let lastBootstrapResult = null;
|
|
97
|
+
|
|
98
|
+
function bootstrapSkipped(env) {
|
|
99
|
+
if (env.NODE_ENV === 'test' || env.CI === 'true') return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function missingCredentials({ env, cwd, home }) {
|
|
104
|
+
return API_KEY_CREDENTIALS.filter((entry) => !credentialPresent(entry, { env, cwd, home }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ensureConstructCredentials({
|
|
108
|
+
env = process.env,
|
|
109
|
+
cwd = process.cwd(),
|
|
110
|
+
home = os.homedir(),
|
|
111
|
+
persist = true,
|
|
112
|
+
opRun = defaultOpRun,
|
|
113
|
+
force = false,
|
|
114
|
+
autoLink = false,
|
|
115
|
+
} = {}) {
|
|
116
|
+
if (lastBootstrapResult && !force) return lastBootstrapResult;
|
|
117
|
+
|
|
118
|
+
if (bootstrapSkipped(env)) {
|
|
119
|
+
lastBootstrapResult = { linked: [], opAvailable: false, skipped: 'hermetic' };
|
|
120
|
+
return lastBootstrapResult;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const missing = missingCredentials({ env, cwd, home });
|
|
124
|
+
if (missing.length === 0) {
|
|
125
|
+
lastBootstrapResult = { linked: [], opAvailable: commandExists('op') };
|
|
126
|
+
return lastBootstrapResult;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!autoLink) {
|
|
130
|
+
lastBootstrapResult = { linked: [], opAvailable: commandExists('op'), pending: missing.map((e) => e.id) };
|
|
131
|
+
return lastBootstrapResult;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const linked = [];
|
|
135
|
+
const opAvailable = opRun !== defaultOpRun || commandExists('op');
|
|
136
|
+
if (!opAvailable) {
|
|
137
|
+
lastBootstrapResult = { linked, opAvailable: false };
|
|
138
|
+
return lastBootstrapResult;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const opItems = listOpItems({ opRun });
|
|
142
|
+
for (const entry of missing) {
|
|
143
|
+
const link = linkCredentialFromOnePassword(entry, { home, persist, opItems });
|
|
144
|
+
if (link) linked.push(link);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lastBootstrapResult = { linked, opAvailable: true };
|
|
148
|
+
return lastBootstrapResult;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function __resetCredentialBootstrapCache() {
|
|
152
|
+
lastBootstrapResult = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function formatCredentialBootstrapNotice(result) {
|
|
156
|
+
if (!result?.linked?.length) return null;
|
|
157
|
+
const names = result.linked.map((entry) => entry.id).join(', ');
|
|
158
|
+
return `Credentials linked from 1Password: ${names}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const readOpenRouterRawFromOpenCode = (configPath) => readRawFromOpenCodeProvider('openrouter', configPath);
|
|
162
|
+
export const readOpenRouterRawFromCreds = () => readRawFromCredsStore('openrouter');
|
|
163
|
+
export const discoverOpenRouterAlternateRaw = (opts) => discoverAlternateRawForCredential(
|
|
164
|
+
API_KEY_CREDENTIALS.find((e) => e.id === 'openrouter'),
|
|
165
|
+
opts,
|
|
166
|
+
);
|
|
167
|
+
export const pickOpenRouterOpItem = (items) => pickOpItem(items, { titles: ['openrouter', 'openrouter api key'] });
|
|
168
|
+
export const openRouterOpRefFromItem = (item) => opRefFromItem(item, 'credential');
|
|
169
|
+
|
|
170
|
+
export function ensureOpenRouterCredential(opts = {}) {
|
|
171
|
+
const result = ensureConstructCredentials(opts);
|
|
172
|
+
const link = result.linked.find((entry) => entry.id === 'openrouter') || null;
|
|
173
|
+
return { linked: Boolean(link), source: link ? '1password' : null, ref: link?.ref || null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function needsOpenRouterForModel(modelId) {
|
|
177
|
+
return typeof modelId === 'string' && /^openrouter\//.test(modelId);
|
|
178
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/credential-catalog.mjs — LLM API-key provider credential metadata.
|
|
3
|
+
*
|
|
4
|
+
* Single catalog mapping env var names, Construct creds-store keys, and 1Password
|
|
5
|
+
* item matching for the secret resolver and credential bootstrap.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const API_KEY_CREDENTIALS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'anthropic',
|
|
11
|
+
envVars: ['ANTHROPIC_API_KEY'],
|
|
12
|
+
credsKey: 'anthropic',
|
|
13
|
+
opTitles: ['anthropic', 'anthropic api key'],
|
|
14
|
+
opField: 'credential',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'openai',
|
|
18
|
+
envVars: ['OPENAI_API_KEY'],
|
|
19
|
+
credsKey: 'openai',
|
|
20
|
+
opTitles: ['openai', 'openai api key'],
|
|
21
|
+
opField: 'credential',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'openrouter',
|
|
25
|
+
envVars: ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
26
|
+
credsKey: 'openrouter',
|
|
27
|
+
opTitles: ['openrouter', 'openrouter api key'],
|
|
28
|
+
opField: 'credential',
|
|
29
|
+
openCodeProvider: 'openrouter',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'github',
|
|
33
|
+
envVars: ['GITHUB_TOKEN', 'GH_TOKEN'],
|
|
34
|
+
credsKey: 'github',
|
|
35
|
+
opTitles: ['github', 'github token', 'github personal access token'],
|
|
36
|
+
opField: 'credential',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function credentialForEnvVar(varName) {
|
|
41
|
+
return API_KEY_CREDENTIALS.find((entry) => entry.envVars.includes(varName)) || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function primaryEnvVar(entry) {
|
|
45
|
+
return entry?.envVars?.[0] || null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/credential-sources.mjs — non-1Password credential source reads.
|
|
3
|
+
*
|
|
4
|
+
* Reads alternate API-key locations (Construct creds store, OpenCode provider
|
|
5
|
+
* settings) for the secret resolver and bootstrap without invoking op read.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import { readCreds } from './creds.mjs';
|
|
12
|
+
import { API_KEY_CREDENTIALS } from './credential-catalog.mjs';
|
|
13
|
+
|
|
14
|
+
export function openCodeConfigPath(homeDir = os.homedir()) {
|
|
15
|
+
return path.join(homeDir, '.config', 'opencode', 'opencode.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPlaceholder(value) {
|
|
19
|
+
return !value || String(value).includes('__OPENROUTER_API_KEY__');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readRawFromOpenCodeProvider(providerName, configPath = openCodeConfigPath()) {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.existsSync(configPath)) return null;
|
|
25
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
26
|
+
const provider = config?.provider?.[providerName];
|
|
27
|
+
if (!provider) return null;
|
|
28
|
+
if (!isPlaceholder(provider.apiKey)) return String(provider.apiKey).trim();
|
|
29
|
+
const auth = provider.options?.headers?.Authorization;
|
|
30
|
+
if (typeof auth === 'string') {
|
|
31
|
+
const value = auth.replace(/^Bearer\s+/i, '').trim();
|
|
32
|
+
if (!isPlaceholder(value)) return value;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readRawFromCredsStore(credsKey) {
|
|
41
|
+
if (!credsKey) return null;
|
|
42
|
+
try {
|
|
43
|
+
const key = readCreds()?.[credsKey]?.key;
|
|
44
|
+
return key ? String(key).trim() : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function discoverAlternateRawForCredential(entry, { home = os.homedir() } = {}) {
|
|
51
|
+
if (!entry) return null;
|
|
52
|
+
const fromCreds = readRawFromCredsStore(entry.credsKey);
|
|
53
|
+
if (fromCreds) return fromCreds;
|
|
54
|
+
if (entry.openCodeProvider) {
|
|
55
|
+
return readRawFromOpenCodeProvider(entry.openCodeProvider, openCodeConfigPath(home));
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function discoverAlternateRawForVar(varName, opts = {}) {
|
|
61
|
+
const entry = API_KEY_CREDENTIALS.find((item) => item.envVars.includes(varName));
|
|
62
|
+
return entry ? discoverAlternateRawForCredential(entry, opts) : null;
|
|
63
|
+
}
|
package/lib/providers/creds.mjs
CHANGED
|
@@ -25,11 +25,14 @@ import fs from 'node:fs';
|
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
|
|
28
|
-
const CONSTRUCT_DIR = path.join(os.homedir(), '.construct');
|
|
29
28
|
const CONFIG_FILE = 'config.env';
|
|
30
29
|
|
|
30
|
+
function constructDir() {
|
|
31
|
+
return path.join(os.homedir(), '.construct');
|
|
32
|
+
}
|
|
33
|
+
|
|
31
34
|
export function credsFilePath() {
|
|
32
|
-
return path.join(
|
|
35
|
+
return path.join(constructDir(), CONFIG_FILE);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function providerKey(provider) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/op-run.mjs — optionally launch a child under 1Password `op run`.
|
|
3
|
+
*
|
|
4
|
+
* When CONSTRUCT_OP_ENV_FILE points to an `op run` env-file (KEY=op://… lines)
|
|
5
|
+
* and the `op` CLI is installed, long-lived Construct services — the dashboard
|
|
6
|
+
* daemon that runs chat turns — are spawned through
|
|
7
|
+
* `op run --no-masking --env-file <file> -- <cmd>`, so op:// references resolve
|
|
8
|
+
* once at startup into the process env. This mirrors the per-invocation model
|
|
9
|
+
* other LLM CLIs use (e.g. `op run -- opencode`) and means a bare `construct chat`
|
|
10
|
+
* gets resolved provider keys without a shell wrapper.
|
|
11
|
+
*
|
|
12
|
+
* Strictly opt-in: with the var unset, the file missing, or `op` absent, the
|
|
13
|
+
* command is returned unchanged and 1Password is never invoked. No other setup
|
|
14
|
+
* path forces it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
function expandHome(file, homeDir) {
|
|
22
|
+
if (file === '~') return homeDir;
|
|
23
|
+
if (file.startsWith('~/')) return `${homeDir}${file.slice(1)}`;
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveOpEnvFile(env = process.env, homeDir = os.homedir()) {
|
|
28
|
+
const raw = env.CONSTRUCT_OP_ENV_FILE;
|
|
29
|
+
if (!raw || !raw.trim()) return null;
|
|
30
|
+
const file = expandHome(raw.trim(), homeDir);
|
|
31
|
+
try {
|
|
32
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function opCliAvailable() {
|
|
39
|
+
try {
|
|
40
|
+
return spawnSync('op', ['--version'], { stdio: 'ignore', timeout: 3000 }).status === 0;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return the command/args to spawn, wrapped in `op run` when opted in. Falls back
|
|
48
|
+
* to the original command unchanged otherwise. `hasOp` is injectable for tests.
|
|
49
|
+
*/
|
|
50
|
+
export function wrapWithOpRun(command, args = [], { env = process.env, homeDir = os.homedir(), hasOp = opCliAvailable } = {}) {
|
|
51
|
+
const file = resolveOpEnvFile(env, homeDir);
|
|
52
|
+
if (!file || !hasOp()) return { command, args, wrapped: false, envFile: null };
|
|
53
|
+
return {
|
|
54
|
+
command: 'op',
|
|
55
|
+
args: ['run', '--no-masking', `--env-file=${file}`, '--', command, ...args],
|
|
56
|
+
wrapped: true,
|
|
57
|
+
envFile: file,
|
|
58
|
+
};
|
|
59
|
+
}
|