heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// credentials.mjs — the ONE bearer reader for routing fact A ("which account is
|
|
2
|
+
// this machine signed in as"), [[HS-HL0D-WW9R-TASK-share-one-bearer-reader-and-one-console]]
|
|
3
|
+
// under [[HS-3PEQ-WW9R-EPIC-one-home-for-every-routing-fact-and-per]].
|
|
4
|
+
//
|
|
5
|
+
// Before this file, four readers parsed `~/.heyspex/credentials.yaml` with
|
|
6
|
+
// different rules: the MCP transport (console-fetch.mjs) and doctor honoured
|
|
7
|
+
// `HEYSPEX_MCP_TOKEN` then the `mcp_token:` key, while `hs whoami` and the CLI's
|
|
8
|
+
// `readPairedToken` took the FIRST `hsmcp_`/`hscmp_` match anywhere in the file
|
|
9
|
+
// and ignored the env var — so `hs whoami` could name one account while the MCP
|
|
10
|
+
// server sent another. Invariant 3 (one fact, one home): every read site now
|
|
11
|
+
// imports this module, and `scripts/check-shared-helpers.mjs` refuses a new
|
|
12
|
+
// `credentials.yaml` token regex anywhere else.
|
|
13
|
+
//
|
|
14
|
+
// Precedence, everywhere:
|
|
15
|
+
// 1. `HEYSPEX_MCP_TOKEN` env (trimmed; blank counts as unset)
|
|
16
|
+
// 2. the top-level `mcp_token:` key of `~/.heyspex/credentials.yaml`
|
|
17
|
+
// (never a bare token-shaped regex over the whole file — a Companion
|
|
18
|
+
// `hscmp_` token under `clerk:` is NOT this machine's MCP bearer)
|
|
19
|
+
//
|
|
20
|
+
// Plain Node ESM, zero dependencies: spex-console imports it straight from
|
|
21
|
+
// TypeScript (same as whoami.mjs), hook scripts and dev-factory import it bare.
|
|
22
|
+
// Pure core ({@link resolveBearer}, {@link parseMcpToken}) + thin shell
|
|
23
|
+
// ({@link readBearer}); the shell never throws — an unreadable file is "no
|
|
24
|
+
// file token". Never logs token material.
|
|
25
|
+
//
|
|
26
|
+
// Per-account store, schema v2 ([[HS-UA9S-WW9R-TASK-store-credentials-per-account-login]],
|
|
27
|
+
// invariant 4: credentials, pointer and active vault are keyed by account and
|
|
28
|
+
// switch together). The file keeps its line-oriented shape so the blocks other
|
|
29
|
+
// writers own (the Companion's `clerk:`, comments, `neon:`) survive byte for byte:
|
|
30
|
+
//
|
|
31
|
+
// schema_version: 2
|
|
32
|
+
// current_account_id: user_B
|
|
33
|
+
// mcp_token: hsmcp_B # mirror of the CURRENT account (see below)
|
|
34
|
+
// console_url: https://… # mirror of the CURRENT account's console
|
|
35
|
+
// accounts:
|
|
36
|
+
// user_A:
|
|
37
|
+
// mcp_token: "hsmcp_A"
|
|
38
|
+
// console_url: "https://…"
|
|
39
|
+
// email: "a@example.test"
|
|
40
|
+
// paired_at: "2026-10-03T…"
|
|
41
|
+
// user_B:
|
|
42
|
+
// …
|
|
43
|
+
// clerk:
|
|
44
|
+
// companion_token: … # untouched
|
|
45
|
+
//
|
|
46
|
+
// The top-level `mcp_token` / `console_url` mirror the current account so every
|
|
47
|
+
// reader that predates v2 (an older published MCP package, the shared console
|
|
48
|
+
// resolver's `console_url:` rung, `hs dev`'s console write) keeps reading the
|
|
49
|
+
// right answer with no change. This module is the only writer of the v2 keys
|
|
50
|
+
// and rewrites the mirror on every write, so the two never disagree.
|
|
51
|
+
//
|
|
52
|
+
// A v1 file (flat `mcp_token:` / `console_url:`, no `schema_version: 2`) is read
|
|
53
|
+
// as ONE account named `legacy` — nothing is rewritten on read. The account id
|
|
54
|
+
// of that legacy token is the `account_id:` an earlier `hs login` left in
|
|
55
|
+
// `~/.heyspex/config.yaml`; that key is now read ONLY for a v1 file, and only
|
|
56
|
+
// `hs login` / `hs login --migrate` (an explicit verb, with `--dry-run`)
|
|
57
|
+
// re-keys the legacy token under the account the console confirms for it.
|
|
58
|
+
|
|
59
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
60
|
+
import os, { homedir } from 'node:os';
|
|
61
|
+
import { dirname, join, resolve } from 'node:path';
|
|
62
|
+
|
|
63
|
+
/** Env var that outranks the credentials file. */
|
|
64
|
+
export const MCP_TOKEN_ENV = 'HEYSPEX_MCP_TOKEN';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {'env' | 'credentials-file'} BearerSource
|
|
68
|
+
* @typedef {{ token: string | null, source: BearerSource | null }} Bearer
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** `~/.heyspex/credentials.yaml` for `homeDir`. */
|
|
72
|
+
export function credentialsFilePath(homeDir = homedir()) {
|
|
73
|
+
return join(homeDir, '.heyspex', 'credentials.yaml');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The ONE token regex. Anchored to a TOP-LEVEL `mcp_token:` line; an optional
|
|
77
|
+
// quote; the value up to a quote, comment or line end. Deliberately not a
|
|
78
|
+
// token-shape (`hsmcp_…`) match: the key is the fact, the prefix is not.
|
|
79
|
+
// `[ \t]*` (not `\s*`): the inherited copies used `\s*`, which swallowed the
|
|
80
|
+
// newline after an EMPTY `mcp_token:` line and captured the next line's text
|
|
81
|
+
// as the token — the fixture case "empty mcp_token key is no token" pins it.
|
|
82
|
+
const MCP_TOKEN_LINE_RE = /^mcp_token:[ \t]*["']?([^"'\r\n#]+)/m;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `mcp_token:` value of a credentials.yaml text, or `null` when the key is
|
|
86
|
+
* absent/empty. Pure.
|
|
87
|
+
* @param {string | null | undefined} raw
|
|
88
|
+
* @returns {string | null}
|
|
89
|
+
*/
|
|
90
|
+
export function parseMcpToken(raw) {
|
|
91
|
+
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
92
|
+
const token = raw.match(MCP_TOKEN_LINE_RE)?.[1]?.trim();
|
|
93
|
+
return token ? token : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// The per-account store (schema v2)
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** On-disk schema this module writes. */
|
|
101
|
+
export const CREDENTIALS_SCHEMA_VERSION = 2;
|
|
102
|
+
|
|
103
|
+
/** The account a v1 (flat, single-token) file's token is read under. */
|
|
104
|
+
export const LEGACY_ACCOUNT_ID = 'legacy';
|
|
105
|
+
|
|
106
|
+
/** The per-account fields, in the order they are written. */
|
|
107
|
+
const ACCOUNT_FIELDS = ['mcp_token', 'console_url', 'email', 'paired_at'];
|
|
108
|
+
|
|
109
|
+
/** Top-level keys this module owns (every other top-level line is preserved). */
|
|
110
|
+
const MANAGED_TOP_LEVEL_KEYS = ['schema_version', 'current_account_id', 'mcp_token', 'console_url'];
|
|
111
|
+
|
|
112
|
+
// An account id is written as a bare YAML key, so it is restricted to a safe
|
|
113
|
+
// alphabet (Clerk ids `user_…`, `legacy`, test ids). Anything else is refused.
|
|
114
|
+
const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.@+-]*$/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {{ mcp_token: string | null, console_url: string | null, email: string | null, paired_at: string | null }} AccountCredentials
|
|
118
|
+
* @typedef {{ schemaVersion: 1 | 2, currentAccountId: string | null, accounts: Record<string, AccountCredentials> }} CredentialsStore
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/** True when `id` can be stored as an account key. */
|
|
122
|
+
export function isValidAccountId(id) {
|
|
123
|
+
return typeof id === 'string' && ACCOUNT_ID_RE.test(id);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function topLevelScalar(raw, key) {
|
|
127
|
+
const re = new RegExp(`^${key}:[ \\t]*["']?([^"'\\r\\n#]+)`, 'm');
|
|
128
|
+
const value = raw.match(re)?.[1]?.trim();
|
|
129
|
+
return value ? value : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function decodeScalar(text) {
|
|
133
|
+
const t = text.trim();
|
|
134
|
+
if (t === '' || t === 'null' || t === '~') return null;
|
|
135
|
+
if (t.startsWith('"')) {
|
|
136
|
+
try {
|
|
137
|
+
const v = JSON.parse(t);
|
|
138
|
+
return typeof v === 'string' && v ? v : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return t.replace(/^"|"$/g, '') || null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (t.startsWith("'")) return t.replace(/^'|'$/g, '') || null;
|
|
144
|
+
const bare = t.replace(/\s+#.*$/, '').trim();
|
|
145
|
+
return bare || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function emptyAccount() {
|
|
149
|
+
return { mcp_token: null, console_url: null, email: null, paired_at: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Index of the top-level `accounts:` line and the end (exclusive) of its block. */
|
|
153
|
+
function findAccountsBlock(lines) {
|
|
154
|
+
const start = lines.findIndex((l) => /^accounts:[ \t]*(#.*)?$/.test(l));
|
|
155
|
+
if (start < 0) return null;
|
|
156
|
+
let end = start + 1;
|
|
157
|
+
while (end < lines.length && (lines[end].trim() === '' || /^[ \t]/.test(lines[end]))) end += 1;
|
|
158
|
+
// Trailing blank lines belong to whatever follows, not to the block.
|
|
159
|
+
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
|
|
160
|
+
return { start, end };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseAccountsBlock(lines) {
|
|
164
|
+
/** @type {Record<string, AccountCredentials>} */
|
|
165
|
+
const accounts = {};
|
|
166
|
+
const block = findAccountsBlock(lines);
|
|
167
|
+
if (!block) return accounts;
|
|
168
|
+
let current = null;
|
|
169
|
+
for (const line of lines.slice(block.start + 1, block.end)) {
|
|
170
|
+
const header = line.match(/^ {2}([^\s:#][^:]*):[ \t]*(#.*)?$/);
|
|
171
|
+
if (header) {
|
|
172
|
+
const id = decodeScalar(header[1]);
|
|
173
|
+
current = isValidAccountId(id) ? id : null;
|
|
174
|
+
if (current) accounts[current] = emptyAccount();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const field = line.match(/^ {4}([a-z_]+):(.*)$/);
|
|
178
|
+
if (field && current && ACCOUNT_FIELDS.includes(field[1])) {
|
|
179
|
+
accounts[current][field[1]] = decodeScalar(field[2]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return accounts;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse a credentials.yaml text into the per-account store. Pure, never throws.
|
|
187
|
+
*
|
|
188
|
+
* A file without `schema_version: 2` (every file written before this task, an
|
|
189
|
+
* empty file, a missing file) is v1: its top-level `mcp_token` (if any) is the
|
|
190
|
+
* one account {@link LEGACY_ACCOUNT_ID}, which is then also current.
|
|
191
|
+
* @param {string | null | undefined} raw
|
|
192
|
+
* @returns {CredentialsStore}
|
|
193
|
+
*/
|
|
194
|
+
export function parseCredentialsStore(raw) {
|
|
195
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
196
|
+
if (topLevelScalar(text, 'schema_version') !== String(CREDENTIALS_SCHEMA_VERSION)) {
|
|
197
|
+
const token = parseMcpToken(text);
|
|
198
|
+
if (!token) return { schemaVersion: 1, currentAccountId: null, accounts: {} };
|
|
199
|
+
return {
|
|
200
|
+
schemaVersion: 1,
|
|
201
|
+
currentAccountId: LEGACY_ACCOUNT_ID,
|
|
202
|
+
accounts: {
|
|
203
|
+
[LEGACY_ACCOUNT_ID]: { ...emptyAccount(), mcp_token: token, console_url: topLevelScalar(text, 'console_url') },
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const accounts = parseAccountsBlock(text.split(/\r?\n/));
|
|
208
|
+
const current = topLevelScalar(text, 'current_account_id');
|
|
209
|
+
return {
|
|
210
|
+
schemaVersion: 2,
|
|
211
|
+
currentAccountId: current && accounts[current] ? current : null,
|
|
212
|
+
accounts,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Find an account by id, or by email (case-insensitive). Pure. */
|
|
217
|
+
export function findStoredAccount(store, idOrEmail) {
|
|
218
|
+
if (typeof idOrEmail !== 'string' || !idOrEmail.trim()) return null;
|
|
219
|
+
const needle = idOrEmail.trim();
|
|
220
|
+
if (store.accounts[needle]) return { accountId: needle, ...store.accounts[needle] };
|
|
221
|
+
const lower = needle.toLowerCase();
|
|
222
|
+
for (const [accountId, entry] of Object.entries(store.accounts)) {
|
|
223
|
+
if (entry.email && entry.email.toLowerCase() === lower) return { accountId, ...entry };
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Pure core: fold an env value and a credentials-file text into ONE bearer
|
|
230
|
+
* verdict. `credentialsText` is `null` when there is no readable file.
|
|
231
|
+
*
|
|
232
|
+
* `accountId` names an account: its stored token is returned (or none). Without
|
|
233
|
+
* it the CURRENT account's token is returned. On a v1 file the one flat token is
|
|
234
|
+
* the `legacy` account, which `legacyAccountId` (the v1 `config.yaml`
|
|
235
|
+
* `account_id`) may also name. The env var outranks the file either way — it is
|
|
236
|
+
* the explicit tier.
|
|
237
|
+
* @param {{ envToken?: string | null | undefined, credentialsText?: string | null | undefined, accountId?: string | null, legacyAccountId?: string | null }} input
|
|
238
|
+
* @returns {Bearer}
|
|
239
|
+
*/
|
|
240
|
+
export function resolveBearer({ envToken, credentialsText, accountId, legacyAccountId } = {}) {
|
|
241
|
+
const fromEnv = typeof envToken === 'string' ? envToken.trim() : '';
|
|
242
|
+
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
243
|
+
const store = parseCredentialsStore(credentialsText);
|
|
244
|
+
let entry;
|
|
245
|
+
if (typeof accountId === 'string' && accountId.trim()) {
|
|
246
|
+
const wanted = accountId.trim();
|
|
247
|
+
entry = store.accounts[wanted];
|
|
248
|
+
if (!entry && store.schemaVersion === 1 && legacyAccountId && wanted === legacyAccountId) {
|
|
249
|
+
entry = store.accounts[LEGACY_ACCOUNT_ID];
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
entry = store.currentAccountId ? store.accounts[store.currentAccountId] : undefined;
|
|
253
|
+
}
|
|
254
|
+
if (entry?.mcp_token) return { token: entry.mcp_token, source: 'credentials-file' };
|
|
255
|
+
return { token: null, source: null };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readTextOrNull(read, path) {
|
|
259
|
+
try {
|
|
260
|
+
const raw = read(path);
|
|
261
|
+
return typeof raw === 'string' ? raw : null;
|
|
262
|
+
} catch {
|
|
263
|
+
return null; // missing / unreadable file
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Same line-anchored shape as session-owner.mjs's historical reader.
|
|
268
|
+
const CONFIG_ACCOUNT_ID_RE = /^account_id:\s*["']?([^"'\r\n#]+)/m;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The v1 `account_id:` of `~/.heyspex/config.yaml` — read ONLY to name the
|
|
272
|
+
* account of a v1 file's legacy token (migration read). Never written.
|
|
273
|
+
*/
|
|
274
|
+
function readLegacyConfigAccountId(read, homeDir) {
|
|
275
|
+
const raw = readTextOrNull(read, join(homeDir, '.heyspex', 'config.yaml'));
|
|
276
|
+
const value = raw?.match(CONFIG_ACCOUNT_ID_RE)?.[1]?.trim() ?? '';
|
|
277
|
+
return value || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Thin shell: read the env and the file, then {@link resolveBearer}. Never
|
|
282
|
+
* throws. `readFile` is a test seam (defaults to `readFileSync` utf8).
|
|
283
|
+
* `accountId` selects a named account's token instead of the current one's.
|
|
284
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string | undefined>, homeDir?: string, readFile?: (p: string) => string | null, accountId?: string | null }} [opts]
|
|
285
|
+
* @returns {Bearer}
|
|
286
|
+
*/
|
|
287
|
+
export function readBearer({ env = process.env, homeDir = homedir(), readFile, accountId } = {}) {
|
|
288
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
289
|
+
const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
|
|
290
|
+
const named = typeof accountId === 'string' && accountId.trim() ? accountId.trim() : null;
|
|
291
|
+
const legacyAccountId =
|
|
292
|
+
named && parseCredentialsStore(credentialsText).schemaVersion === 1
|
|
293
|
+
? readLegacyConfigAccountId(read, homeDir)
|
|
294
|
+
: null;
|
|
295
|
+
return resolveBearer({ envToken: env?.[MCP_TOKEN_ENV], credentialsText, accountId: named, legacyAccountId });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read the whole store from disk (never throws; a missing file is an empty v1
|
|
300
|
+
* store). For v1, `legacyAccountId` carries the `config.yaml` `account_id` the
|
|
301
|
+
* legacy token was last confirmed as (null when none).
|
|
302
|
+
* @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
|
|
303
|
+
* @returns {CredentialsStore & { legacyAccountId: string | null }}
|
|
304
|
+
*/
|
|
305
|
+
export function readCredentialsStore({ homeDir = homedir(), readFile } = {}) {
|
|
306
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
307
|
+
const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
|
|
308
|
+
const legacyAccountId = store.schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
|
|
309
|
+
return { ...store, legacyAccountId };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The account id this machine is signed in as, from the credentials store —
|
|
314
|
+
* the ONE stored account-id fact (invariant 3, fact A). No env tier here: the
|
|
315
|
+
* `HS_ACCOUNT_ID` override is the caller's (session-owner.mjs).
|
|
316
|
+
*
|
|
317
|
+
* - v2: `current_account_id` (a real account; `legacy` defers to the v1 rule);
|
|
318
|
+
* - v1 (an existing single-account file, or no file): the `config.yaml`
|
|
319
|
+
* `account_id` the last `hs login` confirmed — read-compatible, no manual step.
|
|
320
|
+
* @param {{ homeDir?: string, readFile?: (p: string) => string | null }} [opts]
|
|
321
|
+
* @returns {string | null}
|
|
322
|
+
*/
|
|
323
|
+
export function readCurrentAccountId({ homeDir = homedir(), readFile } = {}) {
|
|
324
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'));
|
|
325
|
+
const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
|
|
326
|
+
if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
|
|
327
|
+
return store.currentAccountId;
|
|
328
|
+
}
|
|
329
|
+
if (store.schemaVersion === 2 && store.currentAccountId === null) return null;
|
|
330
|
+
return readLegacyConfigAccountId(read, homeDir);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Real-home guard + writers
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** True under vitest (`VITEST`) or the node:test runner (`NODE_TEST_CONTEXT`). */
|
|
338
|
+
function underTestRunner(env = process.env) {
|
|
339
|
+
return Boolean(env.VITEST) || Boolean(env.NODE_TEST_CONTEXT);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function samePathLoose(a, b) {
|
|
343
|
+
const norm = (p) => {
|
|
344
|
+
const r = resolve(p).replace(/[\\/]+$/, '');
|
|
345
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
346
|
+
};
|
|
347
|
+
return norm(a) === norm(b);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The OS account's real home directory — `os.userInfo().homedir` (the passwd
|
|
352
|
+
* entry / the Windows profile), which a test's `HOME` / `USERPROFILE` swap
|
|
353
|
+
* does NOT move. `os.homedir()` is the wrong yardstick here: the node:test
|
|
354
|
+
* suites point it at a temp dir, so the real home would look "foreign".
|
|
355
|
+
*/
|
|
356
|
+
function realUserHome() {
|
|
357
|
+
try {
|
|
358
|
+
const h = os.userInfo().homedir;
|
|
359
|
+
if (typeof h === 'string' && h) return h;
|
|
360
|
+
} catch {
|
|
361
|
+
// no passwd entry (some containers) — fall through
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Refuse, under a test runner, to write anything into the REAL
|
|
368
|
+
* `~/.heyspex/` (credentials.yaml, config.yaml, registry.json). A sibling
|
|
369
|
+
* test once fell through to the real home and rewrote the owner's
|
|
370
|
+
* registry.json; this is the same guard registry-file.ts carries, for the
|
|
371
|
+
* credentials/config writers. Checked on the resolved target directory, so no
|
|
372
|
+
* call shape slips past it.
|
|
373
|
+
* @param {string} filePath
|
|
374
|
+
*/
|
|
375
|
+
export function assertNotRealHomeUnderTest(filePath) {
|
|
376
|
+
if (!underTestRunner()) return;
|
|
377
|
+
const real = realUserHome();
|
|
378
|
+
if (real && samePathLoose(dirname(filePath), join(real, '.heyspex'))) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`credentials: refusing to write ${filePath} under a test runner — that is the real ` +
|
|
381
|
+
'~/.heyspex directory. Point the test at a temp home.',
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function encodeScalar(value) {
|
|
387
|
+
return JSON.stringify(String(value));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Serialize `store` into `existingText`, rewriting only the keys this module
|
|
392
|
+
* owns (the managed top-level keys and the `accounts:` block) and keeping every
|
|
393
|
+
* other line byte-identical. Pure. Keeps an existing top-level `console_url`
|
|
394
|
+
* when no account is current (it is also the machine's console, `hs dev`).
|
|
395
|
+
* @param {string} existingText
|
|
396
|
+
* @param {CredentialsStore} store
|
|
397
|
+
* @returns {string}
|
|
398
|
+
*/
|
|
399
|
+
export function serializeCredentialsStore(existingText, store) {
|
|
400
|
+
const lines = existingText ? existingText.split(/\r?\n/) : [];
|
|
401
|
+
const block = findAccountsBlock(lines);
|
|
402
|
+
const kept = block ? [...lines.slice(0, block.start), ...lines.slice(block.end)] : [...lines];
|
|
403
|
+
const previousConsoleUrl = topLevelScalar(existingText ?? '', 'console_url');
|
|
404
|
+
// `accounts:` too: an empty store is written as the one-line `accounts: {}`.
|
|
405
|
+
const managed = new RegExp(`^(${[...MANAGED_TOP_LEVEL_KEYS, 'accounts'].join('|')}):`);
|
|
406
|
+
const rest = kept.filter((l) => !managed.test(l));
|
|
407
|
+
while (rest.length > 0 && rest[rest.length - 1].trim() === '') rest.pop();
|
|
408
|
+
while (rest.length > 0 && rest[0].trim() === '') rest.shift();
|
|
409
|
+
|
|
410
|
+
const current = store.currentAccountId ? store.accounts[store.currentAccountId] : null;
|
|
411
|
+
const head = [`schema_version: ${CREDENTIALS_SCHEMA_VERSION}`];
|
|
412
|
+
if (current) head.push(`current_account_id: ${store.currentAccountId}`);
|
|
413
|
+
if (current?.mcp_token) head.push(`mcp_token: ${current.mcp_token}`);
|
|
414
|
+
const mirrorConsole = current?.console_url ?? previousConsoleUrl;
|
|
415
|
+
if (mirrorConsole) head.push(`console_url: ${mirrorConsole}`);
|
|
416
|
+
|
|
417
|
+
const accountLines = ['accounts:'];
|
|
418
|
+
for (const [id, entry] of Object.entries(store.accounts)) {
|
|
419
|
+
if (!isValidAccountId(id)) continue;
|
|
420
|
+
accountLines.push(` ${id}:`);
|
|
421
|
+
for (const field of ACCOUNT_FIELDS) {
|
|
422
|
+
if (entry[field]) accountLines.push(` ${field}: ${encodeScalar(entry[field])}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (accountLines.length === 1) accountLines[0] = 'accounts: {}';
|
|
426
|
+
|
|
427
|
+
return `${[...head, ...accountLines, ...rest].join('\n')}\n`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function writeStore(homeDir, store) {
|
|
431
|
+
const filePath = credentialsFilePath(homeDir);
|
|
432
|
+
assertNotRealHomeUnderTest(filePath);
|
|
433
|
+
let existing = '';
|
|
434
|
+
try {
|
|
435
|
+
existing = readFileSync(filePath, 'utf8');
|
|
436
|
+
} catch {
|
|
437
|
+
existing = '';
|
|
438
|
+
}
|
|
439
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
440
|
+
const tmp = `${filePath}.tmp`;
|
|
441
|
+
writeFileSync(tmp, serializeCredentialsStore(existing, store), { mode: 0o600 });
|
|
442
|
+
renameSync(tmp, filePath);
|
|
443
|
+
return filePath;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function loadForWrite(homeDir) {
|
|
447
|
+
const { legacyAccountId, ...store } = readCredentialsStore({ homeDir });
|
|
448
|
+
return { store: { ...store, accounts: { ...store.accounts } }, legacyAccountId };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function requireAccountId(fn, accountId) {
|
|
452
|
+
if (!isValidAccountId(accountId)) throw new Error(`${fn}: a valid account id is required`);
|
|
453
|
+
return accountId;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Store (or replace) one account's credentials, leaving every other account
|
|
458
|
+
* untouched, and (by default) make it current. Converts a v1 file to v2: the
|
|
459
|
+
* flat legacy token is kept under `rekeyLegacyAs` (the account the console
|
|
460
|
+
* confirmed for it) when given, else under `legacy` — never dropped, never
|
|
461
|
+
* guessed. Returns the file path. Never logs token material.
|
|
462
|
+
* @param {{ homeDir?: string, accountId: string, mcpToken: string, consoleUrl?: string | null, email?: string | null, pairedAt?: string | null, makeCurrent?: boolean, rekeyLegacyAs?: string | null }} input
|
|
463
|
+
*/
|
|
464
|
+
export function writeAccountCredentials({
|
|
465
|
+
homeDir = homedir(),
|
|
466
|
+
accountId,
|
|
467
|
+
mcpToken,
|
|
468
|
+
consoleUrl = null,
|
|
469
|
+
email = null,
|
|
470
|
+
pairedAt = null,
|
|
471
|
+
makeCurrent = true,
|
|
472
|
+
rekeyLegacyAs = null,
|
|
473
|
+
}) {
|
|
474
|
+
requireAccountId('writeAccountCredentials', accountId);
|
|
475
|
+
if (typeof mcpToken !== 'string' || !mcpToken.trim()) {
|
|
476
|
+
throw new Error('writeAccountCredentials: mcpToken is required');
|
|
477
|
+
}
|
|
478
|
+
const { store } = loadForWrite(homeDir);
|
|
479
|
+
if (store.schemaVersion === 1) rekeyLegacy(store, rekeyLegacyAs);
|
|
480
|
+
store.accounts[accountId] = {
|
|
481
|
+
mcp_token: mcpToken.trim(),
|
|
482
|
+
console_url: consoleUrl ? String(consoleUrl).trim().replace(/\/+$/, '') : null,
|
|
483
|
+
email: email ?? null,
|
|
484
|
+
paired_at: pairedAt ?? new Date().toISOString(),
|
|
485
|
+
};
|
|
486
|
+
if (makeCurrent || !store.currentAccountId) store.currentAccountId = accountId;
|
|
487
|
+
store.schemaVersion = 2;
|
|
488
|
+
return writeStore(homeDir, store);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function rekeyLegacy(store, rekeyAs) {
|
|
492
|
+
const legacy = store.accounts[LEGACY_ACCOUNT_ID];
|
|
493
|
+
if (!legacy || !isValidAccountId(rekeyAs) || rekeyAs === LEGACY_ACCOUNT_ID) return;
|
|
494
|
+
delete store.accounts[LEGACY_ACCOUNT_ID];
|
|
495
|
+
if (!store.accounts[rekeyAs]) store.accounts[rekeyAs] = legacy;
|
|
496
|
+
if (store.currentAccountId === LEGACY_ACCOUNT_ID) store.currentAccountId = rekeyAs;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Plan (and with `apply`, perform) the v1 → v2 re-key of an existing
|
|
501
|
+
* single-account file: the flat token moves under `confirmedAccountId` — the
|
|
502
|
+
* account GET /api/mcp/session confirmed for THAT token. `hs login --migrate`
|
|
503
|
+
* is the only caller; `--dry-run` prints the plan without writing.
|
|
504
|
+
* @param {{ homeDir?: string, confirmedAccountId: string, email?: string | null, apply?: boolean }} input
|
|
505
|
+
* @returns {{ action: 'rekey' | 'already-v2' | 'nothing-to-migrate', accountId: string | null, path: string }}
|
|
506
|
+
*/
|
|
507
|
+
export function migrateLegacyCredentials({ homeDir = homedir(), confirmedAccountId, email = null, apply = false }) {
|
|
508
|
+
const path = credentialsFilePath(homeDir);
|
|
509
|
+
const { store } = loadForWrite(homeDir);
|
|
510
|
+
if (store.schemaVersion === 2) return { action: 'already-v2', accountId: store.currentAccountId, path };
|
|
511
|
+
const legacy = store.accounts[LEGACY_ACCOUNT_ID];
|
|
512
|
+
if (!legacy) return { action: 'nothing-to-migrate', accountId: null, path };
|
|
513
|
+
requireAccountId('migrateLegacyCredentials', confirmedAccountId);
|
|
514
|
+
if (apply) {
|
|
515
|
+
rekeyLegacy(store, confirmedAccountId);
|
|
516
|
+
const entry = store.accounts[confirmedAccountId];
|
|
517
|
+
if (entry && !entry.email && email) entry.email = email;
|
|
518
|
+
store.currentAccountId = confirmedAccountId;
|
|
519
|
+
store.schemaVersion = 2;
|
|
520
|
+
writeStore(homeDir, store);
|
|
521
|
+
}
|
|
522
|
+
return { action: 'rekey', accountId: confirmedAccountId, path };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Make `accountId` current. It must already hold a token (a switch never
|
|
527
|
+
* pairs). Returns the file path.
|
|
528
|
+
* @param {{ homeDir?: string, accountId: string }} input
|
|
529
|
+
*/
|
|
530
|
+
export function setCurrentAccount({ homeDir = homedir(), accountId }) {
|
|
531
|
+
requireAccountId('setCurrentAccount', accountId);
|
|
532
|
+
const { store } = loadForWrite(homeDir);
|
|
533
|
+
if (!store.accounts[accountId]?.mcp_token) {
|
|
534
|
+
throw new Error(`setCurrentAccount: no stored credentials for account ${accountId}`);
|
|
535
|
+
}
|
|
536
|
+
store.currentAccountId = accountId;
|
|
537
|
+
store.schemaVersion = 2;
|
|
538
|
+
return writeStore(homeDir, store);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Remove one account (default: the current one). Removing the current account
|
|
543
|
+
* clears `current_account_id` (and the top-level token mirror); every other
|
|
544
|
+
* account is untouched. A no-op write is skipped when nothing matched.
|
|
545
|
+
* @param {{ homeDir?: string, accountId?: string | null }} [input]
|
|
546
|
+
* @returns {{ removed: string | null, wasCurrent: boolean, currentAccountId: string | null, path: string }}
|
|
547
|
+
*/
|
|
548
|
+
export function removeAccount({ homeDir = homedir(), accountId = null } = {}) {
|
|
549
|
+
const path = credentialsFilePath(homeDir);
|
|
550
|
+
const { store } = loadForWrite(homeDir);
|
|
551
|
+
const target = accountId ?? store.currentAccountId;
|
|
552
|
+
if (!target || !store.accounts[target]) {
|
|
553
|
+
return { removed: null, wasCurrent: false, currentAccountId: store.currentAccountId, path };
|
|
554
|
+
}
|
|
555
|
+
delete store.accounts[target];
|
|
556
|
+
const wasCurrent = store.currentAccountId === target;
|
|
557
|
+
if (wasCurrent) store.currentAccountId = null;
|
|
558
|
+
store.schemaVersion = 2;
|
|
559
|
+
writeStore(homeDir, store);
|
|
560
|
+
return { removed: target, wasCurrent, currentAccountId: store.currentAccountId, path };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Convenience for the many "just the token or nothing" call sites.
|
|
565
|
+
* @param {Parameters<typeof readBearer>[0]} [opts]
|
|
566
|
+
* @returns {string | null}
|
|
567
|
+
*/
|
|
568
|
+
export function readBearerToken(opts) {
|
|
569
|
+
return readBearer(opts).token;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The receipt wording for a {@link BearerSource} — what `hs whoami`, the
|
|
574
|
+
* install identity gate and `hs mcp install` print so a human can see WHICH
|
|
575
|
+
* token was confirmed ([[HS-6V9X-WW9R-TASK-make-hs-install-remote-confirm-the]]).
|
|
576
|
+
* Never includes token material.
|
|
577
|
+
* @param {BearerSource | null | undefined} source
|
|
578
|
+
* @returns {string}
|
|
579
|
+
*/
|
|
580
|
+
export function describeBearerSource(source) {
|
|
581
|
+
if (source === 'env') return `${MCP_TOKEN_ENV} (env)`;
|
|
582
|
+
if (source === 'credentials-file') return '~/.heyspex/credentials.yaml (mcp_token)';
|
|
583
|
+
return 'none';
|
|
584
|
+
}
|
|
@@ -13,7 +13,11 @@ import { join } from 'node:path';
|
|
|
13
13
|
|
|
14
14
|
import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
|
|
15
15
|
import { familyOf, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
|
|
16
|
+
import { collectOccupiedKeysEitherKindViaTier } from './occupied-keys.mjs';
|
|
17
|
+
import { resolveReadTier } from './read-path.mjs';
|
|
18
|
+
import { effectiveConsoleUrl } from './mcp-config.mjs';
|
|
16
19
|
import { yamlScalar } from './yaml-scalar.mjs';
|
|
20
|
+
import { resolveSessionOwner } from './session-owner.mjs';
|
|
17
21
|
import {
|
|
18
22
|
isoNow,
|
|
19
23
|
deriveSlug,
|
|
@@ -199,6 +203,7 @@ function buildChildTaskContent({
|
|
|
199
203
|
effort,
|
|
200
204
|
handoffId,
|
|
201
205
|
now,
|
|
206
|
+
sessionOwner = null,
|
|
202
207
|
}) {
|
|
203
208
|
const slug = deriveSlug(childName);
|
|
204
209
|
const parentBasename = parentTask.basename;
|
|
@@ -217,6 +222,13 @@ function buildChildTaskContent({
|
|
|
217
222
|
envelope.reason,
|
|
218
223
|
].filter(Boolean);
|
|
219
224
|
|
|
225
|
+
// `owner:` is inherited from the parent task; a parent with none gets the
|
|
226
|
+
// paired session account; with neither the line is OMITTED — never a literal
|
|
227
|
+
// handle ([[HS-WE87-WW9R-TASK-thread-the-gated-caller-through-every]]).
|
|
228
|
+
const inheritedOwner = getScalarField(parentFm.block, 'owner')?.trim();
|
|
229
|
+
const owner = inheritedOwner || (typeof sessionOwner === 'string' && sessionOwner.trim()) || null;
|
|
230
|
+
const ownerLine = owner ? `\nowner: ${yamlScalar(owner)}` : '';
|
|
231
|
+
|
|
220
232
|
const fm = `---
|
|
221
233
|
id: ${childId}
|
|
222
234
|
file_id: ${childFileKey}
|
|
@@ -225,8 +237,7 @@ name: ${yamlScalar(childName)}
|
|
|
225
237
|
type: task
|
|
226
238
|
status: open
|
|
227
239
|
kind: ${yamlScalar(kind)}
|
|
228
|
-
effort: ${effort}
|
|
229
|
-
owner: ${yamlScalar(getScalarField(parentFm.block, 'owner') ?? 'pwher')}
|
|
240
|
+
effort: ${effort}${ownerLine}
|
|
230
241
|
parent: '${parentEpicLink}'
|
|
231
242
|
milestone: '${parentMilestoneLink}'
|
|
232
243
|
requires_user_testing: ${requiresUserTesting}
|
|
@@ -427,6 +438,12 @@ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
|
|
|
427
438
|
// source; `id:` keys onto the file key alone.
|
|
428
439
|
const parentIdValue = getScalarField(parentFm.block, 'id')?.replace(/^['"]|['"]$/g, '') ?? null;
|
|
429
440
|
const parentFamilyValue = getScalarField(parentFm.block, 'family')?.replace(/^['"]|['"]$/g, '') ?? null;
|
|
441
|
+
// The FAMILY fallback draw (`collectOccupiedTaskIds` / `mintFamilyKey`) stays
|
|
442
|
+
// a DISK scan on purpose ([[HS-FJWL]]): family keys are not required to be
|
|
443
|
+
// globally unique — any number of specs may legally share one — so that scan
|
|
444
|
+
// is a "pick something fresh" default, not a uniqueness gate, and it is only
|
|
445
|
+
// reached at all when the parent has no inheritable family. Do not convert it
|
|
446
|
+
// into a console walk. The FILE key below is the opposite case.
|
|
430
447
|
const childFamilyKey =
|
|
431
448
|
familyOf({
|
|
432
449
|
id: parentIdValue,
|
|
@@ -438,8 +455,30 @@ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
|
|
|
438
455
|
)?.familyKey ??
|
|
439
456
|
mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
|
|
440
457
|
// Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
|
|
441
|
-
// and (V3) the anchor for `id:` itself.
|
|
442
|
-
|
|
458
|
+
// and (V3) the anchor for `id:` itself. TIER-AWARE ([[HS-FJWL]]): a file key
|
|
459
|
+
// is the unique per-file identity, so occupancy has to come from whichever
|
|
460
|
+
// vault is of record for this session. An unreachable console REFUSES the
|
|
461
|
+
// mint rather than falling back to the stale local clone — a duplicate id is
|
|
462
|
+
// worse than a refusal. `deps.tier` / `deps.consoleUrl` / `deps.consoleFetch`
|
|
463
|
+
// are test seams; production resolves them the same way every other
|
|
464
|
+
// `*ViaTier` caller does.
|
|
465
|
+
let occupiedKeys;
|
|
466
|
+
try {
|
|
467
|
+
occupiedKeys = await collectOccupiedKeysEitherKindViaTier(vaultRoot, {
|
|
468
|
+
tier: deps?.tier ?? (await resolveReadTier()),
|
|
469
|
+
consoleUrl: deps?.consoleUrl ?? effectiveConsoleUrl(),
|
|
470
|
+
...(deps?.consoleFetch ? { consoleFetch: deps.consoleFetch } : {}),
|
|
471
|
+
});
|
|
472
|
+
} catch (err) {
|
|
473
|
+
return {
|
|
474
|
+
error: 'CONSOLE_UNREACHABLE_CLOUD_MODE',
|
|
475
|
+
message:
|
|
476
|
+
`Refusing to mint a file key for the split child: ${String(err?.message ?? err)}. ` +
|
|
477
|
+
`In cloud reliance the occupied-key set lives in the console's vault, not on this ` +
|
|
478
|
+
`machine — minting from the local clone would hand out an id another spec may already hold.`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const childFileKey = mintFileKey(occupiedKeys, { randomUnit: deps?.randomUnit });
|
|
443
482
|
const childId = `HS-${childFileKey}`;
|
|
444
483
|
const childSlug = deriveSlug(childName);
|
|
445
484
|
const childBasename = `${childId}-${childFamilyKey}-TASK-${childSlug}`;
|
|
@@ -461,6 +500,7 @@ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
|
|
|
461
500
|
effort,
|
|
462
501
|
handoffId,
|
|
463
502
|
now,
|
|
503
|
+
sessionOwner: (deps?.resolveSessionOwner ?? resolveSessionOwner)(),
|
|
464
504
|
}),
|
|
465
505
|
});
|
|
466
506
|
|