@indigoai-us/hq-cli 5.47.13 → 5.47.14
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/api-keys.d.ts +3 -0
- package/dist/commands/api-keys.js +198 -0
- package/dist/index.js +5 -2
- package/package.json +1 -1
- package/src/commands/api-keys.test.ts +217 -0
- package/src/commands/api-keys.ts +306 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c9f6fe34-9e38-5001-97b7-14b35c956330")}catch(e){}}();
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { getCompanyUid, vaultApiFetch } from "./secrets.js";
|
|
6
|
+
function collectRepeatedOption(value, previous) {
|
|
7
|
+
return [...previous, value];
|
|
8
|
+
}
|
|
9
|
+
function formatMaybe(value) {
|
|
10
|
+
return value ?? "-";
|
|
11
|
+
}
|
|
12
|
+
function formatPrefixes(prefixes) {
|
|
13
|
+
return prefixes.length > 0 ? prefixes.join(", ") : "-";
|
|
14
|
+
}
|
|
15
|
+
function parsePermission(value) {
|
|
16
|
+
if (value === "read" || value === "write" || value === "admin") {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`Invalid permission '${value}'. Use one of: read, write, admin.`);
|
|
20
|
+
}
|
|
21
|
+
function parseExpires(expires) {
|
|
22
|
+
if (expires === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (Number.isNaN(Date.parse(expires))) {
|
|
25
|
+
throw new Error(`Invalid --expires value '${expires}'. Use an ISO-8601 timestamp.`);
|
|
26
|
+
}
|
|
27
|
+
return expires;
|
|
28
|
+
}
|
|
29
|
+
async function readApiError(res, action) {
|
|
30
|
+
const body = (await res.json().catch(() => ({})));
|
|
31
|
+
const message = typeof body.message === "string"
|
|
32
|
+
? body.message
|
|
33
|
+
: typeof body.error === "string"
|
|
34
|
+
? body.error
|
|
35
|
+
: res.statusText;
|
|
36
|
+
if (res.status === 401) {
|
|
37
|
+
return "Not authenticated - please run `hq login`";
|
|
38
|
+
}
|
|
39
|
+
if (res.status === 403) {
|
|
40
|
+
return `Not authorized to ${action}`;
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 404) {
|
|
43
|
+
return message || "API key not found";
|
|
44
|
+
}
|
|
45
|
+
if (res.status >= 500) {
|
|
46
|
+
return `Server error: ${message}`;
|
|
47
|
+
}
|
|
48
|
+
return message || `Request failed (${res.status})`;
|
|
49
|
+
}
|
|
50
|
+
function renderApiKeysTable(apiKeys) {
|
|
51
|
+
const rows = apiKeys.map((apiKey) => ({
|
|
52
|
+
keyId: apiKey.keyId,
|
|
53
|
+
name: apiKey.name,
|
|
54
|
+
permission: apiKey.scope.permission,
|
|
55
|
+
prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
|
|
56
|
+
status: apiKey.status,
|
|
57
|
+
lastUsedAt: formatMaybe(apiKey.lastUsedAt),
|
|
58
|
+
expiresAt: formatMaybe(apiKey.expiresAt),
|
|
59
|
+
}));
|
|
60
|
+
const keyIdWidth = Math.max(6, ...rows.map((row) => row.keyId.length));
|
|
61
|
+
const nameWidth = Math.max(4, ...rows.map((row) => row.name.length));
|
|
62
|
+
const permissionWidth = Math.max(10, ...rows.map((row) => row.permission.length));
|
|
63
|
+
const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
|
|
64
|
+
const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
|
|
65
|
+
const lastUsedWidth = Math.max(11, ...rows.map((row) => row.lastUsedAt.length));
|
|
66
|
+
const expiresWidth = Math.max(10, ...rows.map((row) => row.expiresAt.length));
|
|
67
|
+
const header = [
|
|
68
|
+
"KEY ID".padEnd(keyIdWidth),
|
|
69
|
+
"NAME".padEnd(nameWidth),
|
|
70
|
+
"PERMISSION".padEnd(permissionWidth),
|
|
71
|
+
"PREFIXES".padEnd(prefixesWidth),
|
|
72
|
+
"STATUS".padEnd(statusWidth),
|
|
73
|
+
"LAST USED".padEnd(lastUsedWidth),
|
|
74
|
+
"EXPIRES".padEnd(expiresWidth),
|
|
75
|
+
].join(" ");
|
|
76
|
+
console.log(chalk.bold(header));
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
console.log([
|
|
79
|
+
row.keyId.padEnd(keyIdWidth),
|
|
80
|
+
row.name.padEnd(nameWidth),
|
|
81
|
+
row.permission.padEnd(permissionWidth),
|
|
82
|
+
row.prefixes.padEnd(prefixesWidth),
|
|
83
|
+
row.status.padEnd(statusWidth),
|
|
84
|
+
row.lastUsedAt.padEnd(lastUsedWidth),
|
|
85
|
+
row.expiresAt.padEnd(expiresWidth),
|
|
86
|
+
].join(" "));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function registerApiKeysCommand(program) {
|
|
90
|
+
const apiKeys = program
|
|
91
|
+
.command("api-keys")
|
|
92
|
+
.description("Manage vault API keys for CI and automation")
|
|
93
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
94
|
+
apiKeys
|
|
95
|
+
.command("create")
|
|
96
|
+
.description("Create a new API key")
|
|
97
|
+
.requiredOption("--name <label>", "Human-readable label for the API key")
|
|
98
|
+
.option("--scope <prefix>", "Allowed prefix (repeatable)", collectRepeatedOption, [])
|
|
99
|
+
.option("--permission <level>", "Permission level: read | write | admin", "read")
|
|
100
|
+
.option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
|
|
101
|
+
.action(async (opts) => {
|
|
102
|
+
try {
|
|
103
|
+
if (opts.scope.length === 0) {
|
|
104
|
+
console.error(chalk.red("Error: at least one --scope <prefix> is required."));
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const permission = parsePermission(opts.permission);
|
|
108
|
+
const expiresAt = parseExpires(opts.expires);
|
|
109
|
+
const token = await ensureCognitoToken();
|
|
110
|
+
const companyUid = await getCompanyUid(token, apiKeys.opts().company);
|
|
111
|
+
const res = await vaultApiFetch({
|
|
112
|
+
token,
|
|
113
|
+
path: "/v1/api-keys",
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: {
|
|
116
|
+
companyUid,
|
|
117
|
+
name: opts.name,
|
|
118
|
+
allowedPrefixes: opts.scope,
|
|
119
|
+
permission,
|
|
120
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
throw new Error(await readApiError(res, "create API keys"));
|
|
125
|
+
}
|
|
126
|
+
const data = (await res.json());
|
|
127
|
+
console.log(chalk.green("API key created."));
|
|
128
|
+
console.log(chalk.yellow("Store this key now - it won't be shown again:"));
|
|
129
|
+
console.log(`\n ${data.key.value}\n`);
|
|
130
|
+
console.log(chalk.bold("Metadata"));
|
|
131
|
+
console.log(` Key ID: ${data.apiKey.keyId}`);
|
|
132
|
+
console.log(` Name: ${data.apiKey.name}`);
|
|
133
|
+
console.log(` Company: ${data.apiKey.companyUid}`);
|
|
134
|
+
console.log(` Permission: ${data.apiKey.scope.permission}`);
|
|
135
|
+
console.log(` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`);
|
|
136
|
+
console.log(` Status: ${data.apiKey.status}`);
|
|
137
|
+
console.log(` Created: ${data.apiKey.createdAt}`);
|
|
138
|
+
console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
|
|
139
|
+
console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
apiKeys
|
|
147
|
+
.command("list")
|
|
148
|
+
.description("List API keys for a company")
|
|
149
|
+
.action(async () => {
|
|
150
|
+
try {
|
|
151
|
+
const token = await ensureCognitoToken();
|
|
152
|
+
const companyUid = await getCompanyUid(token, apiKeys.opts().company);
|
|
153
|
+
const res = await vaultApiFetch({
|
|
154
|
+
token,
|
|
155
|
+
path: "/v1/api-keys",
|
|
156
|
+
query: { companyUid },
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw new Error(await readApiError(res, "list API keys"));
|
|
160
|
+
}
|
|
161
|
+
const data = (await res.json());
|
|
162
|
+
if (data.apiKeys.length === 0) {
|
|
163
|
+
console.log(chalk.dim("No API keys found."));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
renderApiKeysTable(data.apiKeys);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
apiKeys
|
|
174
|
+
.command("revoke <keyId>")
|
|
175
|
+
.description("Revoke an API key")
|
|
176
|
+
.action(async (keyId) => {
|
|
177
|
+
try {
|
|
178
|
+
const token = await ensureCognitoToken();
|
|
179
|
+
const res = await vaultApiFetch({
|
|
180
|
+
token,
|
|
181
|
+
path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,
|
|
182
|
+
method: "POST",
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw new Error(await readApiError(res, "revoke this API key"));
|
|
186
|
+
}
|
|
187
|
+
const data = (await res.json());
|
|
188
|
+
console.log(chalk.green(`Revoked API key '${data.apiKey.keyId}'`));
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
return apiKeys;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=api-keys.js.map
|
|
198
|
+
//# debugId=c9f6fe34-9e38-5001-97b7-14b35c956330
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
6
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="964b8c71-056c-51fb-893a-d51f8f6ad5d9")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -28,6 +28,7 @@ import { registerPublishCommand } from "./commands/publish.js";
|
|
|
28
28
|
import { registerCreatorsCommand } from "./commands/creators.js";
|
|
29
29
|
import { registerTeamSyncCommand } from "./commands/team-sync.js";
|
|
30
30
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
31
|
+
import { registerApiKeysCommand } from "./commands/api-keys.js";
|
|
31
32
|
import { registerSecretsCommand } from "./commands/secrets.js";
|
|
32
33
|
import { registerRunCommand } from "./commands/run.js";
|
|
33
34
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
@@ -117,6 +118,8 @@ registerWhoamiCommand(program);
|
|
|
117
118
|
registerAuthCommands(program);
|
|
118
119
|
// Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
|
|
119
120
|
registerSecretsCommand(program);
|
|
121
|
+
// API key management (subcommand group — hq api-keys create|list|revoke)
|
|
122
|
+
registerApiKeysCommand(program);
|
|
120
123
|
// Schema-driven dev runner — hq run [options] -- <cmd>
|
|
121
124
|
registerRunCommand(program);
|
|
122
125
|
// Groups management (subcommand group — hq groups create|delete|add|remove|list|members)
|
|
@@ -177,4 +180,4 @@ registerRescueCommand(program);
|
|
|
177
180
|
}
|
|
178
181
|
})();
|
|
179
182
|
//# sourceMappingURL=index.js.map
|
|
180
|
-
//# debugId=
|
|
183
|
+
//# debugId=964b8c71-056c-51fb-893a-d51f8f6ad5d9
|
package/package.json
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
type MockInstance,
|
|
9
|
+
} from "vitest";
|
|
10
|
+
|
|
11
|
+
vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
12
|
+
const original = (await importOriginal()) as Record<string, unknown>;
|
|
13
|
+
return {
|
|
14
|
+
...original,
|
|
15
|
+
ensureCognitoToken: vi.fn(async () => "test-token"),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.mock("./secrets.js", async (importOriginal) => {
|
|
20
|
+
const original = (await importOriginal()) as Record<string, unknown>;
|
|
21
|
+
return {
|
|
22
|
+
...original,
|
|
23
|
+
getCompanyUid: vi.fn(async () => "cmp_acme"),
|
|
24
|
+
vaultApiFetch: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
import { Command } from "commander";
|
|
29
|
+
import { registerApiKeysCommand } from "./api-keys.js";
|
|
30
|
+
import { getCompanyUid, vaultApiFetch } from "./secrets.js";
|
|
31
|
+
|
|
32
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
33
|
+
return new Response(JSON.stringify(body), {
|
|
34
|
+
status,
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildProgram(): Command {
|
|
40
|
+
const program = new Command();
|
|
41
|
+
program.exitOverride();
|
|
42
|
+
program.configureOutput({
|
|
43
|
+
writeOut: () => undefined,
|
|
44
|
+
writeErr: () => undefined,
|
|
45
|
+
});
|
|
46
|
+
registerApiKeysCommand(program);
|
|
47
|
+
return program;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let logSpy: MockInstance<typeof console.log>;
|
|
51
|
+
let errSpy: MockInstance<typeof console.error>;
|
|
52
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
57
|
+
errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
58
|
+
exitSpy = vi
|
|
59
|
+
.spyOn(process, "exit")
|
|
60
|
+
.mockImplementation(((code?: number) => {
|
|
61
|
+
throw new Error(`__EXIT__:${code ?? 0}`);
|
|
62
|
+
}) as never);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("hq api-keys create", () => {
|
|
70
|
+
it("POSTs the expected body and prints the raw key exactly once", async () => {
|
|
71
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
72
|
+
jsonResponse({
|
|
73
|
+
apiKey: {
|
|
74
|
+
keyId: "key_123",
|
|
75
|
+
companyUid: "cmp_acme",
|
|
76
|
+
name: "CI key",
|
|
77
|
+
scope: {
|
|
78
|
+
allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
|
|
79
|
+
permission: "read",
|
|
80
|
+
},
|
|
81
|
+
status: "active",
|
|
82
|
+
createdAt: "2026-06-19T12:00:00.000Z",
|
|
83
|
+
lastUsedAt: null,
|
|
84
|
+
expiresAt: "2026-07-01T00:00:00.000Z",
|
|
85
|
+
},
|
|
86
|
+
key: { value: "hqk_test_secret_value" },
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const program = buildProgram();
|
|
91
|
+
await program.parseAsync(
|
|
92
|
+
[
|
|
93
|
+
"api-keys",
|
|
94
|
+
"create",
|
|
95
|
+
"--company",
|
|
96
|
+
"indigo",
|
|
97
|
+
"--name",
|
|
98
|
+
"CI key",
|
|
99
|
+
"--scope",
|
|
100
|
+
"HQ_PRO/HQ_PROD",
|
|
101
|
+
"--scope",
|
|
102
|
+
"HQ_PRO/HQ_DEV",
|
|
103
|
+
"--permission",
|
|
104
|
+
"read",
|
|
105
|
+
"--expires",
|
|
106
|
+
"2026-07-01T00:00:00.000Z",
|
|
107
|
+
],
|
|
108
|
+
{ from: "user" },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(getCompanyUid).toHaveBeenCalledWith("test-token", "indigo");
|
|
112
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
113
|
+
token: "test-token",
|
|
114
|
+
path: "/v1/api-keys",
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: {
|
|
117
|
+
companyUid: "cmp_acme",
|
|
118
|
+
name: "CI key",
|
|
119
|
+
allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
|
|
120
|
+
permission: "read",
|
|
121
|
+
expiresAt: "2026-07-01T00:00:00.000Z",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const printed = logSpy.mock.calls
|
|
126
|
+
.map((call) => call.map((value) => String(value)).join(" "))
|
|
127
|
+
.join("\n");
|
|
128
|
+
expect(printed).toContain("Store this key now");
|
|
129
|
+
expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(1);
|
|
130
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
131
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("hq api-keys list", () => {
|
|
136
|
+
it("renders metadata rows and never prints any key value", async () => {
|
|
137
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
138
|
+
jsonResponse({
|
|
139
|
+
companyUid: "cmp_acme",
|
|
140
|
+
apiKeys: [
|
|
141
|
+
{
|
|
142
|
+
keyId: "key_123",
|
|
143
|
+
companyUid: "cmp_acme",
|
|
144
|
+
name: "CI key",
|
|
145
|
+
scope: {
|
|
146
|
+
allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
|
|
147
|
+
permission: "read",
|
|
148
|
+
},
|
|
149
|
+
status: "active",
|
|
150
|
+
createdAt: "2026-06-19T12:00:00.000Z",
|
|
151
|
+
lastUsedAt: "2026-06-20T08:00:00.000Z",
|
|
152
|
+
expiresAt: "2026-07-01T00:00:00.000Z",
|
|
153
|
+
key: { value: "hqk_should_not_print" },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const program = buildProgram();
|
|
160
|
+
await program.parseAsync(
|
|
161
|
+
["api-keys", "--company", "indigo", "list"],
|
|
162
|
+
{ from: "user" },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(getCompanyUid).toHaveBeenCalledWith("test-token", "indigo");
|
|
166
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
167
|
+
token: "test-token",
|
|
168
|
+
path: "/v1/api-keys",
|
|
169
|
+
query: { companyUid: "cmp_acme" },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const printed = logSpy.mock.calls
|
|
173
|
+
.map((call) => call.map((value) => String(value)).join(" "))
|
|
174
|
+
.join("\n");
|
|
175
|
+
expect(printed).toContain("KEY ID");
|
|
176
|
+
expect(printed).toContain("key_123");
|
|
177
|
+
expect(printed).toContain("CI key");
|
|
178
|
+
expect(printed).toContain("HQ_PRO/HQ_PROD, HQ_PRO/HQ_DEV");
|
|
179
|
+
expect(printed).not.toContain("hqk_should_not_print");
|
|
180
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("hq api-keys revoke", () => {
|
|
185
|
+
it("POSTs to the revoke route for the provided key id", async () => {
|
|
186
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
187
|
+
jsonResponse({
|
|
188
|
+
apiKey: {
|
|
189
|
+
keyId: "key_123",
|
|
190
|
+
companyUid: "cmp_acme",
|
|
191
|
+
name: "CI key",
|
|
192
|
+
scope: {
|
|
193
|
+
allowedPrefixes: ["HQ_PRO/HQ_PROD"],
|
|
194
|
+
permission: "read",
|
|
195
|
+
},
|
|
196
|
+
status: "revoked",
|
|
197
|
+
createdAt: "2026-06-19T12:00:00.000Z",
|
|
198
|
+
lastUsedAt: null,
|
|
199
|
+
expiresAt: null,
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const program = buildProgram();
|
|
205
|
+
await program.parseAsync(["api-keys", "revoke", "key_123"], {
|
|
206
|
+
from: "user",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
210
|
+
token: "test-token",
|
|
211
|
+
path: "/v1/api-keys/key_123/revoke",
|
|
212
|
+
method: "POST",
|
|
213
|
+
});
|
|
214
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("key_123"));
|
|
215
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
+
import { getCompanyUid, vaultApiFetch } from "./secrets.js";
|
|
5
|
+
|
|
6
|
+
type ApiKeyPermission = "read" | "write" | "admin";
|
|
7
|
+
|
|
8
|
+
interface ApiKeyMetadata {
|
|
9
|
+
keyId: string;
|
|
10
|
+
companyUid: string;
|
|
11
|
+
name: string;
|
|
12
|
+
scope: {
|
|
13
|
+
allowedPrefixes: string[];
|
|
14
|
+
permission: ApiKeyPermission;
|
|
15
|
+
};
|
|
16
|
+
status: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
lastUsedAt?: string | null;
|
|
19
|
+
expiresAt?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CreateApiKeyResponse {
|
|
23
|
+
apiKey: ApiKeyMetadata;
|
|
24
|
+
key: {
|
|
25
|
+
value: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ListApiKeysResponse {
|
|
30
|
+
companyUid: string;
|
|
31
|
+
apiKeys: ApiKeyMetadata[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RevokeApiKeyResponse {
|
|
35
|
+
apiKey: ApiKeyMetadata;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ApiKeysGroupOptions {
|
|
39
|
+
company?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CreateApiKeyOptions {
|
|
43
|
+
name: string;
|
|
44
|
+
scope: string[];
|
|
45
|
+
permission: string;
|
|
46
|
+
expires?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectRepeatedOption(value: string, previous: string[]): string[] {
|
|
50
|
+
return [...previous, value];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatMaybe(value?: string | null): string {
|
|
54
|
+
return value ?? "-";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatPrefixes(prefixes: string[]): string {
|
|
58
|
+
return prefixes.length > 0 ? prefixes.join(", ") : "-";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parsePermission(value: string): ApiKeyPermission {
|
|
62
|
+
if (value === "read" || value === "write" || value === "admin") {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Invalid permission '${value}'. Use one of: read, write, admin.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseExpires(expires?: string): string | undefined {
|
|
71
|
+
if (expires === undefined) return undefined;
|
|
72
|
+
if (Number.isNaN(Date.parse(expires))) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Invalid --expires value '${expires}'. Use an ISO-8601 timestamp.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return expires;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readApiError(res: Response, action: string): Promise<string> {
|
|
81
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
82
|
+
const message =
|
|
83
|
+
typeof body.message === "string"
|
|
84
|
+
? body.message
|
|
85
|
+
: typeof body.error === "string"
|
|
86
|
+
? body.error
|
|
87
|
+
: res.statusText;
|
|
88
|
+
|
|
89
|
+
if (res.status === 401) {
|
|
90
|
+
return "Not authenticated - please run `hq login`";
|
|
91
|
+
}
|
|
92
|
+
if (res.status === 403) {
|
|
93
|
+
return `Not authorized to ${action}`;
|
|
94
|
+
}
|
|
95
|
+
if (res.status === 404) {
|
|
96
|
+
return message || "API key not found";
|
|
97
|
+
}
|
|
98
|
+
if (res.status >= 500) {
|
|
99
|
+
return `Server error: ${message}`;
|
|
100
|
+
}
|
|
101
|
+
return message || `Request failed (${res.status})`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
|
|
105
|
+
const rows = apiKeys.map((apiKey) => ({
|
|
106
|
+
keyId: apiKey.keyId,
|
|
107
|
+
name: apiKey.name,
|
|
108
|
+
permission: apiKey.scope.permission,
|
|
109
|
+
prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
|
|
110
|
+
status: apiKey.status,
|
|
111
|
+
lastUsedAt: formatMaybe(apiKey.lastUsedAt),
|
|
112
|
+
expiresAt: formatMaybe(apiKey.expiresAt),
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const keyIdWidth = Math.max(6, ...rows.map((row) => row.keyId.length));
|
|
116
|
+
const nameWidth = Math.max(4, ...rows.map((row) => row.name.length));
|
|
117
|
+
const permissionWidth = Math.max(
|
|
118
|
+
10,
|
|
119
|
+
...rows.map((row) => row.permission.length),
|
|
120
|
+
);
|
|
121
|
+
const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
|
|
122
|
+
const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
|
|
123
|
+
const lastUsedWidth = Math.max(
|
|
124
|
+
11,
|
|
125
|
+
...rows.map((row) => row.lastUsedAt.length),
|
|
126
|
+
);
|
|
127
|
+
const expiresWidth = Math.max(
|
|
128
|
+
10,
|
|
129
|
+
...rows.map((row) => row.expiresAt.length),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const header = [
|
|
133
|
+
"KEY ID".padEnd(keyIdWidth),
|
|
134
|
+
"NAME".padEnd(nameWidth),
|
|
135
|
+
"PERMISSION".padEnd(permissionWidth),
|
|
136
|
+
"PREFIXES".padEnd(prefixesWidth),
|
|
137
|
+
"STATUS".padEnd(statusWidth),
|
|
138
|
+
"LAST USED".padEnd(lastUsedWidth),
|
|
139
|
+
"EXPIRES".padEnd(expiresWidth),
|
|
140
|
+
].join(" ");
|
|
141
|
+
console.log(chalk.bold(header));
|
|
142
|
+
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
console.log(
|
|
145
|
+
[
|
|
146
|
+
row.keyId.padEnd(keyIdWidth),
|
|
147
|
+
row.name.padEnd(nameWidth),
|
|
148
|
+
row.permission.padEnd(permissionWidth),
|
|
149
|
+
row.prefixes.padEnd(prefixesWidth),
|
|
150
|
+
row.status.padEnd(statusWidth),
|
|
151
|
+
row.lastUsedAt.padEnd(lastUsedWidth),
|
|
152
|
+
row.expiresAt.padEnd(expiresWidth),
|
|
153
|
+
].join(" "),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function registerApiKeysCommand(program: Command): Command {
|
|
159
|
+
const apiKeys = program
|
|
160
|
+
.command("api-keys")
|
|
161
|
+
.description("Manage vault API keys for CI and automation")
|
|
162
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
163
|
+
|
|
164
|
+
apiKeys
|
|
165
|
+
.command("create")
|
|
166
|
+
.description("Create a new API key")
|
|
167
|
+
.requiredOption("--name <label>", "Human-readable label for the API key")
|
|
168
|
+
.option(
|
|
169
|
+
"--scope <prefix>",
|
|
170
|
+
"Allowed prefix (repeatable)",
|
|
171
|
+
collectRepeatedOption,
|
|
172
|
+
[],
|
|
173
|
+
)
|
|
174
|
+
.option(
|
|
175
|
+
"--permission <level>",
|
|
176
|
+
"Permission level: read | write | admin",
|
|
177
|
+
"read",
|
|
178
|
+
)
|
|
179
|
+
.option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
|
|
180
|
+
.action(async (opts: CreateApiKeyOptions) => {
|
|
181
|
+
try {
|
|
182
|
+
if (opts.scope.length === 0) {
|
|
183
|
+
console.error(
|
|
184
|
+
chalk.red("Error: at least one --scope <prefix> is required."),
|
|
185
|
+
);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const permission = parsePermission(opts.permission);
|
|
190
|
+
const expiresAt = parseExpires(opts.expires);
|
|
191
|
+
const token = await ensureCognitoToken();
|
|
192
|
+
const companyUid = await getCompanyUid(
|
|
193
|
+
token,
|
|
194
|
+
(apiKeys.opts() as ApiKeysGroupOptions).company,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const res = await vaultApiFetch({
|
|
198
|
+
token,
|
|
199
|
+
path: "/v1/api-keys",
|
|
200
|
+
method: "POST",
|
|
201
|
+
body: {
|
|
202
|
+
companyUid,
|
|
203
|
+
name: opts.name,
|
|
204
|
+
allowedPrefixes: opts.scope,
|
|
205
|
+
permission,
|
|
206
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
throw new Error(await readApiError(res, "create API keys"));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const data = (await res.json()) as CreateApiKeyResponse;
|
|
215
|
+
console.log(chalk.green("API key created."));
|
|
216
|
+
console.log(
|
|
217
|
+
chalk.yellow("Store this key now - it won't be shown again:"),
|
|
218
|
+
);
|
|
219
|
+
console.log(`\n ${data.key.value}\n`);
|
|
220
|
+
console.log(chalk.bold("Metadata"));
|
|
221
|
+
console.log(` Key ID: ${data.apiKey.keyId}`);
|
|
222
|
+
console.log(` Name: ${data.apiKey.name}`);
|
|
223
|
+
console.log(` Company: ${data.apiKey.companyUid}`);
|
|
224
|
+
console.log(` Permission: ${data.apiKey.scope.permission}`);
|
|
225
|
+
console.log(
|
|
226
|
+
` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`,
|
|
227
|
+
);
|
|
228
|
+
console.log(` Status: ${data.apiKey.status}`);
|
|
229
|
+
console.log(` Created: ${data.apiKey.createdAt}`);
|
|
230
|
+
console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
|
|
231
|
+
console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(
|
|
234
|
+
chalk.red("Error:"),
|
|
235
|
+
err instanceof Error ? err.message : String(err),
|
|
236
|
+
);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
apiKeys
|
|
242
|
+
.command("list")
|
|
243
|
+
.description("List API keys for a company")
|
|
244
|
+
.action(async () => {
|
|
245
|
+
try {
|
|
246
|
+
const token = await ensureCognitoToken();
|
|
247
|
+
const companyUid = await getCompanyUid(
|
|
248
|
+
token,
|
|
249
|
+
(apiKeys.opts() as ApiKeysGroupOptions).company,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const res = await vaultApiFetch({
|
|
253
|
+
token,
|
|
254
|
+
path: "/v1/api-keys",
|
|
255
|
+
query: { companyUid },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
throw new Error(await readApiError(res, "list API keys"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const data = (await res.json()) as ListApiKeysResponse;
|
|
263
|
+
if (data.apiKeys.length === 0) {
|
|
264
|
+
console.log(chalk.dim("No API keys found."));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
renderApiKeysTable(data.apiKeys);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error(
|
|
271
|
+
chalk.red("Error:"),
|
|
272
|
+
err instanceof Error ? err.message : String(err),
|
|
273
|
+
);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
apiKeys
|
|
279
|
+
.command("revoke <keyId>")
|
|
280
|
+
.description("Revoke an API key")
|
|
281
|
+
.action(async (keyId: string) => {
|
|
282
|
+
try {
|
|
283
|
+
const token = await ensureCognitoToken();
|
|
284
|
+
const res = await vaultApiFetch({
|
|
285
|
+
token,
|
|
286
|
+
path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,
|
|
287
|
+
method: "POST",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
throw new Error(await readApiError(res, "revoke this API key"));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const data = (await res.json()) as RevokeApiKeyResponse;
|
|
295
|
+
console.log(chalk.green(`Revoked API key '${data.apiKey.keyId}'`));
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error(
|
|
298
|
+
chalk.red("Error:"),
|
|
299
|
+
err instanceof Error ? err.message : String(err),
|
|
300
|
+
);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return apiKeys;
|
|
306
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { registerPublishCommand } from "./commands/publish.js";
|
|
|
28
28
|
import { registerCreatorsCommand } from "./commands/creators.js";
|
|
29
29
|
import { registerTeamSyncCommand } from "./commands/team-sync.js";
|
|
30
30
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
31
|
+
import { registerApiKeysCommand } from "./commands/api-keys.js";
|
|
31
32
|
import { registerSecretsCommand } from "./commands/secrets.js";
|
|
32
33
|
import { registerRunCommand } from "./commands/run.js";
|
|
33
34
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
@@ -144,6 +145,9 @@ registerAuthCommands(program);
|
|
|
144
145
|
// Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
|
|
145
146
|
registerSecretsCommand(program);
|
|
146
147
|
|
|
148
|
+
// API key management (subcommand group — hq api-keys create|list|revoke)
|
|
149
|
+
registerApiKeysCommand(program);
|
|
150
|
+
|
|
147
151
|
// Schema-driven dev runner — hq run [options] -- <cmd>
|
|
148
152
|
registerRunCommand(program);
|
|
149
153
|
|