@indigoai-us/hq-cli 5.61.0 → 5.62.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/dist/commands/agents.d.ts +109 -0
- package/dist/commands/agents.js +385 -0
- package/dist/commands/db-migrate.d.ts +6 -0
- package/dist/commands/db-migrate.js +42 -0
- package/dist/commands/db-provision.d.ts +15 -0
- package/dist/commands/db-provision.js +78 -0
- package/dist/commands/db-sql.d.ts +9 -0
- package/dist/commands/db-sql.js +81 -0
- package/dist/commands/db-status.d.ts +7 -0
- package/dist/commands/db-status.js +70 -0
- package/dist/commands/db.d.ts +9 -0
- package/dist/commands/db.js +23 -0
- package/dist/commands/integrations.d.ts +78 -0
- package/dist/commands/integrations.js +309 -0
- package/dist/commands/members.js +4 -4
- package/dist/commands/outposts.d.ts +60 -0
- package/dist/commands/outposts.js +255 -0
- package/dist/commands/secrets.d.ts +8 -0
- package/dist/commands/secrets.js +23 -8
- package/dist/commands/skill.d.ts +153 -0
- package/dist/commands/skill.js +593 -0
- package/dist/commands/workers.d.ts +48 -0
- package/dist/commands/workers.js +229 -0
- package/dist/lib/db/control-plane.d.ts +45 -0
- package/dist/lib/db/control-plane.js +81 -0
- package/dist/lib/db/local.d.ts +49 -0
- package/dist/lib/db/local.js +106 -0
- package/dist/lib/db/migrate.d.ts +41 -0
- package/dist/lib/db/migrate.js +104 -0
- package/dist/lib/db/paths.d.ts +56 -0
- package/dist/lib/db/paths.js +103 -0
- package/dist/lib/db/remote-engine.d.ts +58 -0
- package/dist/lib/db/remote-engine.js +90 -0
- package/dist/lib/db/remote-sql.d.ts +22 -0
- package/dist/lib/db/remote-sql.js +39 -0
- package/dist/lib/db/sql.d.ts +49 -0
- package/dist/lib/db/sql.js +132 -0
- package/dist/main.js +27 -2
- package/dist/utils/cognito-session.js +3 -3
- package/dist/utils/sandbox-runner-client.js +3 -3
- package/package.json +9 -1
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/agents.test.ts +297 -0
- package/src/commands/agents.ts +561 -0
- package/src/commands/db-migrate.ts +55 -0
- package/src/commands/db-provision.ts +102 -0
- package/src/commands/db-sql.ts +124 -0
- package/src/commands/db-status.ts +100 -0
- package/src/commands/db.ts +26 -0
- package/src/commands/integrations.test.ts +284 -0
- package/src/commands/integrations.ts +438 -0
- package/src/commands/members.ts +2 -2
- package/src/commands/outposts.test.ts +177 -0
- package/src/commands/outposts.ts +338 -0
- package/src/commands/secrets.parse-destination.test.ts +38 -0
- package/src/commands/secrets.test.ts +24 -0
- package/src/commands/secrets.ts +30 -10
- package/src/commands/skill.test.ts +770 -0
- package/src/commands/skill.ts +796 -0
- package/src/commands/workers.test.ts +158 -0
- package/src/commands/workers.ts +298 -0
- package/src/lib/db/control-plane.test.ts +59 -0
- package/src/lib/db/control-plane.ts +113 -0
- package/src/lib/db/local.test.ts +81 -0
- package/src/lib/db/local.ts +148 -0
- package/src/lib/db/migrate.test.ts +133 -0
- package/src/lib/db/migrate.ts +137 -0
- package/src/lib/db/paths.test.ts +112 -0
- package/src/lib/db/paths.ts +128 -0
- package/src/lib/db/remote-engine.test.ts +44 -0
- package/src/lib/db/remote-engine.ts +148 -0
- package/src/lib/db/remote-sql.test.ts +32 -0
- package/src/lib/db/remote-sql.ts +62 -0
- package/src/lib/db/sql.test.ts +106 -0
- package/src/lib/db/sql.ts +192 -0
- package/src/main.ts +31 -0
- package/src/utils/cognito-session.ts +1 -1
- package/src/utils/sandbox-runner-client.ts +1 -1
- package/test/commands/db-tenant-isolation.test.ts +94 -0
- package/test/commands/db.test.ts +85 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq db provision — request remote vault DB binding (US-009).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
|
|
8
|
+
import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
|
|
9
|
+
|
|
10
|
+
export interface DbProvisionCommandDeps {
|
|
11
|
+
resolveCompany: (slug: string) => Promise<{ companyUid: string; companySlug: string }>;
|
|
12
|
+
getAccessToken: () => Promise<string>;
|
|
13
|
+
apiBaseUrl: string;
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultDeps = (): DbProvisionCommandDeps => ({
|
|
18
|
+
async resolveCompany(slug) {
|
|
19
|
+
// Production path: resolve via vault API membership. Tests inject mocks.
|
|
20
|
+
// Keep slug as placeholder uid only when HQ_DB_MOCK_CONTROL_PLANE=1.
|
|
21
|
+
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
22
|
+
return { companyUid: `cmp_${slug}`, companySlug: slug };
|
|
23
|
+
}
|
|
24
|
+
throw new Error(
|
|
25
|
+
"company resolution not wired in this build path — inject deps or set HQ_DB_MOCK_CONTROL_PLANE=1 for local mock",
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
async getAccessToken() {
|
|
29
|
+
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
30
|
+
return "mock-token";
|
|
31
|
+
}
|
|
32
|
+
throw new Error("auth token resolution not wired — inject deps for production");
|
|
33
|
+
},
|
|
34
|
+
apiBaseUrl:
|
|
35
|
+
process.env.HQ_API_BASE_URL ||
|
|
36
|
+
process.env.HQ_CLOUD_API_URL ||
|
|
37
|
+
"https://hqapi.getindigo.ai",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export function registerDbProvisionCommand(
|
|
41
|
+
db: Command,
|
|
42
|
+
depsFactory: () => DbProvisionCommandDeps = defaultDeps,
|
|
43
|
+
): void {
|
|
44
|
+
db.command("provision")
|
|
45
|
+
.description(
|
|
46
|
+
"Provision (or re-bind) the company remote vault DB via HQ control plane",
|
|
47
|
+
)
|
|
48
|
+
.requiredOption("--company <slug>", "Company slug")
|
|
49
|
+
.option("--region <region>", "AWS region", "us-east-1")
|
|
50
|
+
.action(async (opts: { company: string; region?: string }) => {
|
|
51
|
+
try {
|
|
52
|
+
const deps = depsFactory();
|
|
53
|
+
const company = String(opts.company ?? "").trim();
|
|
54
|
+
if (!company) {
|
|
55
|
+
console.error(chalk.red("Error: --company is required"));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resolved = await deps.resolveCompany(company);
|
|
61
|
+
const client = new ControlPlaneDbClient({
|
|
62
|
+
baseUrl: deps.apiBaseUrl,
|
|
63
|
+
getAccessToken: deps.getAccessToken,
|
|
64
|
+
fetchImpl: deps.fetchImpl,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await client.provision({
|
|
68
|
+
companyUid: resolved.companyUid,
|
|
69
|
+
companySlug: resolved.companySlug,
|
|
70
|
+
region: opts.region,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
console.log(`company: ${result.companySlug}`);
|
|
74
|
+
console.log(`remote: ${result.status}`);
|
|
75
|
+
console.log(`engine: ${result.engineId}`);
|
|
76
|
+
console.log(`region: ${result.region}`);
|
|
77
|
+
console.log(`resourceArn: ${result.resourceArn}`);
|
|
78
|
+
console.log(`secretRef: ${result.secretRef}`);
|
|
79
|
+
console.log(`idempotent: ${result.idempotent ? "yes" : "no"}`);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
82
|
+
const status = (error as { status?: number }).status;
|
|
83
|
+
if (status === 402 || /PLAN_REQUIRED|Team plan|\$500/i.test(msg)) {
|
|
84
|
+
console.error(
|
|
85
|
+
chalk.red("Error:"),
|
|
86
|
+
"Remote vault DB requires the HQ Team plan ($500/mo).",
|
|
87
|
+
);
|
|
88
|
+
console.error(
|
|
89
|
+
chalk.dim(
|
|
90
|
+
"Local databases still work: hq db status|sql|migrate — no Team plan required.",
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
console.error(chalk.red("Error:"), msg);
|
|
95
|
+
}
|
|
96
|
+
if (/postgres:\/\//i.test(msg)) {
|
|
97
|
+
console.error(chalk.red("Error: refused to print secret material"));
|
|
98
|
+
}
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq db sql — run SQL against the company local vault DB (US-004).
|
|
3
|
+
*
|
|
4
|
+
* Default is read-only. Prefer `hq db migrate` for schema changes.
|
|
5
|
+
* Never prints remote connection strings.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
|
|
12
|
+
import { runRemoteSql } from "../lib/db/remote-sql.js";
|
|
13
|
+
import { formatSqlResult, runLocalSql } from "../lib/db/sql.js";
|
|
14
|
+
|
|
15
|
+
function readSqlArg(sqlArgs: string[], stdinFlag: boolean): string {
|
|
16
|
+
if (stdinFlag || sqlArgs.length === 0) {
|
|
17
|
+
if (process.stdin.isTTY && sqlArgs.length === 0) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"SQL required: pass as arguments after -- or pipe via stdin",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return fs.readFileSync(0, "utf8");
|
|
23
|
+
}
|
|
24
|
+
return sqlArgs.join(" ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerDbSqlCommand(db: Command): void {
|
|
28
|
+
db.command("sql")
|
|
29
|
+
.description(
|
|
30
|
+
"Run SQL against the company vault DB (local default; --remote for secrets-injected remote; read-only by default)",
|
|
31
|
+
)
|
|
32
|
+
.requiredOption("--company <slug>", "Company slug (tenant scope; required)")
|
|
33
|
+
.option("--write", "Allow write/DDL statements (discouraged for ad-hoc DDL)", false)
|
|
34
|
+
.option(
|
|
35
|
+
"--remote",
|
|
36
|
+
"Use remote tier via vault-bound credentials (not auto-replicated with local)",
|
|
37
|
+
false,
|
|
38
|
+
)
|
|
39
|
+
.option(
|
|
40
|
+
"--db-path <path>",
|
|
41
|
+
"Dangerous: open a non-canonical DB path (denied unless --allow-cross-company-path)",
|
|
42
|
+
)
|
|
43
|
+
.option(
|
|
44
|
+
"--allow-cross-company-path",
|
|
45
|
+
"Dangerous: permit --db-path outside this company's canonical local DB",
|
|
46
|
+
false,
|
|
47
|
+
)
|
|
48
|
+
.option("--format <fmt>", "Output format: jsonl | table", "jsonl")
|
|
49
|
+
.option("--home <path>", "Override home for local DB root (tests)")
|
|
50
|
+
.option("--stdin", "Read SQL from stdin", false)
|
|
51
|
+
.argument("[sql...]", "SQL statement (or use --stdin / pipe)")
|
|
52
|
+
.action(
|
|
53
|
+
async (
|
|
54
|
+
sqlParts: string[],
|
|
55
|
+
opts: {
|
|
56
|
+
company: string;
|
|
57
|
+
write?: boolean;
|
|
58
|
+
remote?: boolean;
|
|
59
|
+
dbPath?: string;
|
|
60
|
+
allowCrossCompanyPath?: boolean;
|
|
61
|
+
format?: string;
|
|
62
|
+
home?: string;
|
|
63
|
+
stdin?: boolean;
|
|
64
|
+
},
|
|
65
|
+
) => {
|
|
66
|
+
try {
|
|
67
|
+
const company = String(opts.company ?? "").trim();
|
|
68
|
+
if (!company) {
|
|
69
|
+
console.error(chalk.red("Error: --company is required"));
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sql = readSqlArg(sqlParts, !!opts.stdin).trim();
|
|
75
|
+
const format =
|
|
76
|
+
opts.format === "table" ? ("table" as const) : ("jsonl" as const);
|
|
77
|
+
|
|
78
|
+
if (opts.remote) {
|
|
79
|
+
// Local and remote are not auto-replicated (v1 limitation).
|
|
80
|
+
const remoteResult = await runRemoteSql({
|
|
81
|
+
company,
|
|
82
|
+
sql,
|
|
83
|
+
getConnectionConfig: async () => {
|
|
84
|
+
throw new Error("no remote binding");
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
console.log(
|
|
88
|
+
formatSqlResult(
|
|
89
|
+
{
|
|
90
|
+
columns:
|
|
91
|
+
remoteResult.rows[0]
|
|
92
|
+
? Object.keys(remoteResult.rows[0])
|
|
93
|
+
: [],
|
|
94
|
+
rows: remoteResult.rows,
|
|
95
|
+
changes: 0,
|
|
96
|
+
readonly: true,
|
|
97
|
+
},
|
|
98
|
+
format,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = runLocalSql({
|
|
105
|
+
company,
|
|
106
|
+
sql,
|
|
107
|
+
write: !!opts.write,
|
|
108
|
+
dbPathOverride: opts.dbPath,
|
|
109
|
+
allowCrossCompanyPath: !!opts.allowCrossCompanyPath,
|
|
110
|
+
format,
|
|
111
|
+
...(opts.home ? { home: opts.home } : {}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log(formatSqlResult(result, format));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(
|
|
117
|
+
chalk.red("Error:"),
|
|
118
|
+
error instanceof Error ? error.message : "Unknown error",
|
|
119
|
+
);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq db status — ensure local vault DB exists and report health.
|
|
3
|
+
* Optionally reports remote tier when control plane returns a binding (US-009).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
|
|
9
|
+
import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
|
|
10
|
+
import {
|
|
11
|
+
ensureAndStatusLocalDb,
|
|
12
|
+
formatLocalDbStatus,
|
|
13
|
+
} from "../lib/db/local.js";
|
|
14
|
+
|
|
15
|
+
export function registerDbStatusCommand(db: Command): void {
|
|
16
|
+
db.command("status")
|
|
17
|
+
.description(
|
|
18
|
+
"Show local (and remote when bound) vault DB status; auto-creates local SQLite if missing",
|
|
19
|
+
)
|
|
20
|
+
.requiredOption(
|
|
21
|
+
"--company <slug>",
|
|
22
|
+
"Company slug (tenant scope; required)",
|
|
23
|
+
)
|
|
24
|
+
.option(
|
|
25
|
+
"--home <path>",
|
|
26
|
+
"Override home directory for local DB root (tests / advanced only)",
|
|
27
|
+
)
|
|
28
|
+
.option(
|
|
29
|
+
"--remote",
|
|
30
|
+
"Also query control plane for remote tier status (requires auth)",
|
|
31
|
+
false,
|
|
32
|
+
)
|
|
33
|
+
.option("--company-uid <uid>", "Company uid for remote status (with --remote)")
|
|
34
|
+
.action(
|
|
35
|
+
async (opts: {
|
|
36
|
+
company: string;
|
|
37
|
+
home?: string;
|
|
38
|
+
remote?: boolean;
|
|
39
|
+
companyUid?: string;
|
|
40
|
+
}) => {
|
|
41
|
+
try {
|
|
42
|
+
const company = String(opts.company ?? "").trim();
|
|
43
|
+
if (!company) {
|
|
44
|
+
console.error(chalk.red("Error: --company is required"));
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const status = ensureAndStatusLocalDb(
|
|
50
|
+
company,
|
|
51
|
+
opts.home ? { home: opts.home } : undefined,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Never print remote connection material.
|
|
55
|
+
console.log(formatLocalDbStatus(status));
|
|
56
|
+
|
|
57
|
+
if (opts.remote) {
|
|
58
|
+
const companyUid = opts.companyUid || `cmp_${company}`;
|
|
59
|
+
const baseUrl =
|
|
60
|
+
process.env.HQ_API_BASE_URL ||
|
|
61
|
+
process.env.HQ_CLOUD_API_URL ||
|
|
62
|
+
"https://hqapi.getindigo.ai";
|
|
63
|
+
try {
|
|
64
|
+
const client = new ControlPlaneDbClient({
|
|
65
|
+
baseUrl,
|
|
66
|
+
getAccessToken: async () => {
|
|
67
|
+
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
68
|
+
return "mock-token";
|
|
69
|
+
}
|
|
70
|
+
throw new Error("auth required for remote status");
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const remote = await client.status(companyUid);
|
|
74
|
+
if (!remote.remote) {
|
|
75
|
+
console.log("remote: (none)");
|
|
76
|
+
} else {
|
|
77
|
+
console.log(`remote: ${remote.remote.status}`);
|
|
78
|
+
console.log(`remoteEngine: ${remote.remote.engineId}`);
|
|
79
|
+
console.log(`remoteRegion: ${remote.remote.region}`);
|
|
80
|
+
console.log(`remoteResourceArn: ${remote.remote.resourceArn}`);
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const msg = e instanceof Error ? e.message : "remote status failed";
|
|
84
|
+
console.log(`remote: error (${msg})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!status.healthy) {
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(
|
|
93
|
+
chalk.red("Error:"),
|
|
94
|
+
error instanceof Error ? error.message : "Unknown error",
|
|
95
|
+
);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq db — vault database commands (local + remote tiers).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
import { registerDbMigrateCommand } from "./db-migrate.js";
|
|
8
|
+
import { registerDbProvisionCommand } from "./db-provision.js";
|
|
9
|
+
import { registerDbSqlCommand } from "./db-sql.js";
|
|
10
|
+
import { registerDbStatusCommand } from "./db-status.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register the `hq db` command group and its subcommands.
|
|
14
|
+
*/
|
|
15
|
+
export function registerDbCommand(program: Command): void {
|
|
16
|
+
const db = program
|
|
17
|
+
.command("db")
|
|
18
|
+
.description(
|
|
19
|
+
"Vault databases — local SQLite per company and HQ-managed remote Postgres-class DBs",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
registerDbStatusCommand(db);
|
|
23
|
+
registerDbSqlCommand(db);
|
|
24
|
+
registerDbMigrateCommand(db);
|
|
25
|
+
registerDbProvisionCommand(db);
|
|
26
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq integrations list|tools|call|approve` (integrations.ts).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - toolPrefixForProvider — factory-prefix stripping + slug normalization
|
|
6
|
+
* (must mirror hq-pro's factoryToolPrefix so composed HQ tool names hit
|
|
7
|
+
* registered gateway tools)
|
|
8
|
+
* - selectConnection — by id, by provider (factory:-prefixed match),
|
|
9
|
+
* single-active default, and the guiding errors for none/ambiguous
|
|
10
|
+
* - unwrapGatewayResult — MCP text-content unwrapping + non-JSON fallback
|
|
11
|
+
* - queued-outcome detection + the approve hint in `call`
|
|
12
|
+
* - approve — posts to the confirm route and prints the unwrapped result
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
|
17
|
+
|
|
18
|
+
vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
19
|
+
const original =
|
|
20
|
+
await importOriginal<typeof import("../utils/cognito-session.js")>();
|
|
21
|
+
return {
|
|
22
|
+
...original,
|
|
23
|
+
ensureCognitoIdToken: vi.fn(async () => "test-id-token"),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock("../utils/vault-api.js", async (importOriginal) => {
|
|
28
|
+
const original = await importOriginal<typeof import("../utils/vault-api.js")>();
|
|
29
|
+
return {
|
|
30
|
+
...original,
|
|
31
|
+
getCompanyUid: vi.fn(async () => "cmp_acme"),
|
|
32
|
+
vaultApiFetch: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
37
|
+
import {
|
|
38
|
+
IntegrationsCliError,
|
|
39
|
+
queuedOutcome,
|
|
40
|
+
registerIntegrationsCommand,
|
|
41
|
+
selectConnection,
|
|
42
|
+
toolPrefixForProvider,
|
|
43
|
+
unwrapGatewayResult,
|
|
44
|
+
} from "./integrations.js";
|
|
45
|
+
|
|
46
|
+
const vaultApiFetchMock = vi.mocked(vaultApiFetch);
|
|
47
|
+
|
|
48
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
49
|
+
return {
|
|
50
|
+
ok: status >= 200 && status < 300,
|
|
51
|
+
status,
|
|
52
|
+
json: async () => body,
|
|
53
|
+
} as unknown as Response;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function connectionsResponse() {
|
|
57
|
+
return jsonResponse({
|
|
58
|
+
connections: [
|
|
59
|
+
{
|
|
60
|
+
id: "acct_factory_linear",
|
|
61
|
+
provider: "factory:linear",
|
|
62
|
+
status: "connected",
|
|
63
|
+
writePolicy: "confirm",
|
|
64
|
+
installation: { displayName: "Linear MCP server", status: "installed" },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function gatewayTextResponse(payload: unknown) {
|
|
71
|
+
return jsonResponse({
|
|
72
|
+
jsonrpc: "2.0",
|
|
73
|
+
id: "x",
|
|
74
|
+
result: { content: [{ type: "text", text: JSON.stringify(payload) }] },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runCli(argv: string[]): Promise<void> {
|
|
79
|
+
const program = new Command();
|
|
80
|
+
program.exitOverride();
|
|
81
|
+
registerIntegrationsCommand(program);
|
|
82
|
+
await program.parseAsync(["node", "hq", ...argv]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let logSpy: MockInstance;
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
logSpy.mockRestore();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function logged(): string {
|
|
95
|
+
return logSpy.mock.calls.map((call) => call.join(" ")).join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("toolPrefixForProvider", () => {
|
|
99
|
+
it("strips the factory: prefix and normalizes to the gateway's tool prefix", () => {
|
|
100
|
+
expect(toolPrefixForProvider("factory:linear")).toBe("linear");
|
|
101
|
+
expect(toolPrefixForProvider("factory:google-workspace")).toBe("google.workspace");
|
|
102
|
+
expect(toolPrefixForProvider("Linear")).toBe("linear");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("selectConnection", () => {
|
|
107
|
+
const linear = {
|
|
108
|
+
id: "acct_1",
|
|
109
|
+
provider: "factory:linear",
|
|
110
|
+
status: "connected",
|
|
111
|
+
};
|
|
112
|
+
const notion = {
|
|
113
|
+
id: "acct_2",
|
|
114
|
+
provider: "factory:notion",
|
|
115
|
+
status: "connected",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
it("matches --provider against the bare factory slug", () => {
|
|
119
|
+
expect(selectConnection([linear, notion], { provider: "linear" })).toBe(linear);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("defaults to the single active connection", () => {
|
|
123
|
+
expect(selectConnection([linear], {})).toBe(linear);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("guides on ambiguity and on unknown providers", () => {
|
|
127
|
+
expect(() => selectConnection([linear, notion], {})).toThrow(/--provider/);
|
|
128
|
+
expect(() => selectConnection([linear], { provider: "jira" })).toThrow(
|
|
129
|
+
/Connected: linear/,
|
|
130
|
+
);
|
|
131
|
+
expect(() => selectConnection([], {})).toThrow(/No connected apps yet/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("resolves an explicit connection id even when revoked", () => {
|
|
135
|
+
const revoked = { ...linear, status: "revoked" };
|
|
136
|
+
expect(selectConnection([revoked], { connection: "acct_1" })).toBe(revoked);
|
|
137
|
+
expect(() => selectConnection([revoked], { connection: "acct_9" })).toThrow(
|
|
138
|
+
IntegrationsCliError,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("unwrapGatewayResult + queuedOutcome", () => {
|
|
144
|
+
it("unwraps MCP text content into the inner JSON payload", () => {
|
|
145
|
+
expect(
|
|
146
|
+
unwrapGatewayResult({
|
|
147
|
+
content: [{ type: "text", text: '{"tools":[{"name":"list_issues"}]}' }],
|
|
148
|
+
}),
|
|
149
|
+
).toEqual({ tools: [{ name: "list_issues" }] });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("falls back to raw text / raw result on non-JSON shapes", () => {
|
|
153
|
+
expect(
|
|
154
|
+
unwrapGatewayResult({ content: [{ type: "text", text: "plain" }] }),
|
|
155
|
+
).toBe("plain");
|
|
156
|
+
expect(unwrapGatewayResult({ other: 1 })).toEqual({ other: 1 });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("detects the queued-for-approval outcome", () => {
|
|
160
|
+
expect(
|
|
161
|
+
queuedOutcome({ queuedForApproval: true, queueId: "cq_1" }),
|
|
162
|
+
).toMatchObject({ queueId: "cq_1" });
|
|
163
|
+
expect(queuedOutcome({ issues: [] })).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("hq integrations tools", () => {
|
|
168
|
+
it("composes the read tool name from the connection's provider and lists upstream tools", async () => {
|
|
169
|
+
vaultApiFetchMock
|
|
170
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
171
|
+
.mockResolvedValueOnce(
|
|
172
|
+
gatewayTextResponse({
|
|
173
|
+
tools: [{ name: "list_issues", title: "List issues" }],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await runCli(["integrations", "tools", "--provider", "linear"]);
|
|
178
|
+
|
|
179
|
+
const gatewayCall = vaultApiFetchMock.mock.calls[1]![0];
|
|
180
|
+
expect(gatewayCall).toMatchObject({
|
|
181
|
+
path: "/v1/integrations/mcp",
|
|
182
|
+
method: "POST",
|
|
183
|
+
});
|
|
184
|
+
expect(gatewayCall.body).toMatchObject({
|
|
185
|
+
method: "tools/call",
|
|
186
|
+
params: {
|
|
187
|
+
companyUid: "cmp_acme",
|
|
188
|
+
name: "linear.mcp.tools.list",
|
|
189
|
+
arguments: { companyUid: "cmp_acme", connectionId: "acct_factory_linear" },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
expect(logged()).toContain("list_issues");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("hq integrations call", () => {
|
|
197
|
+
it("sends the upstream tool + args + idempotency key and prints the result", async () => {
|
|
198
|
+
vaultApiFetchMock
|
|
199
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
200
|
+
.mockResolvedValueOnce(gatewayTextResponse({ issues: [{ id: "DEV-1" }] }));
|
|
201
|
+
|
|
202
|
+
await runCli([
|
|
203
|
+
"integrations",
|
|
204
|
+
"call",
|
|
205
|
+
"list_issues",
|
|
206
|
+
"--provider",
|
|
207
|
+
"linear",
|
|
208
|
+
"--args",
|
|
209
|
+
'{"assignee":"me"}',
|
|
210
|
+
"--idempotency-key",
|
|
211
|
+
"stable-key",
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const gatewayCall = vaultApiFetchMock.mock.calls[1]![0];
|
|
215
|
+
expect(gatewayCall.body).toMatchObject({
|
|
216
|
+
params: {
|
|
217
|
+
name: "linear.mcp.tools.call",
|
|
218
|
+
arguments: {
|
|
219
|
+
toolName: "list_issues",
|
|
220
|
+
arguments: { assignee: "me" },
|
|
221
|
+
idempotencyKey: "stable-key",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(logged()).toContain("DEV-1");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("surfaces a queued outcome with the exact approve command", async () => {
|
|
229
|
+
vaultApiFetchMock
|
|
230
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
231
|
+
.mockResolvedValueOnce(
|
|
232
|
+
gatewayTextResponse({
|
|
233
|
+
queuedForApproval: true,
|
|
234
|
+
status: "queued",
|
|
235
|
+
queueId: "cq_123",
|
|
236
|
+
connectionId: "acct_factory_linear",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await runCli([
|
|
241
|
+
"integrations",
|
|
242
|
+
"call",
|
|
243
|
+
"save_issue",
|
|
244
|
+
"--provider",
|
|
245
|
+
"linear",
|
|
246
|
+
"--args",
|
|
247
|
+
"{}",
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const output = logged();
|
|
251
|
+
expect(output).toContain("Queued for approval");
|
|
252
|
+
expect(output).toContain("hq integrations approve cq_123 --provider linear");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects non-object --args with a usage hint", async () => {
|
|
256
|
+
await expect(
|
|
257
|
+
runCli(["integrations", "call", "t", "--provider", "linear", "--args", "[1]"]),
|
|
258
|
+
).rejects.toThrow(/--args must be a JSON object/);
|
|
259
|
+
expect(vaultApiFetchMock).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("hq integrations approve", () => {
|
|
264
|
+
it("posts the decision to the confirm route and prints the unwrapped result", async () => {
|
|
265
|
+
vaultApiFetchMock
|
|
266
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
267
|
+
.mockResolvedValueOnce(
|
|
268
|
+
jsonResponse({
|
|
269
|
+
status: "resumed",
|
|
270
|
+
result: { content: [{ type: "text", text: '{"issues":[]}' }] },
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await runCli(["integrations", "approve", "cq_123", "--provider", "linear"]);
|
|
275
|
+
|
|
276
|
+
const confirmCall = vaultApiFetchMock.mock.calls[1]![0];
|
|
277
|
+
expect(confirmCall).toMatchObject({
|
|
278
|
+
path: "/v1/integrations/confirm/cq_123/approve",
|
|
279
|
+
method: "POST",
|
|
280
|
+
body: { companyUid: "cmp_acme", connectionId: "acct_factory_linear" },
|
|
281
|
+
});
|
|
282
|
+
expect(logged()).toContain("Approved");
|
|
283
|
+
});
|
|
284
|
+
});
|