@indigoai-us/hq-cli 5.47.12 → 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/bin/hq-auth-refresh.d.ts +3 -2
- package/dist/bin/hq-auth-refresh.js +5 -4
- package/dist/commands/api-keys.d.ts +3 -0
- package/dist/commands/api-keys.js +198 -0
- package/dist/commands/auth.d.ts +2 -2
- package/dist/commands/auth.js +7 -7
- package/dist/index.js +5 -2
- package/dist/utils/cognito-session.d.ts +6 -4
- package/dist/utils/cognito-session.js +15 -10
- package/package.json +1 -1
- package/src/bin/hq-auth-refresh.ts +3 -2
- package/src/commands/api-keys.test.ts +217 -0
- package/src/commands/api-keys.ts +306 -0
- package/src/commands/auth.ts +5 -5
- package/src/index.ts +4 -0
- package/src/utils/cognito-session.machine.test.ts +11 -16
- package/src/utils/cognito-session.refresh.test.ts +84 -0
- package/src/utils/cognito-session.ts +14 -8
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* shell out to a plain binary instead of a subcommand. Equivalent to
|
|
7
7
|
* `hq auth refresh`.
|
|
8
8
|
*
|
|
9
|
-
* Exit 0
|
|
10
|
-
*
|
|
9
|
+
* Exit 0 if a valid session is ensured now (healthy cache hit, refresh, or
|
|
10
|
+
* machine-token mint). Exit 1 if no valid session could be ensured
|
|
11
|
+
* non-interactively.
|
|
11
12
|
*/
|
|
12
13
|
export {};
|
|
13
14
|
//# sourceMappingURL=hq-auth-refresh.d.ts.map
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* shell out to a plain binary instead of a subcommand. Equivalent to
|
|
7
7
|
* `hq auth refresh`.
|
|
8
8
|
*
|
|
9
|
-
* Exit 0
|
|
10
|
-
*
|
|
9
|
+
* Exit 0 if a valid session is ensured now (healthy cache hit, refresh, or
|
|
10
|
+
* machine-token mint). Exit 1 if no valid session could be ensured
|
|
11
|
+
* non-interactively.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
!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]="
|
|
14
|
+
!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]="03d2c531-f620-5d93-a87b-9a87264ad77b")}catch(e){}}();
|
|
14
15
|
import { initSentry, Sentry } from "../sentry.js";
|
|
15
16
|
import { refreshCachedSession } from "../utils/cognito-session.js";
|
|
16
17
|
initSentry();
|
|
@@ -37,4 +38,4 @@ initSentry();
|
|
|
37
38
|
}
|
|
38
39
|
})();
|
|
39
40
|
//# sourceMappingURL=hq-auth-refresh.js.map
|
|
40
|
-
//# debugId=
|
|
41
|
+
//# debugId=03d2c531-f620-5d93-a87b-9a87264ad77b
|
|
@@ -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/commands/auth.d.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Subcommands:
|
|
5
5
|
* hq auth login — open the Cognito Hosted UI in the browser and cache tokens
|
|
6
6
|
* hq auth logout — clear the cached HQ session
|
|
7
|
-
* hq auth refresh —
|
|
7
|
+
* hq auth refresh — ensure a valid cached Cognito session (non-interactive)
|
|
8
8
|
* hq auth status — show whether a valid session is cached + expiry
|
|
9
9
|
*
|
|
10
10
|
* Sign-up is owned by the onboarding web app at
|
|
11
11
|
* https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
|
|
12
12
|
* into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
|
|
13
|
-
* session is kept
|
|
13
|
+
* session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
|
|
14
14
|
* by the deploy + sync skills.
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from "commander";
|
package/dist/commands/auth.js
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* Subcommands:
|
|
5
5
|
* hq auth login — open the Cognito Hosted UI in the browser and cache tokens
|
|
6
6
|
* hq auth logout — clear the cached HQ session
|
|
7
|
-
* hq auth refresh —
|
|
7
|
+
* hq auth refresh — ensure a valid cached Cognito session (non-interactive)
|
|
8
8
|
* hq auth status — show whether a valid session is cached + expiry
|
|
9
9
|
*
|
|
10
10
|
* Sign-up is owned by the onboarding web app at
|
|
11
11
|
* https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
|
|
12
12
|
* into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
|
|
13
|
-
* session is kept
|
|
13
|
+
* session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
|
|
14
14
|
* by the deploy + sync skills.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
!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]="
|
|
17
|
+
!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]="66a74720-f5d1-58ed-93fd-ead15fc18146")}catch(e){}}();
|
|
18
18
|
import chalk from "chalk";
|
|
19
19
|
import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
|
|
20
20
|
import { refreshCachedSession, } from "../utils/cognito-session.js";
|
|
@@ -98,14 +98,14 @@ export function registerAuthCommands(program) {
|
|
|
98
98
|
});
|
|
99
99
|
authCmd
|
|
100
100
|
.command("refresh")
|
|
101
|
-
.description("
|
|
101
|
+
.description("Ensure a valid cached Cognito session (refresh only if expiring; no-op if already valid)")
|
|
102
102
|
.action(async () => {
|
|
103
103
|
const result = await refreshCachedSession();
|
|
104
104
|
if (result.refreshed) {
|
|
105
|
-
console.log(chalk.green("HQ session
|
|
105
|
+
console.log(chalk.green("HQ session ensured"));
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
|
-
console.error(chalk.yellow(`No
|
|
108
|
+
console.error(chalk.yellow(`No valid session: ${result.reason ?? "unknown"}`));
|
|
109
109
|
process.exit(1);
|
|
110
110
|
});
|
|
111
111
|
authCmd
|
|
@@ -136,4 +136,4 @@ export function registerAuthCommands(program) {
|
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
138
|
//# sourceMappingURL=auth.js.map
|
|
139
|
-
//# debugId=
|
|
139
|
+
//# debugId=66a74720-f5d1-58ed-93fd-ead15fc18146
|
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
|
|
@@ -109,10 +109,12 @@ export declare const CLI_CLIENT_INFO: ClientInfo;
|
|
|
109
109
|
/** Build a VaultServiceConfig with the given access token. */
|
|
110
110
|
export declare function buildVaultConfig(authToken: string): VaultServiceConfig;
|
|
111
111
|
/**
|
|
112
|
-
*
|
|
113
|
-
* `hq auth refresh` and the `hq-auth-refresh`
|
|
114
|
-
*
|
|
115
|
-
*
|
|
112
|
+
* Ensure a valid cached Cognito session exists right now, non-interactively,
|
|
113
|
+
* and return the result. Used by `hq auth refresh` and the `hq-auth-refresh`
|
|
114
|
+
* bin. `refreshed: true` means a valid session is ensured now from a healthy
|
|
115
|
+
* cache hit, a token refresh, or a machine-token mint. `refreshed: false`
|
|
116
|
+
* means no valid session could be ensured non-interactively; the caller gets a
|
|
117
|
+
* reason string and can decide what to do next. Never opens a browser.
|
|
116
118
|
*/
|
|
117
119
|
export declare function refreshCachedSession(): Promise<{
|
|
118
120
|
refreshed: boolean;
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
* HQ_VAULT_API_URL — vault-service API Gateway URL
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
!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]="
|
|
22
|
+
!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]="ab9733a5-3984-553b-8fcc-91b86d112c79")}catch(e){}}();
|
|
23
23
|
import * as fs from "fs";
|
|
24
24
|
import * as os from "os";
|
|
25
25
|
import * as path from "path";
|
|
26
26
|
import * as yaml from "js-yaml";
|
|
27
27
|
import chalk from "chalk";
|
|
28
|
-
import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens,
|
|
28
|
+
import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, } from "@indigoai-us/hq-cloud";
|
|
29
29
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
30
30
|
export const DEFAULT_COGNITO = {
|
|
31
31
|
region: process.env.AWS_REGION ?? "us-east-1",
|
|
@@ -334,17 +334,19 @@ export function buildVaultConfig(authToken) {
|
|
|
334
334
|
};
|
|
335
335
|
}
|
|
336
336
|
/**
|
|
337
|
-
*
|
|
338
|
-
* `hq auth refresh` and the `hq-auth-refresh`
|
|
339
|
-
*
|
|
340
|
-
*
|
|
337
|
+
* Ensure a valid cached Cognito session exists right now, non-interactively,
|
|
338
|
+
* and return the result. Used by `hq auth refresh` and the `hq-auth-refresh`
|
|
339
|
+
* bin. `refreshed: true` means a valid session is ensured now from a healthy
|
|
340
|
+
* cache hit, a token refresh, or a machine-token mint. `refreshed: false`
|
|
341
|
+
* means no valid session could be ensured non-interactively; the caller gets a
|
|
342
|
+
* reason string and can decide what to do next. Never opens a browser.
|
|
341
343
|
*/
|
|
342
344
|
export async function refreshCachedSession() {
|
|
343
|
-
// Machine identities have no refresh token
|
|
344
|
-
//
|
|
345
|
+
// Machine identities have no refresh token; ensure a valid cached machine
|
|
346
|
+
// session without forcing a re-mint when the cache is already healthy.
|
|
345
347
|
if (isMachineIdentity()) {
|
|
346
348
|
try {
|
|
347
|
-
await
|
|
349
|
+
await getValidMachineTokens(DEFAULT_COGNITO);
|
|
348
350
|
return { refreshed: true };
|
|
349
351
|
}
|
|
350
352
|
catch (err) {
|
|
@@ -358,6 +360,9 @@ export async function refreshCachedSession() {
|
|
|
358
360
|
if (!cached) {
|
|
359
361
|
return { refreshed: false, reason: "no cached session" };
|
|
360
362
|
}
|
|
363
|
+
if (!isExpiring(cached, 120)) {
|
|
364
|
+
return { refreshed: true };
|
|
365
|
+
}
|
|
361
366
|
try {
|
|
362
367
|
await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
|
|
363
368
|
return { refreshed: true };
|
|
@@ -370,4 +375,4 @@ export async function refreshCachedSession() {
|
|
|
370
375
|
}
|
|
371
376
|
}
|
|
372
377
|
//# sourceMappingURL=cognito-session.js.map
|
|
373
|
-
//# debugId=
|
|
378
|
+
//# debugId=ab9733a5-3984-553b-8fcc-91b86d112c79
|
package/package.json
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* shell out to a plain binary instead of a subcommand. Equivalent to
|
|
8
8
|
* `hq auth refresh`.
|
|
9
9
|
*
|
|
10
|
-
* Exit 0
|
|
11
|
-
*
|
|
10
|
+
* Exit 0 if a valid session is ensured now (healthy cache hit, refresh, or
|
|
11
|
+
* machine-token mint). Exit 1 if no valid session could be ensured
|
|
12
|
+
* non-interactively.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { initSentry, Sentry } from "../sentry.js";
|
|
@@ -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/commands/auth.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Subcommands:
|
|
5
5
|
* hq auth login — open the Cognito Hosted UI in the browser and cache tokens
|
|
6
6
|
* hq auth logout — clear the cached HQ session
|
|
7
|
-
* hq auth refresh —
|
|
7
|
+
* hq auth refresh — ensure a valid cached Cognito session (non-interactive)
|
|
8
8
|
* hq auth status — show whether a valid session is cached + expiry
|
|
9
9
|
*
|
|
10
10
|
* Sign-up is owned by the onboarding web app at
|
|
11
11
|
* https://onboarding.indigo-hq.com. `hq auth login` signs an existing account
|
|
12
12
|
* into this machine by writing ~/.hq/cognito-tokens.json; once cached, the
|
|
13
|
-
* session is kept
|
|
13
|
+
* session is kept valid by `hq auth refresh` / `hq-auth-refresh` and consumed
|
|
14
14
|
* by the deploy + sync skills.
|
|
15
15
|
*/
|
|
16
16
|
|
|
@@ -137,16 +137,16 @@ export function registerAuthCommands(program: Command): void {
|
|
|
137
137
|
authCmd
|
|
138
138
|
.command("refresh")
|
|
139
139
|
.description(
|
|
140
|
-
"
|
|
140
|
+
"Ensure a valid cached Cognito session (refresh only if expiring; no-op if already valid)",
|
|
141
141
|
)
|
|
142
142
|
.action(async () => {
|
|
143
143
|
const result = await refreshCachedSession();
|
|
144
144
|
if (result.refreshed) {
|
|
145
|
-
console.log(chalk.green("HQ session
|
|
145
|
+
console.log(chalk.green("HQ session ensured"));
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
148
|
console.error(
|
|
149
|
-
chalk.yellow(`No
|
|
149
|
+
chalk.yellow(`No valid session: ${result.reason ?? "unknown"}`),
|
|
150
150
|
);
|
|
151
151
|
process.exit(1);
|
|
152
152
|
});
|
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
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* authorizer accepts ID tokens and the agent claims
|
|
7
7
|
* (custom:entityType/custom:entityUid) ride the ID token only
|
|
8
8
|
* - never refresh or open a browser — sessions re-mint on demand
|
|
9
|
-
* - treat `hq auth refresh` as a
|
|
9
|
+
* - treat `hq auth refresh` as a non-interactive ensure-valid-session call
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
@@ -81,34 +81,29 @@ describe("ensureCognitoToken in machine mode", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
describe("refreshCachedSession in machine mode", () => {
|
|
84
|
-
it("
|
|
84
|
+
it("ensures a healthy machine session via getValidMachineTokens", async () => {
|
|
85
85
|
mocks.isMachineIdentity.mockReturnValue(true);
|
|
86
|
-
mocks.
|
|
87
|
-
mocks.loadCachedTokens.mockReturnValue(null);
|
|
86
|
+
mocks.getValidMachineTokens.mockResolvedValue(MACHINE_TOKENS);
|
|
88
87
|
|
|
89
88
|
const result = await refreshCachedSession();
|
|
90
89
|
|
|
91
90
|
expect(result).toEqual({ refreshed: true });
|
|
92
|
-
expect(mocks.
|
|
91
|
+
expect(mocks.getValidMachineTokens).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(mocks.mintMachineTokens).not.toHaveBeenCalled();
|
|
93
93
|
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
94
|
+
expect(mocks.browserLogin).not.toHaveBeenCalled();
|
|
95
|
+
expect(mocks.loadCachedTokens).not.toHaveBeenCalled();
|
|
94
96
|
});
|
|
95
97
|
|
|
96
|
-
it("surfaces the
|
|
98
|
+
it("surfaces the machine-session error as the reason on failure", async () => {
|
|
97
99
|
mocks.isMachineIdentity.mockReturnValue(true);
|
|
98
|
-
mocks.
|
|
100
|
+
mocks.getValidMachineTokens.mockRejectedValue(new Error("NotAuthorized"));
|
|
99
101
|
|
|
100
102
|
const result = await refreshCachedSession();
|
|
101
103
|
|
|
102
104
|
expect(result.refreshed).toBe(false);
|
|
103
105
|
expect(result.reason).toContain("NotAuthorized");
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
it("keeps the human refresh path unchanged", async () => {
|
|
107
|
-
mocks.isMachineIdentity.mockReturnValue(false);
|
|
108
|
-
mocks.loadCachedTokens.mockReturnValue(null);
|
|
109
|
-
|
|
110
|
-
const result = await refreshCachedSession();
|
|
111
|
-
|
|
112
|
-
expect(result).toEqual({ refreshed: false, reason: "no cached session" });
|
|
106
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
107
|
+
expect(mocks.browserLogin).not.toHaveBeenCalled();
|
|
113
108
|
});
|
|
114
109
|
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
isMachineIdentity: vi.fn(),
|
|
5
|
+
getValidMachineTokens: vi.fn(),
|
|
6
|
+
mintMachineTokens: vi.fn(),
|
|
7
|
+
loadCachedTokens: vi.fn(),
|
|
8
|
+
refreshTokens: vi.fn(),
|
|
9
|
+
browserLogin: vi.fn(),
|
|
10
|
+
isExpiring: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
|
|
15
|
+
return { ...actual, ...mocks };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
import { refreshCachedSession } from "./cognito-session.js";
|
|
19
|
+
|
|
20
|
+
const HUMAN_TOKENS = {
|
|
21
|
+
accessToken: "human-access-token",
|
|
22
|
+
idToken: "human-id-token",
|
|
23
|
+
refreshToken: "human-refresh-token",
|
|
24
|
+
expiresAt: Date.now() + 3600_000,
|
|
25
|
+
tokenType: "Bearer" as const,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.resetAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("refreshCachedSession in human mode", () => {
|
|
33
|
+
it("returns no cached session without attempting a refresh", async () => {
|
|
34
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
35
|
+
mocks.loadCachedTokens.mockReturnValue(null);
|
|
36
|
+
|
|
37
|
+
const result = await refreshCachedSession();
|
|
38
|
+
|
|
39
|
+
expect(result).toEqual({ refreshed: false, reason: "no cached session" });
|
|
40
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns success on a healthy cached session without refreshing", async () => {
|
|
44
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
45
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
46
|
+
mocks.isExpiring.mockReturnValue(false);
|
|
47
|
+
|
|
48
|
+
const result = await refreshCachedSession();
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({ refreshed: true });
|
|
51
|
+
expect(mocks.isExpiring).toHaveBeenCalledWith(HUMAN_TOKENS, 120);
|
|
52
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("refreshes once when the cached session is expiring", async () => {
|
|
56
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
57
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
58
|
+
mocks.isExpiring.mockReturnValue(true);
|
|
59
|
+
mocks.refreshTokens.mockResolvedValue({
|
|
60
|
+
...HUMAN_TOKENS,
|
|
61
|
+
accessToken: "refreshed-access-token",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await refreshCachedSession();
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({ refreshed: true });
|
|
67
|
+
expect(mocks.refreshTokens).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(mocks.refreshTokens).toHaveBeenCalledWith(
|
|
69
|
+
expect.any(Object),
|
|
70
|
+
HUMAN_TOKENS.refreshToken,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("surfaces refresh failures as a non-throwing result", async () => {
|
|
75
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
76
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
77
|
+
mocks.isExpiring.mockReturnValue(true);
|
|
78
|
+
mocks.refreshTokens.mockRejectedValue(new Error("token expired"));
|
|
79
|
+
|
|
80
|
+
const result = await refreshCachedSession();
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ refreshed: false, reason: "token expired" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
detectHqCoreVersion,
|
|
33
33
|
isMachineIdentity,
|
|
34
34
|
getValidMachineTokens,
|
|
35
|
-
mintMachineTokens,
|
|
36
35
|
type CognitoAuthConfig,
|
|
37
36
|
type ClientInfo,
|
|
38
37
|
type VaultServiceConfig,
|
|
@@ -399,20 +398,22 @@ export function buildVaultConfig(authToken: string): VaultServiceConfig {
|
|
|
399
398
|
}
|
|
400
399
|
|
|
401
400
|
/**
|
|
402
|
-
*
|
|
403
|
-
* `hq auth refresh` and the `hq-auth-refresh`
|
|
404
|
-
*
|
|
405
|
-
*
|
|
401
|
+
* Ensure a valid cached Cognito session exists right now, non-interactively,
|
|
402
|
+
* and return the result. Used by `hq auth refresh` and the `hq-auth-refresh`
|
|
403
|
+
* bin. `refreshed: true` means a valid session is ensured now from a healthy
|
|
404
|
+
* cache hit, a token refresh, or a machine-token mint. `refreshed: false`
|
|
405
|
+
* means no valid session could be ensured non-interactively; the caller gets a
|
|
406
|
+
* reason string and can decide what to do next. Never opens a browser.
|
|
406
407
|
*/
|
|
407
408
|
export async function refreshCachedSession(): Promise<{
|
|
408
409
|
refreshed: boolean;
|
|
409
410
|
reason?: string;
|
|
410
411
|
}> {
|
|
411
|
-
// Machine identities have no refresh token
|
|
412
|
-
//
|
|
412
|
+
// Machine identities have no refresh token; ensure a valid cached machine
|
|
413
|
+
// session without forcing a re-mint when the cache is already healthy.
|
|
413
414
|
if (isMachineIdentity()) {
|
|
414
415
|
try {
|
|
415
|
-
await
|
|
416
|
+
await getValidMachineTokens(DEFAULT_COGNITO);
|
|
416
417
|
return { refreshed: true };
|
|
417
418
|
} catch (err) {
|
|
418
419
|
return {
|
|
@@ -426,6 +427,11 @@ export async function refreshCachedSession(): Promise<{
|
|
|
426
427
|
if (!cached) {
|
|
427
428
|
return { refreshed: false, reason: "no cached session" };
|
|
428
429
|
}
|
|
430
|
+
|
|
431
|
+
if (!isExpiring(cached, 120)) {
|
|
432
|
+
return { refreshed: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
429
435
|
try {
|
|
430
436
|
await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
|
|
431
437
|
return { refreshed: true };
|