@indigoai-us/hq-cli 5.326.0 → 5.327.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/CHANGELOG.md +5 -0
- package/dist/command-catalog.generated.d.ts +9 -0
- package/dist/command-catalog.generated.js +10 -0
- package/dist/commands/auth.js +3 -1
- package/dist/commands/core-detect-stale-review-base-fast.d.ts +2 -0
- package/dist/commands/core-detect-stale-review-base-fast.js +23 -0
- package/dist/commands/core-register-project-native.js +1 -1
- package/dist/commands/core-repo-run-registry-fast.d.ts +2 -0
- package/dist/commands/core-repo-run-registry-fast.js +35 -0
- package/dist/commands/core-rrr-worktree-scope-fix-fast-flag.d.ts +7 -0
- package/dist/commands/core-rrr-worktree-scope-fix-fast-flag.js +25 -0
- package/dist/commands/core-session-journal-native.js +1 -1
- package/dist/commands/core-share-suggestion-state-fast.d.ts +2 -0
- package/dist/commands/core-share-suggestion-state-fast.js +25 -0
- package/dist/commands/logout.js +2 -0
- package/dist/commands/mcp-status.js +7 -0
- package/dist/fast-core.js +3 -6
- package/dist/fast-scaffold.js +5 -1
- package/dist/lib/daemon/mcp-endpoint.d.ts +8 -0
- package/dist/lib/daemon/mcp-endpoint.js +30 -0
- package/dist/lib/daemon/mcp-oauth.d.ts +24 -0
- package/dist/lib/daemon/mcp-oauth.js +298 -0
- package/dist/lib/daemon/ops/mcp.d.ts +0 -1
- package/dist/lib/daemon/ops/mcp.js +31 -31
- package/dist/lib/process-flag-company-context-fast.d.ts +4 -0
- package/dist/lib/process-flag-company-context-fast.js +20 -0
- package/dist/lib/process-flag-company-context-light.d.ts +9 -0
- package/dist/lib/process-flag-company-context-light.js +81 -0
- package/dist/lib/process-flag-company-context.d.ts +2 -4
- package/dist/lib/process-flag-company-context.js +2 -46
- package/dist/utils/hq-roots-core.d.ts +130 -0
- package/dist/utils/hq-roots-core.js +354 -0
- package/dist/utils/hq-roots.d.ts +2 -129
- package/dist/utils/hq-roots.js +2 -353
- package/dist/utils/package-root-diagnostics.d.ts +5 -36
- package/dist/utils/package-root-diagnostics.js +4 -83
- package/dist/utils/package-root-resolution-error.d.ts +32 -0
- package/dist/utils/package-root-resolution-error.js +79 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.327.0] — 2026-10-02
|
|
6
|
+
- Reduce hook-time startup imports for repo-run-registry and isolate hook-helper handlers from sibling modules.
|
|
7
|
+
- Keep package-root Sentry diagnostics off the fast-route import path unless root diagnostics are needed.
|
|
8
|
+
- `hq mcp login` adds browser-based OAuth sign-in for hosted MCP.
|
|
9
|
+
|
|
5
10
|
## [5.326.0] — 2026-10-02
|
|
6
11
|
- Company-scoped daemon tools now reject an explicit company that conflicts with the active session or repository binding.
|
|
7
12
|
- Added a flag-gated `hq mcp serve` command and global install hook for the Anywhere runtime.
|
|
@@ -3728,6 +3728,15 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
3728
3728
|
readonly arguments: readonly [];
|
|
3729
3729
|
readonly options: readonly [];
|
|
3730
3730
|
readonly subcommands: readonly [];
|
|
3731
|
+
}, {
|
|
3732
|
+
readonly name: "login";
|
|
3733
|
+
readonly description: "Sign in to the hosted HQ MCP connector with browser OAuth";
|
|
3734
|
+
readonly aliases: readonly [];
|
|
3735
|
+
readonly hidden: false;
|
|
3736
|
+
readonly usage: "[options]";
|
|
3737
|
+
readonly arguments: readonly [];
|
|
3738
|
+
readonly options: readonly [];
|
|
3739
|
+
readonly subcommands: readonly [];
|
|
3731
3740
|
}, {
|
|
3732
3741
|
readonly name: "status";
|
|
3733
3742
|
readonly description: "Show installed MCP packs/servers and their health in BOTH Claude and Codex (PARTIAL/DRIFT flagged; secrets redacted)";
|
|
@@ -4811,6 +4811,16 @@ export const COMMAND_CATALOG = [
|
|
|
4811
4811
|
"options": [],
|
|
4812
4812
|
"subcommands": []
|
|
4813
4813
|
},
|
|
4814
|
+
{
|
|
4815
|
+
"name": "login",
|
|
4816
|
+
"description": "Sign in to the hosted HQ MCP connector with browser OAuth",
|
|
4817
|
+
"aliases": [],
|
|
4818
|
+
"hidden": false,
|
|
4819
|
+
"usage": "[options]",
|
|
4820
|
+
"arguments": [],
|
|
4821
|
+
"options": [],
|
|
4822
|
+
"subcommands": []
|
|
4823
|
+
},
|
|
4814
4824
|
{
|
|
4815
4825
|
"name": "status",
|
|
4816
4826
|
"description": "Show installed MCP packs/servers and their health in BOTH Claude and Codex (PARTIAL/DRIFT flagged; secrets redacted)",
|
package/dist/commands/auth.js
CHANGED
|
@@ -264,7 +264,9 @@ export function registerAuthCommands(program) {
|
|
|
264
264
|
authCmd
|
|
265
265
|
.command("logout")
|
|
266
266
|
.description("Clear the cached HQ Cognito session")
|
|
267
|
-
.action(() => {
|
|
267
|
+
.action(async () => {
|
|
268
|
+
const { clearMcpOAuthCaches } = await import("../lib/daemon/mcp-oauth.js");
|
|
269
|
+
clearMcpOAuthCaches();
|
|
268
270
|
const existing = loadCachedTokens();
|
|
269
271
|
if (!existing) {
|
|
270
272
|
console.log(chalk.yellow("No cached HQ session"));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Fast-route adapter isolated from the other hook-helper command modules. */
|
|
2
|
+
import { resolveBundledAsset } from "../utils/hq-roots-core.js";
|
|
3
|
+
import { runDetectStaleReviewBase, DETECT_STALE_REVIEW_BASE_ASSET } from "./core-detect-stale-review-base-native.js";
|
|
4
|
+
function writeResult(result) {
|
|
5
|
+
if (result.stdout)
|
|
6
|
+
process.stdout.write(result.stdout);
|
|
7
|
+
if (result.stderr)
|
|
8
|
+
process.stderr.write(result.stderr);
|
|
9
|
+
return result.code;
|
|
10
|
+
}
|
|
11
|
+
export async function runDetectStaleReviewBaseFast(args, _stdin, _cwd, hqRoot) {
|
|
12
|
+
let root = hqRoot;
|
|
13
|
+
if (!root) {
|
|
14
|
+
const { resolveLiveRoot } = await import("../utils/hq-roots-core.js");
|
|
15
|
+
root = resolveLiveRoot();
|
|
16
|
+
}
|
|
17
|
+
return writeResult(runDetectStaleReviewBase(args, {
|
|
18
|
+
cwd: root,
|
|
19
|
+
env: { ...process.env, HQ_ROOT: root },
|
|
20
|
+
scriptPath: resolveBundledAsset(DETECT_STALE_REVIEW_BASE_ASSET),
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=core-detect-stale-review-base-fast.js.map
|
|
@@ -4,7 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
|
|
7
|
-
import { resolveBundledAsset } from "../utils/hq-roots.js";
|
|
7
|
+
import { resolveBundledAsset } from "../utils/hq-roots-core.js";
|
|
8
8
|
export const REGISTER_PROJECT_ASSET = "core/scripts/register-project.sh";
|
|
9
9
|
const USAGE = "usage: register-project.sh <company> <project> | --audit <company> | --backfill <company> --only-active [--yes] | --retry-pending <company>";
|
|
10
10
|
const MIN_HQ_CLI = "5.139.0";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare function runRepoRunRegistryFast(args: readonly string[], _stdin: string, _cwd: string, hqRoot: string | undefined, _commandName: string, _hqRootArg?: string, worktreeScopeFixEnabled?: boolean): Promise<number>;
|
|
2
|
+
//# sourceMappingURL=core-repo-run-registry-fast.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Fast-route adapter isolated from the other hook-helper command modules. */
|
|
2
|
+
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
3
|
+
import { runRepoRunRegistry, REPO_RUN_REGISTRY_ASSET } from "./core-repo-run-registry-native.js";
|
|
4
|
+
function writeResult(result) {
|
|
5
|
+
if (result.stdout)
|
|
6
|
+
process.stdout.write(result.stdout);
|
|
7
|
+
if (result.stderr)
|
|
8
|
+
process.stderr.write(result.stderr);
|
|
9
|
+
return result.code;
|
|
10
|
+
}
|
|
11
|
+
async function liveRoot(hqRoot) {
|
|
12
|
+
if (hqRoot)
|
|
13
|
+
return hqRoot;
|
|
14
|
+
const { resolveLiveRoot } = await import("../utils/hq-roots-core.js");
|
|
15
|
+
return resolveLiveRoot();
|
|
16
|
+
}
|
|
17
|
+
export async function runRepoRunRegistryFast(args, _stdin, _cwd, hqRoot, _commandName, _hqRootArg, worktreeScopeFixEnabled = false) {
|
|
18
|
+
const root = await liveRoot(hqRoot);
|
|
19
|
+
return writeResult(runRepoRunRegistry(args, {
|
|
20
|
+
root,
|
|
21
|
+
cwd: root,
|
|
22
|
+
env: process.env,
|
|
23
|
+
worktreeScopeFixEnabled,
|
|
24
|
+
runShellFallback: (fallbackArgs) => {
|
|
25
|
+
const { code } = runBundledScript({
|
|
26
|
+
asset: REPO_RUN_REGISTRY_ASSET,
|
|
27
|
+
args: [...fallbackArgs],
|
|
28
|
+
cwd: root,
|
|
29
|
+
hqRoot: root,
|
|
30
|
+
});
|
|
31
|
+
return { code, stdout: "", stderr: "" };
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=core-repo-run-registry-fast.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Light company-scoped reader for the repo-run-registry worktree-scope flag. */
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the same default-off, company-scoped flag without loading the full
|
|
4
|
+
* flag registry and work-context graph on the ordinary UID-scoped path.
|
|
5
|
+
*/
|
|
6
|
+
export declare function coreRrrWorktreeScopeFixFastFlagEnabled(env?: NodeJS.ProcessEnv): Promise<boolean>;
|
|
7
|
+
//# sourceMappingURL=core-rrr-worktree-scope-fix-fast-flag.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Light company-scoped reader for the repo-run-registry worktree-scope flag. */
|
|
2
|
+
import { readLocalOverride } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
import { readFlagSnapshotFile } from "../lib/flag-snapshot-file.js";
|
|
4
|
+
import { resolveFastProcessFlagCompanyContext } from "../lib/process-flag-company-context-fast.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the same default-off, company-scoped flag without loading the full
|
|
7
|
+
* flag registry and work-context graph on the ordinary UID-scoped path.
|
|
8
|
+
*/
|
|
9
|
+
export async function coreRrrWorktreeScopeFixFastFlagEnabled(env = process.env) {
|
|
10
|
+
const flagKey = "core-rrr-worktree-scope-fix";
|
|
11
|
+
const override = readLocalOverride(flagKey, env);
|
|
12
|
+
if (override !== null)
|
|
13
|
+
return override;
|
|
14
|
+
try {
|
|
15
|
+
const context = await resolveFastProcessFlagCompanyContext(env);
|
|
16
|
+
if (!context.companyUid)
|
|
17
|
+
return false;
|
|
18
|
+
return readFlagSnapshotFile(context.companyUid, { env })?.flags[flagKey] === true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Keep the fast gate's established fail-closed default for unavailable scope or cache data.
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=core-rrr-worktree-scope-fix-fast-flag.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { resolveSessionHelperRuntime } from "./core-session-helper-runtime.js";
|
|
4
|
-
import { resolveBundledAsset } from "../utils/hq-roots.js";
|
|
4
|
+
import { resolveBundledAsset } from "../utils/hq-roots-core.js";
|
|
5
5
|
const USAGE = `session-journal.sh: usage:
|
|
6
6
|
session-journal.sh write "<title>" [--files f1,f2,...] [--body-file PATH] [--session KEY]
|
|
7
7
|
session-journal.sh list [--date YYYY-MM-DD]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Fast-route adapter isolated from the other hook-helper command modules. */
|
|
2
|
+
import { runShareSuggestionState } from "./core-share-suggestion-state-native.js";
|
|
3
|
+
function writeResult(result) {
|
|
4
|
+
if (result.stdout)
|
|
5
|
+
process.stdout.write(result.stdout);
|
|
6
|
+
if (result.stderr)
|
|
7
|
+
process.stderr.write(result.stderr);
|
|
8
|
+
return result.code;
|
|
9
|
+
}
|
|
10
|
+
async function liveRoot(hqRoot) {
|
|
11
|
+
if (hqRoot)
|
|
12
|
+
return hqRoot;
|
|
13
|
+
const { resolveLiveRoot } = await import("../utils/hq-roots-core.js");
|
|
14
|
+
return resolveLiveRoot();
|
|
15
|
+
}
|
|
16
|
+
export async function runShareSuggestionStateFast(args, stdin, _cwd, hqRoot) {
|
|
17
|
+
const root = await liveRoot(hqRoot);
|
|
18
|
+
return writeResult(runShareSuggestionState(args, stdin, {
|
|
19
|
+
root,
|
|
20
|
+
cwd: root,
|
|
21
|
+
env: process.env,
|
|
22
|
+
stdinIsTty: process.stdin.isTTY,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=core-share-suggestion-state-fast.js.map
|
package/dist/commands/logout.js
CHANGED
|
@@ -9,6 +9,8 @@ export function registerLogoutCommand(program) {
|
|
|
9
9
|
.description('Log out and clear cached credentials')
|
|
10
10
|
.action(async () => {
|
|
11
11
|
try {
|
|
12
|
+
const { clearMcpOAuthCaches } = await import('../lib/daemon/mcp-oauth.js');
|
|
13
|
+
clearMcpOAuthCaches();
|
|
12
14
|
const existing = loadCachedTokens();
|
|
13
15
|
if (!existing) {
|
|
14
16
|
console.log(chalk.yellow('No cached HQ session'));
|
|
@@ -396,6 +396,13 @@ export function registerMcpCommand(program) {
|
|
|
396
396
|
const { runMcpServe } = await import('./mcp-serve.js');
|
|
397
397
|
await runMcpServe();
|
|
398
398
|
});
|
|
399
|
+
mcp
|
|
400
|
+
.command('login')
|
|
401
|
+
.description('Sign in to the hosted HQ MCP connector with browser OAuth')
|
|
402
|
+
.action(async () => {
|
|
403
|
+
const { loginHostedMcp } = await import('../lib/daemon/mcp-oauth.js');
|
|
404
|
+
await loginHostedMcp();
|
|
405
|
+
});
|
|
399
406
|
mcp
|
|
400
407
|
.command('status')
|
|
401
408
|
.alias('list')
|
package/dist/fast-core.js
CHANGED
|
@@ -39,11 +39,8 @@ export async function runFastCore(argv, parsed = parseFastCore(argv)) {
|
|
|
39
39
|
const useShell = entry.kind === "native" && Boolean(entry.familyFlagKey) && !familyEnabled;
|
|
40
40
|
let worktreeScopeFixEnabled;
|
|
41
41
|
if (entry.name === "repo-run-registry") {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
import("./native-fast-route.js"),
|
|
45
|
-
]);
|
|
46
|
-
worktreeScopeFixEnabled = await fastFlagEnabled(CORE_RRR_WORKTREE_SCOPE_FIX_FLAG, process.env, { numericEnvironmentOverride: false });
|
|
42
|
+
const { coreRrrWorktreeScopeFixFastFlagEnabled } = await import("./commands/core-rrr-worktree-scope-fix-fast-flag.js");
|
|
43
|
+
worktreeScopeFixEnabled = await coreRrrWorktreeScopeFixFastFlagEnabled(process.env);
|
|
47
44
|
}
|
|
48
45
|
const shouldInjectRoot = useShell
|
|
49
46
|
? entry.shellHqRoot === "live" || (entry.shellHqRoot === "reconcile-default" &&
|
|
@@ -57,7 +54,7 @@ export async function runFastCore(argv, parsed = parseFastCore(argv)) {
|
|
|
57
54
|
// its expected-user-error formatting remains authoritative.
|
|
58
55
|
let hqRoot;
|
|
59
56
|
if (shouldInjectRoot) {
|
|
60
|
-
const { resolveLiveRoot } = await import("./utils/hq-roots.js");
|
|
57
|
+
const { resolveLiveRoot } = await import("./utils/hq-roots-core.js");
|
|
61
58
|
try {
|
|
62
59
|
hqRoot = resolveLiveRoot(parsed.hqRootArg ? { hqRoot: parsed.hqRootArg } : {});
|
|
63
60
|
}
|
package/dist/fast-scaffold.js
CHANGED
|
@@ -175,7 +175,11 @@ export const FAST_SCAFFOLD_SCRIPTS = [
|
|
|
175
175
|
...["share-suggestion-state", "repo-run-registry", "detect-stale-review-base"].map((name) => ({
|
|
176
176
|
name,
|
|
177
177
|
kind: "native",
|
|
178
|
-
modulePath: "
|
|
178
|
+
modulePath: name === "share-suggestion-state"
|
|
179
|
+
? "./commands/core-share-suggestion-state-fast.js"
|
|
180
|
+
: name === "repo-run-registry"
|
|
181
|
+
? "./commands/core-repo-run-registry-fast.js"
|
|
182
|
+
: "./commands/core-detect-stale-review-base-fast.js",
|
|
179
183
|
exportName: name === "share-suggestion-state"
|
|
180
184
|
? "runShareSuggestionStateFast"
|
|
181
185
|
: name === "repo-run-registry"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare class McpEndpointError extends Error {
|
|
2
|
+
readonly code = "hq_mcp_config_error";
|
|
3
|
+
}
|
|
4
|
+
/** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
|
|
5
|
+
export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
|
|
6
|
+
/** OAuth endpoints share the origin with the Streamable HTTP MCP endpoint. */
|
|
7
|
+
export declare function resolveHqMcpOAuthOrigin(env?: NodeJS.ProcessEnv): URL;
|
|
8
|
+
//# sourceMappingURL=mcp-endpoint.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DEFAULT_VAULT_API_URL } from "../../utils/cognito-session.js";
|
|
2
|
+
export class McpEndpointError extends Error {
|
|
3
|
+
code = "hq_mcp_config_error";
|
|
4
|
+
}
|
|
5
|
+
/** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
|
|
6
|
+
export function resolveHqMcpEndpoint(env = process.env) {
|
|
7
|
+
const apiUrl = env.HQ_VAULT_API_URL?.trim() || DEFAULT_VAULT_API_URL;
|
|
8
|
+
let api;
|
|
9
|
+
try {
|
|
10
|
+
api = new URL(apiUrl);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new McpEndpointError("HQ's configured API URL is invalid; check HQ_VAULT_API_URL.");
|
|
14
|
+
}
|
|
15
|
+
if (api.protocol !== "https:" || !api.hostname.startsWith("hqapi.")) {
|
|
16
|
+
throw new McpEndpointError("Hosted hq-mcp requires an HTTPS hqapi host in HQ_VAULT_API_URL.");
|
|
17
|
+
}
|
|
18
|
+
api.hostname = `hq-mcp.${api.hostname.slice("hqapi.".length)}`;
|
|
19
|
+
api.pathname = "/mcp";
|
|
20
|
+
api.search = "";
|
|
21
|
+
api.hash = "";
|
|
22
|
+
return api;
|
|
23
|
+
}
|
|
24
|
+
/** OAuth endpoints share the origin with the Streamable HTTP MCP endpoint. */
|
|
25
|
+
export function resolveHqMcpOAuthOrigin(env = process.env) {
|
|
26
|
+
const endpoint = resolveHqMcpEndpoint(env);
|
|
27
|
+
endpoint.pathname = "/";
|
|
28
|
+
return endpoint;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=mcp-endpoint.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare class McpOAuthError extends Error {
|
|
2
|
+
readonly code = "hq_mcp_auth_required";
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export interface McpOAuthOptions {
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
home?: string;
|
|
8
|
+
oauthOrigin?: URL;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
openBrowser?: (url: string) => Promise<unknown>;
|
|
11
|
+
writeOutput?: (message: string) => void;
|
|
12
|
+
isEnabled?: () => boolean;
|
|
13
|
+
isMachine?: () => boolean;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
callbackTimeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function mcpOAuthCacheFile(options?: McpOAuthOptions): string;
|
|
18
|
+
/** Perform hosted DCR + PKCE login and cache only the hq-mcp audience tokens. */
|
|
19
|
+
export declare function loginHostedMcp(options?: McpOAuthOptions): Promise<void>;
|
|
20
|
+
/** Load or refresh a person's hosted MCP token without printing token material. */
|
|
21
|
+
export declare function getMcpAudienceToken(options?: McpOAuthOptions): Promise<string>;
|
|
22
|
+
/** Remove all per-origin hosted MCP OAuth caches during either HQ logout flow. */
|
|
23
|
+
export declare function clearMcpOAuthCaches(options?: Pick<McpOAuthOptions, "env" | "home">): void;
|
|
24
|
+
//# sourceMappingURL=mcp-oauth.d.ts.map
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as http from "node:http";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import open from "open";
|
|
7
|
+
import { resolveCognitoTokenSource } from "../../utils/cognito-session.js";
|
|
8
|
+
import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
|
|
9
|
+
import { resolveHqMcpOAuthOrigin } from "./mcp-endpoint.js";
|
|
10
|
+
const MCP_SCOPE = "openid email profile hq-mcp/use";
|
|
11
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
12
|
+
const DEFAULT_CALLBACK_TIMEOUT_MS = 5 * 60_000;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
14
|
+
export class McpOAuthError extends Error {
|
|
15
|
+
code = "hq_mcp_auth_required";
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "McpOAuthError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function cacheFile(env, home, origin) {
|
|
22
|
+
const stateDir = env.HQ_STATE_DIR?.trim() || path.join(home, ".hq");
|
|
23
|
+
const originHash = createHash("sha256").update(origin.origin).digest("hex").slice(0, 16);
|
|
24
|
+
return path.join(path.resolve(stateDir), `mcp-oauth-tokens-${originHash}.json`);
|
|
25
|
+
}
|
|
26
|
+
function readCache(file) {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return undefined;
|
|
31
|
+
const cache = value;
|
|
32
|
+
if (cache.version !== 1 || typeof cache.origin !== "string" || !cache.origin ||
|
|
33
|
+
typeof cache.clientId !== "string" || !cache.clientId ||
|
|
34
|
+
typeof cache.accessToken !== "string" || !cache.accessToken ||
|
|
35
|
+
typeof cache.refreshToken !== "string" || !cache.refreshToken ||
|
|
36
|
+
typeof cache.expiresAt !== "number" || !Number.isFinite(cache.expiresAt))
|
|
37
|
+
return undefined;
|
|
38
|
+
return cache;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError)
|
|
42
|
+
return undefined;
|
|
43
|
+
throw new McpOAuthError("The hosted MCP token cache could not be read. Check its permissions or run `hq mcp login` again.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeCache(file, cache) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
const temporary = `${file}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
49
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
50
|
+
try {
|
|
51
|
+
fs.writeFileSync(fd, JSON.stringify(cache));
|
|
52
|
+
fs.fsyncSync(fd);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
fs.renameSync(temporary, file);
|
|
59
|
+
fs.chmodSync(file, 0o600);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
try {
|
|
63
|
+
fs.unlinkSync(temporary);
|
|
64
|
+
}
|
|
65
|
+
catch { /* retain the original write error */ }
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function asObject(value) {
|
|
70
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
71
|
+
}
|
|
72
|
+
async function postForm(url, form, fetchImpl) {
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetchImpl(url, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
78
|
+
body: form.toString(),
|
|
79
|
+
signal: AbortSignal.timeout(30_000),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new McpOAuthError("Hosted MCP sign-in could not reach the OAuth service. Retry `hq mcp login`.");
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new McpOAuthError("Hosted MCP OAuth request failed (HTTP " + response.status + "). Retry `hq mcp login`.");
|
|
87
|
+
}
|
|
88
|
+
let data;
|
|
89
|
+
try {
|
|
90
|
+
const raw = await response.text();
|
|
91
|
+
if (raw.length > MAX_RESPONSE_BYTES)
|
|
92
|
+
throw new Error("response too large");
|
|
93
|
+
data = JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new McpOAuthError("Hosted MCP OAuth returned an invalid response. Retry `hq mcp login`.");
|
|
97
|
+
}
|
|
98
|
+
const result = asObject(data);
|
|
99
|
+
if (!result)
|
|
100
|
+
throw new McpOAuthError("Hosted MCP OAuth returned an invalid response. Retry `hq mcp login`.");
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
function validTokens(data, now, previous) {
|
|
104
|
+
if (typeof data.access_token !== "string" || !data.access_token ||
|
|
105
|
+
typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0)
|
|
106
|
+
throw new McpOAuthError("Hosted MCP OAuth did not return a usable access token.");
|
|
107
|
+
const refreshToken = typeof data.refresh_token === "string" && data.refresh_token
|
|
108
|
+
? data.refresh_token
|
|
109
|
+
: previous?.refreshToken;
|
|
110
|
+
if (!refreshToken)
|
|
111
|
+
throw new McpOAuthError("Hosted MCP OAuth did not return a refresh token. Run `hq mcp login` again.");
|
|
112
|
+
return {
|
|
113
|
+
version: 1,
|
|
114
|
+
origin: previous?.origin ?? "",
|
|
115
|
+
clientId: previous?.clientId ?? "",
|
|
116
|
+
accessToken: data.access_token,
|
|
117
|
+
refreshToken,
|
|
118
|
+
expiresAt: now + data.expires_in * 1000,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function oauthUrl(origin, endpoint) {
|
|
122
|
+
return new URL(endpoint, origin);
|
|
123
|
+
}
|
|
124
|
+
function newPkce() {
|
|
125
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
126
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
127
|
+
}
|
|
128
|
+
async function runLogin(options) {
|
|
129
|
+
const env = options.env ?? process.env;
|
|
130
|
+
if (!(options.isEnabled ?? (() => anywhereRuntimeEnabled(env)))()) {
|
|
131
|
+
throw new McpOAuthError("HQ Anywhere is not enabled. Enable the hq-anywhere-runtime flag to use `hq mcp login`.");
|
|
132
|
+
}
|
|
133
|
+
if ((options.isMachine ?? (() => resolveCognitoTokenSource(env) === "machine"))()) {
|
|
134
|
+
throw new McpOAuthError("`hq mcp login` is for person accounts; this box uses machine credentials.");
|
|
135
|
+
}
|
|
136
|
+
const origin = options.oauthOrigin ?? resolveHqMcpOAuthOrigin(env);
|
|
137
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
138
|
+
const output = options.writeOutput ?? ((message) => process.stdout.write(`${message}\n`));
|
|
139
|
+
const pkce = newPkce();
|
|
140
|
+
const state = randomBytes(24).toString("base64url");
|
|
141
|
+
let authorizationCode;
|
|
142
|
+
let callbackError;
|
|
143
|
+
let resolveCallback;
|
|
144
|
+
const callback = new Promise((resolve) => { resolveCallback = resolve; });
|
|
145
|
+
const server = http.createServer((request, response) => {
|
|
146
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
147
|
+
if (request.method !== "GET" || url.pathname !== "/oauth/callback") {
|
|
148
|
+
response.writeHead(404).end("Not found");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (url.searchParams.get("state") !== state) {
|
|
152
|
+
callbackError = new Error("state");
|
|
153
|
+
}
|
|
154
|
+
else if (url.searchParams.has("error")) {
|
|
155
|
+
callbackError = new Error("authorization");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
authorizationCode = url.searchParams.get("code") ?? undefined;
|
|
159
|
+
if (!authorizationCode)
|
|
160
|
+
callbackError = new Error("code");
|
|
161
|
+
}
|
|
162
|
+
response.writeHead(callbackError ? 400 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
163
|
+
response.end(callbackError ? "Sign-in failed. Return to the HQ CLI." : "Sign-in complete. Return to the HQ CLI.");
|
|
164
|
+
resolveCallback();
|
|
165
|
+
});
|
|
166
|
+
let timeout;
|
|
167
|
+
try {
|
|
168
|
+
await new Promise((resolve, reject) => {
|
|
169
|
+
server.once("error", reject);
|
|
170
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
171
|
+
});
|
|
172
|
+
const address = server.address();
|
|
173
|
+
if (!address || typeof address === "string")
|
|
174
|
+
throw new McpOAuthError("Could not start the local OAuth callback listener.");
|
|
175
|
+
const redirectUri = `http://127.0.0.1:${address.port}/oauth/callback`;
|
|
176
|
+
let registrationResponse;
|
|
177
|
+
try {
|
|
178
|
+
registrationResponse = await fetchImpl(oauthUrl(origin, "/oauth/register"), {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: { "content-type": "application/json" },
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
redirect_uris: [redirectUri], token_endpoint_auth_method: "none",
|
|
183
|
+
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
|
184
|
+
client_name: "HQ CLI",
|
|
185
|
+
}),
|
|
186
|
+
signal: AbortSignal.timeout(30_000),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new McpOAuthError("Hosted MCP client registration failed. Retry `hq mcp login`.");
|
|
191
|
+
}
|
|
192
|
+
if (!registrationResponse.ok)
|
|
193
|
+
throw new McpOAuthError(`Hosted MCP client registration failed (HTTP ${registrationResponse.status}).`);
|
|
194
|
+
let registration;
|
|
195
|
+
try {
|
|
196
|
+
registration = asObject(await registrationResponse.json());
|
|
197
|
+
}
|
|
198
|
+
catch { /* handled below */ }
|
|
199
|
+
if (!registration || typeof registration.client_id !== "string" || !registration.client_id.startsWith("dcr_")) {
|
|
200
|
+
throw new McpOAuthError("Hosted MCP returned an invalid client registration.");
|
|
201
|
+
}
|
|
202
|
+
const clientId = registration.client_id;
|
|
203
|
+
const authorize = oauthUrl(origin, "/oauth/authorize");
|
|
204
|
+
authorize.search = new URLSearchParams({
|
|
205
|
+
response_type: "code", client_id: clientId, redirect_uri: redirectUri,
|
|
206
|
+
code_challenge: pkce.challenge, code_challenge_method: "S256", state, scope: MCP_SCOPE,
|
|
207
|
+
}).toString();
|
|
208
|
+
const remoteShell = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
209
|
+
if (remoteShell) {
|
|
210
|
+
output(`Open this URL on your workstation to sign in to hosted hq-mcp:\n${authorize.toString()}\nForward the callback port first with: ssh -L ${address.port}:127.0.0.1:${address.port} <this-host>`);
|
|
211
|
+
if (options.openBrowser)
|
|
212
|
+
await options.openBrowser(authorize.toString());
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
try {
|
|
216
|
+
await (options.openBrowser ?? ((url) => open(url)))(authorize.toString());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
output(`Open this URL to sign in to hosted hq-mcp:\n${authorize.toString()}`);
|
|
220
|
+
}
|
|
221
|
+
timeout = setTimeout(() => {
|
|
222
|
+
callbackError = new Error("timeout");
|
|
223
|
+
resolveCallback();
|
|
224
|
+
}, options.callbackTimeoutMs ?? DEFAULT_CALLBACK_TIMEOUT_MS);
|
|
225
|
+
await callback;
|
|
226
|
+
if (callbackError || !authorizationCode)
|
|
227
|
+
throw new McpOAuthError("Hosted MCP sign-in did not complete. Retry `hq mcp login`.");
|
|
228
|
+
const tokens = await postForm(oauthUrl(origin, "/oauth/token"), new URLSearchParams({
|
|
229
|
+
grant_type: "authorization_code", client_id: clientId, code: authorizationCode,
|
|
230
|
+
redirect_uri: redirectUri, code_verifier: pkce.verifier,
|
|
231
|
+
}), fetchImpl);
|
|
232
|
+
const now = (options.now ?? Date.now)();
|
|
233
|
+
const cache = validTokens(tokens, now);
|
|
234
|
+
cache.origin = origin.origin;
|
|
235
|
+
cache.clientId = clientId;
|
|
236
|
+
return cache;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
if (timeout)
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export function mcpOAuthCacheFile(options = {}) {
|
|
245
|
+
const env = options.env ?? process.env;
|
|
246
|
+
return cacheFile(env, options.home ?? os.homedir(), options.oauthOrigin ?? resolveHqMcpOAuthOrigin(env));
|
|
247
|
+
}
|
|
248
|
+
/** Perform hosted DCR + PKCE login and cache only the hq-mcp audience tokens. */
|
|
249
|
+
export async function loginHostedMcp(options = {}) {
|
|
250
|
+
const cache = await runLogin(options);
|
|
251
|
+
writeCache(mcpOAuthCacheFile(options), cache);
|
|
252
|
+
const writeOutput = options.writeOutput ?? ((message) => process.stdout.write(`${message}\n`));
|
|
253
|
+
writeOutput("Signed in to hosted hq-mcp. The MCP audience token is stored in the private HQ token cache.");
|
|
254
|
+
}
|
|
255
|
+
/** Load or refresh a person's hosted MCP token without printing token material. */
|
|
256
|
+
export async function getMcpAudienceToken(options = {}) {
|
|
257
|
+
const file = mcpOAuthCacheFile(options);
|
|
258
|
+
const cache = readCache(file);
|
|
259
|
+
const now = (options.now ?? Date.now)();
|
|
260
|
+
if (!cache || cache.origin !== (options.oauthOrigin ?? resolveHqMcpOAuthOrigin(options.env ?? process.env)).origin)
|
|
261
|
+
throw new McpOAuthError("Sign in to hosted hq-mcp with `hq mcp login` first.");
|
|
262
|
+
if (cache.expiresAt > now + EXPIRY_SKEW_MS)
|
|
263
|
+
return cache.accessToken;
|
|
264
|
+
const origin = options.oauthOrigin ?? resolveHqMcpOAuthOrigin(options.env ?? process.env);
|
|
265
|
+
const tokens = await postForm(oauthUrl(origin, "/oauth/token"), new URLSearchParams({
|
|
266
|
+
grant_type: "refresh_token", client_id: cache.clientId, refresh_token: cache.refreshToken,
|
|
267
|
+
}), options.fetchImpl ?? fetch);
|
|
268
|
+
const refreshed = validTokens(tokens, now, cache);
|
|
269
|
+
refreshed.clientId = cache.clientId;
|
|
270
|
+
writeCache(file, refreshed);
|
|
271
|
+
return refreshed.accessToken;
|
|
272
|
+
}
|
|
273
|
+
/** Remove all per-origin hosted MCP OAuth caches during either HQ logout flow. */
|
|
274
|
+
export function clearMcpOAuthCaches(options = {}) {
|
|
275
|
+
const env = options.env ?? process.env;
|
|
276
|
+
const stateDir = path.resolve(env.HQ_STATE_DIR?.trim() || path.join(options.home ?? os.homedir(), ".hq"));
|
|
277
|
+
let entries;
|
|
278
|
+
try {
|
|
279
|
+
entries = fs.readdirSync(stateDir);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code === "ENOENT")
|
|
283
|
+
return;
|
|
284
|
+
throw new McpOAuthError("The hosted MCP token cache could not be cleared. Check its directory permissions.");
|
|
285
|
+
}
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (entry !== "mcp-oauth-tokens.json" && !/^mcp-oauth-tokens-[a-f0-9]{16}\.json$/.test(entry))
|
|
288
|
+
continue;
|
|
289
|
+
try {
|
|
290
|
+
fs.unlinkSync(path.join(stateDir, entry));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error.code !== "ENOENT")
|
|
294
|
+
throw new McpOAuthError("The hosted MCP token cache could not be cleared. Check its file permissions.");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=mcp-oauth.js.map
|
|
@@ -13,7 +13,6 @@ export interface McpToolResult {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function jsonResult(value: unknown): McpToolResult;
|
|
15
15
|
export declare const HQ_MCP_HTTP_TIMEOUT_MS = 30000;
|
|
16
|
-
/** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
|
|
17
16
|
export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
|
|
18
17
|
export interface HqMcpBridgeOptions {
|
|
19
18
|
endpoint?: URL;
|