@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,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq integrations` subcommand group — use company-connected apps (the
|
|
3
|
+
* integration factory's governed gateway) from any HQ-authenticated session.
|
|
4
|
+
*
|
|
5
|
+
* The console connects apps (e.g. Linear via OAuth sign-in); cloud agents get
|
|
6
|
+
* their tools natively through hq-pro's integration MCP gateway
|
|
7
|
+
* (POST /v1/integrations/mcp). This command gives LOCAL sessions (Claude
|
|
8
|
+
* Code / Codex chats, scripts) the same first-class path — no hand-crafted
|
|
9
|
+
* authenticated curl calls.
|
|
10
|
+
*
|
|
11
|
+
* Subcommands:
|
|
12
|
+
* hq integrations list Connected apps for the company.
|
|
13
|
+
* hq integrations tools --provider <p> What the connected app can do.
|
|
14
|
+
* hq integrations call <tool> --provider <p> --args '<json>'
|
|
15
|
+
* Invoke one of the app's tools.
|
|
16
|
+
* hq integrations approve|reject <queueId> --provider <p>
|
|
17
|
+
* Decide a queued (approval-gated)
|
|
18
|
+
* call; approve executes it.
|
|
19
|
+
*
|
|
20
|
+
* Governance: reads flow freely; calls that can change the app are subject to
|
|
21
|
+
* the connection's write policy (default: a person approves first). A queued
|
|
22
|
+
* outcome is surfaced clearly with the exact approve command to run.
|
|
23
|
+
*
|
|
24
|
+
* Auth: Cognito ID token via the shared session cache (`hq auth refresh`
|
|
25
|
+
* semantics); company resolves like every other command — `--company <slug>`
|
|
26
|
+
* or the caller's single active membership. Never hardcoded.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { Command } from "commander";
|
|
31
|
+
import chalk from "chalk";
|
|
32
|
+
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
33
|
+
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
34
|
+
|
|
35
|
+
interface AdminConnection {
|
|
36
|
+
id: string;
|
|
37
|
+
provider: string;
|
|
38
|
+
status: string;
|
|
39
|
+
writePolicy?: string;
|
|
40
|
+
installation?: {
|
|
41
|
+
displayName?: string;
|
|
42
|
+
domain?: string;
|
|
43
|
+
status?: string;
|
|
44
|
+
} | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface GatewayMessage {
|
|
48
|
+
result?: unknown;
|
|
49
|
+
error?: { code?: number; message?: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class IntegrationsCliError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "IntegrationsCliError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
|
|
60
|
+
export function toolPrefixForProvider(provider: string): string {
|
|
61
|
+
return provider
|
|
62
|
+
.replace(/^factory:/, "")
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, ".");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchConnections(
|
|
69
|
+
token: string,
|
|
70
|
+
companyUid: string,
|
|
71
|
+
): Promise<AdminConnection[]> {
|
|
72
|
+
const res = await vaultApiFetch({
|
|
73
|
+
token,
|
|
74
|
+
path: "/v1/integrations/admin",
|
|
75
|
+
query: { companyUid },
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
79
|
+
throw new IntegrationsCliError(
|
|
80
|
+
body.error ?? `Failed to list integrations (HTTP ${res.status})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const data = (await res.json()) as { connections?: AdminConnection[] };
|
|
84
|
+
return data.connections ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve one connection by `--connection acct_…` or `--provider linear`
|
|
89
|
+
* (matches `factory:<slug>` and bare provider ids, case-insensitive). Errors
|
|
90
|
+
* list what IS connected so the fix is one command away.
|
|
91
|
+
*/
|
|
92
|
+
export function selectConnection(
|
|
93
|
+
connections: AdminConnection[],
|
|
94
|
+
opts: { connection?: string; provider?: string },
|
|
95
|
+
): AdminConnection {
|
|
96
|
+
const active = connections.filter((c) => c.status !== "revoked");
|
|
97
|
+
if (opts.connection) {
|
|
98
|
+
const match = connections.find((c) => c.id === opts.connection);
|
|
99
|
+
if (!match) {
|
|
100
|
+
throw new IntegrationsCliError(
|
|
101
|
+
`No connection '${opts.connection}'. Run \`hq integrations list\` to see connected apps.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return match;
|
|
105
|
+
}
|
|
106
|
+
if (opts.provider) {
|
|
107
|
+
const want = opts.provider.trim().toLowerCase();
|
|
108
|
+
const match = active.find((c) => {
|
|
109
|
+
const bare = c.provider.replace(/^factory:/, "").toLowerCase();
|
|
110
|
+
return bare === want || c.provider.toLowerCase() === want;
|
|
111
|
+
});
|
|
112
|
+
if (!match) {
|
|
113
|
+
const available = active
|
|
114
|
+
.map((c) => c.provider.replace(/^factory:/, ""))
|
|
115
|
+
.join(", ");
|
|
116
|
+
throw new IntegrationsCliError(
|
|
117
|
+
`No connected app matches '${opts.provider}'.` +
|
|
118
|
+
(available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps on the console Integrations page."),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return match;
|
|
122
|
+
}
|
|
123
|
+
if (active.length === 1) return active[0];
|
|
124
|
+
if (active.length === 0) {
|
|
125
|
+
throw new IntegrationsCliError(
|
|
126
|
+
"No connected apps yet. Connect one on the console Integrations page, then retry.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw new IntegrationsCliError(
|
|
130
|
+
`Multiple apps are connected — pick one with --provider:\n` +
|
|
131
|
+
active.map((c) => ` --provider ${c.provider.replace(/^factory:/, "")}`).join("\n"),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function callGateway(
|
|
136
|
+
token: string,
|
|
137
|
+
params: Record<string, unknown>,
|
|
138
|
+
): Promise<GatewayMessage> {
|
|
139
|
+
const res = await vaultApiFetch({
|
|
140
|
+
token,
|
|
141
|
+
path: "/v1/integrations/mcp",
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: {
|
|
144
|
+
jsonrpc: "2.0",
|
|
145
|
+
id: `hq-cli-${randomUUID()}`,
|
|
146
|
+
method: "tools/call",
|
|
147
|
+
params,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const message = (await res.json().catch(() => null)) as GatewayMessage | null;
|
|
151
|
+
if (!res.ok || !message) {
|
|
152
|
+
throw new IntegrationsCliError(
|
|
153
|
+
`Integration gateway request failed (HTTP ${res.status}).`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (message.error) {
|
|
157
|
+
throw new IntegrationsCliError(
|
|
158
|
+
message.error.message ?? "Integration gateway returned an error.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return message;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
|
|
166
|
+
* where `text` is the provider's JSON. Unwrap to the inner payload; fall back
|
|
167
|
+
* to the raw result when the shape differs.
|
|
168
|
+
*/
|
|
169
|
+
export function unwrapGatewayResult(result: unknown): unknown {
|
|
170
|
+
if (result && typeof result === "object" && Array.isArray((result as { content?: unknown }).content)) {
|
|
171
|
+
const content = (result as { content: Array<{ type?: string; text?: string }> }).content;
|
|
172
|
+
const text = content.find((c) => c.type === "text")?.text;
|
|
173
|
+
if (typeof text === "string") {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(text);
|
|
176
|
+
} catch {
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface QueuedOutcome {
|
|
185
|
+
queuedForApproval: true;
|
|
186
|
+
queueId: string;
|
|
187
|
+
connectionId: string;
|
|
188
|
+
expiresAt?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function queuedOutcome(payload: unknown): QueuedOutcome | null {
|
|
192
|
+
if (
|
|
193
|
+
payload &&
|
|
194
|
+
typeof payload === "object" &&
|
|
195
|
+
(payload as { queuedForApproval?: unknown }).queuedForApproval === true &&
|
|
196
|
+
typeof (payload as { queueId?: unknown }).queueId === "string"
|
|
197
|
+
) {
|
|
198
|
+
return payload as unknown as QueuedOutcome;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function printJson(value: unknown): void {
|
|
204
|
+
console.log(JSON.stringify(value, null, 2));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function registerIntegrationsCommand(program: Command): void {
|
|
208
|
+
const integrations = program
|
|
209
|
+
.command("integrations")
|
|
210
|
+
.description(
|
|
211
|
+
"Use company-connected apps (Linear, Notion, …) through HQ's governed integration gateway",
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
integrations
|
|
215
|
+
.command("list")
|
|
216
|
+
.description("List the company's connected apps")
|
|
217
|
+
.option("--company <slug>", "Company slug (defaults to your single active company)")
|
|
218
|
+
.option("--json", "Machine-readable output")
|
|
219
|
+
.action(async (opts: { company?: string; json?: boolean }) => {
|
|
220
|
+
const token = await ensureCognitoIdToken();
|
|
221
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
222
|
+
const connections = await fetchConnections(token, companyUid);
|
|
223
|
+
if (opts.json) {
|
|
224
|
+
printJson(connections);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (connections.length === 0) {
|
|
228
|
+
console.log(
|
|
229
|
+
"No apps connected yet. Connect one on the console Integrations page.",
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const c of connections) {
|
|
234
|
+
const name =
|
|
235
|
+
c.installation?.displayName ?? c.provider.replace(/^factory:/, "");
|
|
236
|
+
const flags = [
|
|
237
|
+
c.status,
|
|
238
|
+
c.writePolicy ? `writes: ${c.writePolicy}` : null,
|
|
239
|
+
c.installation?.status === "needs_credentials" ? "needs sign-in" : null,
|
|
240
|
+
]
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.join(" · ");
|
|
243
|
+
console.log(
|
|
244
|
+
`${chalk.bold(name)} (${c.provider.replace(/^factory:/, "")}) ${chalk.dim(flags)}`,
|
|
245
|
+
);
|
|
246
|
+
console.log(chalk.dim(` connection: ${c.id}`));
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
integrations
|
|
251
|
+
.command("tools")
|
|
252
|
+
.description("List what a connected app can do")
|
|
253
|
+
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
254
|
+
.option("--connection <id>", "Connection id (acct_…)")
|
|
255
|
+
.option("--company <slug>", "Company slug")
|
|
256
|
+
.option("--json", "Machine-readable output")
|
|
257
|
+
.action(
|
|
258
|
+
async (opts: {
|
|
259
|
+
provider?: string;
|
|
260
|
+
connection?: string;
|
|
261
|
+
company?: string;
|
|
262
|
+
json?: boolean;
|
|
263
|
+
}) => {
|
|
264
|
+
const token = await ensureCognitoIdToken();
|
|
265
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
266
|
+
const connection = selectConnection(
|
|
267
|
+
await fetchConnections(token, companyUid),
|
|
268
|
+
opts,
|
|
269
|
+
);
|
|
270
|
+
const prefix = toolPrefixForProvider(connection.provider);
|
|
271
|
+
const message = await callGateway(token, {
|
|
272
|
+
companyUid,
|
|
273
|
+
name: `${prefix}.mcp.tools.list`,
|
|
274
|
+
arguments: { companyUid, connectionId: connection.id },
|
|
275
|
+
});
|
|
276
|
+
const payload = unwrapGatewayResult(message.result) as {
|
|
277
|
+
tools?: Array<{ name: string; title?: string; description?: string }>;
|
|
278
|
+
};
|
|
279
|
+
if (opts.json) {
|
|
280
|
+
printJson(payload);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const tools = payload?.tools ?? [];
|
|
284
|
+
if (tools.length === 0) {
|
|
285
|
+
console.log("The app reported no tools.");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (const tool of tools) {
|
|
289
|
+
const label = tool.title && tool.title !== tool.name ? ` ${chalk.dim(tool.title)}` : "";
|
|
290
|
+
console.log(`${chalk.bold(tool.name)}${label}`);
|
|
291
|
+
}
|
|
292
|
+
console.log(
|
|
293
|
+
chalk.dim(
|
|
294
|
+
`\n${tools.length} tools. Call one with: hq integrations call <tool> --provider ${connection.provider.replace(/^factory:/, "")} --args '<json>'`,
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
integrations
|
|
301
|
+
.command("call <tool>")
|
|
302
|
+
.description("Call one of a connected app's tools (approval-gated when it changes things)")
|
|
303
|
+
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
304
|
+
.option("--connection <id>", "Connection id (acct_…)")
|
|
305
|
+
.option("--company <slug>", "Company slug")
|
|
306
|
+
.option("--args <json>", "Tool arguments as JSON", "{}")
|
|
307
|
+
.option(
|
|
308
|
+
"--idempotency-key <key>",
|
|
309
|
+
"Stable key so retries execute at most once (default: generated)",
|
|
310
|
+
)
|
|
311
|
+
.option("--json", "Machine-readable output")
|
|
312
|
+
.action(
|
|
313
|
+
async (
|
|
314
|
+
tool: string,
|
|
315
|
+
opts: {
|
|
316
|
+
provider?: string;
|
|
317
|
+
connection?: string;
|
|
318
|
+
company?: string;
|
|
319
|
+
args: string;
|
|
320
|
+
idempotencyKey?: string;
|
|
321
|
+
json?: boolean;
|
|
322
|
+
},
|
|
323
|
+
) => {
|
|
324
|
+
let parsedArgs: Record<string, unknown>;
|
|
325
|
+
try {
|
|
326
|
+
const raw: unknown = JSON.parse(opts.args);
|
|
327
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
328
|
+
throw new Error("not an object");
|
|
329
|
+
}
|
|
330
|
+
parsedArgs = raw as Record<string, unknown>;
|
|
331
|
+
} catch {
|
|
332
|
+
throw new IntegrationsCliError(
|
|
333
|
+
`--args must be a JSON object, e.g. --args '{"assignee":"me"}'`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const token = await ensureCognitoIdToken();
|
|
337
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
338
|
+
const connection = selectConnection(
|
|
339
|
+
await fetchConnections(token, companyUid),
|
|
340
|
+
opts,
|
|
341
|
+
);
|
|
342
|
+
const prefix = toolPrefixForProvider(connection.provider);
|
|
343
|
+
const idempotencyKey = opts.idempotencyKey ?? `hq-cli-${randomUUID()}`;
|
|
344
|
+
const message = await callGateway(token, {
|
|
345
|
+
companyUid,
|
|
346
|
+
name: `${prefix}.mcp.tools.call`,
|
|
347
|
+
arguments: {
|
|
348
|
+
companyUid,
|
|
349
|
+
connectionId: connection.id,
|
|
350
|
+
toolName: tool,
|
|
351
|
+
arguments: parsedArgs,
|
|
352
|
+
idempotencyKey,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const payload = unwrapGatewayResult(message.result);
|
|
356
|
+
const queued = queuedOutcome(payload);
|
|
357
|
+
if (queued) {
|
|
358
|
+
if (opts.json) {
|
|
359
|
+
printJson(payload);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
console.log(
|
|
363
|
+
chalk.yellow(
|
|
364
|
+
"Queued for approval — this call can change the app, so a company owner decides first.",
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
console.log(
|
|
368
|
+
`Approve with:\n hq integrations approve ${queued.queueId} --provider ${connection.provider.replace(/^factory:/, "")}`,
|
|
369
|
+
);
|
|
370
|
+
if (queued.expiresAt) {
|
|
371
|
+
console.log(chalk.dim(`Expires ${queued.expiresAt}`));
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
printJson(payload);
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
for (const decision of ["approve", "reject"] as const) {
|
|
380
|
+
integrations
|
|
381
|
+
.command(`${decision} <queueId>`)
|
|
382
|
+
.description(
|
|
383
|
+
decision === "approve"
|
|
384
|
+
? "Approve a queued call — it executes exactly once (owner only)"
|
|
385
|
+
: "Reject a queued call (owner only)",
|
|
386
|
+
)
|
|
387
|
+
.option("--provider <slug>", "Connected app (e.g. linear)")
|
|
388
|
+
.option("--connection <id>", "Connection id (acct_…)")
|
|
389
|
+
.option("--company <slug>", "Company slug")
|
|
390
|
+
.option("--json", "Machine-readable output")
|
|
391
|
+
.action(
|
|
392
|
+
async (
|
|
393
|
+
queueId: string,
|
|
394
|
+
opts: {
|
|
395
|
+
provider?: string;
|
|
396
|
+
connection?: string;
|
|
397
|
+
company?: string;
|
|
398
|
+
json?: boolean;
|
|
399
|
+
},
|
|
400
|
+
) => {
|
|
401
|
+
const token = await ensureCognitoIdToken();
|
|
402
|
+
const companyUid = await getCompanyUid(token, opts.company);
|
|
403
|
+
const connection = selectConnection(
|
|
404
|
+
await fetchConnections(token, companyUid),
|
|
405
|
+
opts,
|
|
406
|
+
);
|
|
407
|
+
const res = await vaultApiFetch({
|
|
408
|
+
token,
|
|
409
|
+
path: `/v1/integrations/confirm/${encodeURIComponent(queueId)}/${decision}`,
|
|
410
|
+
method: "POST",
|
|
411
|
+
body: { companyUid, connectionId: connection.id },
|
|
412
|
+
});
|
|
413
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
414
|
+
status?: string;
|
|
415
|
+
result?: unknown;
|
|
416
|
+
error?: string;
|
|
417
|
+
};
|
|
418
|
+
if (!res.ok) {
|
|
419
|
+
throw new IntegrationsCliError(
|
|
420
|
+
body.error ?? `${decision} failed (HTTP ${res.status})`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (opts.json) {
|
|
424
|
+
printJson(body);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
console.log(
|
|
428
|
+
decision === "approve"
|
|
429
|
+
? chalk.green(`Approved — the call ran (status: ${body.status ?? "resumed"}).`)
|
|
430
|
+
: chalk.yellow(`Rejected (status: ${body.status ?? "rejected"}).`),
|
|
431
|
+
);
|
|
432
|
+
if (decision === "approve" && body.result !== undefined) {
|
|
433
|
+
printJson(unwrapGatewayResult(body.result));
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
package/src/commands/members.ts
CHANGED
|
@@ -754,10 +754,10 @@ export function registerMembersCommand(program: Command): void {
|
|
|
754
754
|
` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`,
|
|
755
755
|
);
|
|
756
756
|
console.log(
|
|
757
|
-
` at https://hq.
|
|
757
|
+
` at https://hq.computer with that email address.`,
|
|
758
758
|
);
|
|
759
759
|
console.log(
|
|
760
|
-
` 2. Or use the hq-console UI at https://hq.
|
|
760
|
+
` 2. Or use the hq-console UI at https://hq.computer to issue the`,
|
|
761
761
|
);
|
|
762
762
|
console.log(
|
|
763
763
|
` invite instead — the UI path triggers an automated email via Resend.`,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq outposts` (outposts.ts).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors company.test.ts: mock ensureCognitoToken, spy on global fetch, drive
|
|
5
|
+
* through a Commander program, assert the /outpost/* request shape (method +
|
|
6
|
+
* path + query) plus the destroy guard and the teardown-incomplete retry path.
|
|
7
|
+
* Outposts are caller-scoped, so there is no company resolution to mock.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import {
|
|
12
|
+
afterEach,
|
|
13
|
+
beforeEach,
|
|
14
|
+
describe,
|
|
15
|
+
expect,
|
|
16
|
+
it,
|
|
17
|
+
vi,
|
|
18
|
+
type MockInstance,
|
|
19
|
+
} from "vitest";
|
|
20
|
+
|
|
21
|
+
vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
22
|
+
const original =
|
|
23
|
+
await importOriginal<typeof import("../utils/cognito-session.js")>();
|
|
24
|
+
return {
|
|
25
|
+
...original,
|
|
26
|
+
ensureCognitoToken: vi.fn(async () => "test-token"),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
31
|
+
import { registerOutpostsCommand } from "./outposts.js";
|
|
32
|
+
|
|
33
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
34
|
+
return new Response(JSON.stringify(body), {
|
|
35
|
+
status,
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
41
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
42
|
+
let logSpy: MockInstance<typeof console.log>;
|
|
43
|
+
const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
48
|
+
mockEnsureCognitoToken.mockResolvedValue("test-token");
|
|
49
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number) => {
|
|
50
|
+
throw new Error(`process.exit(${code})`);
|
|
51
|
+
}) as unknown as MockInstance<typeof process.exit>;
|
|
52
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
53
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function buildProgram(): Command {
|
|
61
|
+
const program = new Command();
|
|
62
|
+
program.name("hq").exitOverride();
|
|
63
|
+
registerOutpostsCommand(program);
|
|
64
|
+
return program;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function run(args: string[]): Promise<void> {
|
|
68
|
+
await buildProgram().parseAsync(["node", "hq", ...args]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("hq outposts list", () => {
|
|
72
|
+
it("GETs /outpost/list", async () => {
|
|
73
|
+
fetchSpy.mockResolvedValueOnce(
|
|
74
|
+
jsonResponse(200, {
|
|
75
|
+
outposts: [
|
|
76
|
+
{
|
|
77
|
+
outpostId: "primary",
|
|
78
|
+
state: "running",
|
|
79
|
+
instanceName: "outpost-abc",
|
|
80
|
+
region: "us-east-1",
|
|
81
|
+
agentRuntime: "claude",
|
|
82
|
+
platform: "ec2",
|
|
83
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await run(["outposts", "list"]);
|
|
90
|
+
|
|
91
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
92
|
+
expect(String(url)).toContain("/outpost/list");
|
|
93
|
+
expect(init?.method ?? "GET").toBe("GET");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("emits raw JSON with --json", async () => {
|
|
97
|
+
const stdoutSpy = vi
|
|
98
|
+
.spyOn(process.stdout, "write")
|
|
99
|
+
.mockImplementation(() => true);
|
|
100
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { outposts: [] }));
|
|
101
|
+
await run(["outposts", "list", "--json"]);
|
|
102
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
103
|
+
expect(printed).toContain("[]");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("hq outposts status", () => {
|
|
108
|
+
it("GETs /outpost/status with the --id query param", async () => {
|
|
109
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { state: "running" }));
|
|
110
|
+
await run(["outposts", "status", "--id", "2"]);
|
|
111
|
+
const url = String(fetchSpy.mock.calls[0][0]);
|
|
112
|
+
expect(url).toContain("/outpost/status");
|
|
113
|
+
expect(url).toContain("outpostId=2");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("omits outpostId when --id is not given", async () => {
|
|
117
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { state: "running" }));
|
|
118
|
+
await run(["outposts", "status"]);
|
|
119
|
+
expect(String(fetchSpy.mock.calls[0][0])).not.toContain("outpostId=");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("hq outposts codex-enable / login", () => {
|
|
124
|
+
it("POSTs /outpost/codex/enable", async () => {
|
|
125
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
|
|
126
|
+
await run(["outposts", "codex-enable"]);
|
|
127
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
128
|
+
expect(String(url)).toContain("/outpost/codex/enable");
|
|
129
|
+
expect(init?.method).toBe("POST");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("POSTs /outpost/regenerate-login-url", async () => {
|
|
133
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
|
|
134
|
+
await run(["outposts", "login"]);
|
|
135
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
136
|
+
expect(String(url)).toContain("/outpost/regenerate-login-url");
|
|
137
|
+
expect(init?.method).toBe("POST");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("hq outposts destroy", () => {
|
|
142
|
+
it("refuses without --yes and does not call the API", async () => {
|
|
143
|
+
await expect(run(["outposts", "destroy"])).rejects.toThrow(
|
|
144
|
+
"process.exit(1)",
|
|
145
|
+
);
|
|
146
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("POSTs /outpost/destroy with --yes", async () => {
|
|
150
|
+
fetchSpy.mockResolvedValueOnce(
|
|
151
|
+
jsonResponse(200, { ok: true, userId: "u1" }),
|
|
152
|
+
);
|
|
153
|
+
await run(["outposts", "destroy", "--yes"]);
|
|
154
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
155
|
+
expect(String(url)).toContain("/outpost/destroy");
|
|
156
|
+
expect(init?.method).toBe("POST");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("treats a 409 teardown-incomplete as retryable (no exit)", async () => {
|
|
160
|
+
fetchSpy.mockResolvedValueOnce(
|
|
161
|
+
jsonResponse(409, { error: true, step: "teardown-incomplete" }),
|
|
162
|
+
);
|
|
163
|
+
await run(["outposts", "destroy", "--id", "2", "--yes"]);
|
|
164
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
165
|
+
expect(printed).toMatch(/still in progress/i);
|
|
166
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("exits 1 on a genuine failure", async () => {
|
|
170
|
+
fetchSpy.mockResolvedValueOnce(
|
|
171
|
+
jsonResponse(500, { error: "boom", step: "teardown" }),
|
|
172
|
+
);
|
|
173
|
+
await expect(
|
|
174
|
+
run(["outposts", "destroy", "--yes"]),
|
|
175
|
+
).rejects.toThrow("process.exit(1)");
|
|
176
|
+
});
|
|
177
|
+
});
|