@indigoai-us/hq-cli 5.77.10 → 5.77.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/commands/api-keys.js +53 -10
- package/dist/commands/members.d.ts +8 -0
- package/dist/commands/members.js +82 -9
- package/dist/commands/secrets.js +127 -21
- package/dist/utils/resolve-vault-credential.d.ts +30 -0
- package/dist/utils/resolve-vault-credential.js +48 -0
- package/package.json +1 -1
- package/src/commands/api-keys.test.ts +75 -1
- package/src/commands/api-keys.ts +86 -10
- package/src/commands/members.test.ts +195 -3
- package/src/commands/members.ts +124 -10
- package/src/commands/secrets.test.ts +133 -0
- package/src/commands/secrets.ts +172 -29
- package/src/utils/resolve-vault-credential.test.ts +69 -0
- package/src/utils/resolve-vault-credential.ts +60 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ensureCognitoToken } from "./cognito-session.js";
|
|
2
|
+
/** Vault API keys issued by `hq api-keys create` (hq-pro). */
|
|
3
|
+
export const HQ_API_KEY_PREFIX = "hqk_";
|
|
4
|
+
/**
|
|
5
|
+
* Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
|
|
6
|
+
* Does not validate prefix — use {@link resolveVaultCredential} for that.
|
|
7
|
+
*/
|
|
8
|
+
export function peekHqApiKey() {
|
|
9
|
+
const raw = process.env.HQ_API_KEY;
|
|
10
|
+
if (raw === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const trimmed = raw.trim();
|
|
13
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve vault auth for CLI commands.
|
|
17
|
+
*
|
|
18
|
+
* When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
|
|
19
|
+
* and Cognito is never used as a fallback (fail-closed). When unset, uses the
|
|
20
|
+
* cached Cognito session (interactive login if needed).
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveVaultCredential(options) {
|
|
23
|
+
const apiKey = peekHqApiKey();
|
|
24
|
+
if (apiKey !== undefined) {
|
|
25
|
+
if (!apiKey.startsWith(HQ_API_KEY_PREFIX)) {
|
|
26
|
+
throw new Error(`HQ_API_KEY must start with '${HQ_API_KEY_PREFIX}' (vault API key). ` +
|
|
27
|
+
`Got a value that is not a vault key — refusing to fall back to Cognito. ` +
|
|
28
|
+
`Unset HQ_API_KEY to use your session, or create a key with \`hq api-keys create\`.`);
|
|
29
|
+
}
|
|
30
|
+
return { kind: "api-key", token: apiKey };
|
|
31
|
+
}
|
|
32
|
+
const token = await ensureCognitoToken({
|
|
33
|
+
interactive: options?.interactive,
|
|
34
|
+
});
|
|
35
|
+
return { kind: "cognito", token };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Throw when HQ_API_KEY is set but the command only supports Cognito sessions
|
|
39
|
+
* (list, set, ACL, api-keys admin, etc.).
|
|
40
|
+
*/
|
|
41
|
+
export function assertCognitoOnlyCommand(commandLabel) {
|
|
42
|
+
if (peekHqApiKey() === undefined)
|
|
43
|
+
return;
|
|
44
|
+
throw new Error(`HQ_API_KEY is set; '${commandLabel}' is not supported for API keys. ` +
|
|
45
|
+
`API keys support scoped secret reads via \`hq secrets get\`, \`hq secrets exec\`, ` +
|
|
46
|
+
`and \`hq secrets env\`. Unset HQ_API_KEY to use your Cognito session.`);
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=resolve-vault-credential.js.map
|
package/package.json
CHANGED
|
@@ -126,10 +126,78 @@ describe("hq api-keys create", () => {
|
|
|
126
126
|
.map((call) => call.map((value) => String(value)).join(" "))
|
|
127
127
|
.join("\n");
|
|
128
128
|
expect(printed).toContain("Store this key now");
|
|
129
|
-
expect(printed.
|
|
129
|
+
expect(printed).toContain("export HQ_API_KEY=");
|
|
130
|
+
expect(printed).toContain("hq secrets get");
|
|
131
|
+
// Key value appears in Store line + export line only (not in list metadata).
|
|
132
|
+
expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(2);
|
|
130
133
|
expect(errSpy).not.toHaveBeenCalled();
|
|
131
134
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
132
135
|
});
|
|
136
|
+
|
|
137
|
+
it("includes deploy scope when --deploy-app is set", async () => {
|
|
138
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
139
|
+
jsonResponse({
|
|
140
|
+
apiKey: {
|
|
141
|
+
keyId: "key_deploy",
|
|
142
|
+
companyUid: "cmp_acme",
|
|
143
|
+
name: "Deploy key",
|
|
144
|
+
scope: {
|
|
145
|
+
allowedPrefixes: ["CI"],
|
|
146
|
+
permission: "read",
|
|
147
|
+
deploy: {
|
|
148
|
+
apps: ["my-app", "other-app"],
|
|
149
|
+
capabilities: ["deploy:write"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
status: "active",
|
|
153
|
+
createdAt: "2026-06-19T12:00:00.000Z",
|
|
154
|
+
lastUsedAt: null,
|
|
155
|
+
expiresAt: null,
|
|
156
|
+
},
|
|
157
|
+
key: { value: "hqk_deploy_secret" },
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const program = buildProgram();
|
|
162
|
+
await program.parseAsync(
|
|
163
|
+
[
|
|
164
|
+
"api-keys",
|
|
165
|
+
"create",
|
|
166
|
+
"--name",
|
|
167
|
+
"Deploy key",
|
|
168
|
+
"--scope",
|
|
169
|
+
"CI",
|
|
170
|
+
"--deploy-app",
|
|
171
|
+
"my-app",
|
|
172
|
+
"--deploy-app",
|
|
173
|
+
"other-app",
|
|
174
|
+
],
|
|
175
|
+
{ from: "user" },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
179
|
+
token: "test-token",
|
|
180
|
+
path: "/v1/api-keys",
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: {
|
|
183
|
+
companyUid: "cmp_acme",
|
|
184
|
+
name: "Deploy key",
|
|
185
|
+
allowedPrefixes: ["CI"],
|
|
186
|
+
permission: "read",
|
|
187
|
+
deploy: {
|
|
188
|
+
apps: ["my-app", "other-app"],
|
|
189
|
+
capabilities: ["deploy:write"],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const printed = logSpy.mock.calls
|
|
195
|
+
.map((call) => call.map((value) => String(value)).join(" "))
|
|
196
|
+
.join("\n");
|
|
197
|
+
expect(printed).toContain("Deploy apps:");
|
|
198
|
+
expect(printed).toContain("my-app, other-app");
|
|
199
|
+
expect(printed).toContain("hq-deploy API");
|
|
200
|
+
});
|
|
133
201
|
});
|
|
134
202
|
|
|
135
203
|
describe("hq api-keys list", () => {
|
|
@@ -145,6 +213,10 @@ describe("hq api-keys list", () => {
|
|
|
145
213
|
scope: {
|
|
146
214
|
allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
|
|
147
215
|
permission: "read",
|
|
216
|
+
deploy: {
|
|
217
|
+
apps: ["preview-app"],
|
|
218
|
+
capabilities: ["deploy:write"],
|
|
219
|
+
},
|
|
148
220
|
},
|
|
149
221
|
status: "active",
|
|
150
222
|
createdAt: "2026-06-19T12:00:00.000Z",
|
|
@@ -173,9 +245,11 @@ describe("hq api-keys list", () => {
|
|
|
173
245
|
.map((call) => call.map((value) => String(value)).join(" "))
|
|
174
246
|
.join("\n");
|
|
175
247
|
expect(printed).toContain("KEY ID");
|
|
248
|
+
expect(printed).toContain("DEPLOY");
|
|
176
249
|
expect(printed).toContain("key_123");
|
|
177
250
|
expect(printed).toContain("CI key");
|
|
178
251
|
expect(printed).toContain("HQ_PRO/HQ_PROD, HQ_PRO/HQ_DEV");
|
|
252
|
+
expect(printed).toContain("preview-app");
|
|
179
253
|
expect(printed).not.toContain("hqk_should_not_print");
|
|
180
254
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
181
255
|
});
|
package/src/commands/api-keys.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
+
import { assertCognitoOnlyCommand } from "../utils/resolve-vault-credential.js";
|
|
4
5
|
import { getCompanyUid, vaultApiFetch } from "./secrets.js";
|
|
5
6
|
|
|
7
|
+
async function requireCognitoForApiKeys(label: string): Promise<string> {
|
|
8
|
+
assertCognitoOnlyCommand(label);
|
|
9
|
+
return ensureCognitoToken();
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
type ApiKeyPermission = "read" | "write" | "admin";
|
|
7
13
|
|
|
8
14
|
interface ApiKeyMetadata {
|
|
@@ -12,6 +18,10 @@ interface ApiKeyMetadata {
|
|
|
12
18
|
scope: {
|
|
13
19
|
allowedPrefixes: string[];
|
|
14
20
|
permission: ApiKeyPermission;
|
|
21
|
+
deploy?: {
|
|
22
|
+
apps: string[];
|
|
23
|
+
capabilities: string[];
|
|
24
|
+
};
|
|
15
25
|
};
|
|
16
26
|
status: string;
|
|
17
27
|
createdAt: string;
|
|
@@ -42,6 +52,7 @@ interface ApiKeysGroupOptions {
|
|
|
42
52
|
interface CreateApiKeyOptions {
|
|
43
53
|
name: string;
|
|
44
54
|
scope: string[];
|
|
55
|
+
deployApp: string[];
|
|
45
56
|
permission: string;
|
|
46
57
|
expires?: string;
|
|
47
58
|
}
|
|
@@ -58,6 +69,13 @@ function formatPrefixes(prefixes: string[]): string {
|
|
|
58
69
|
return prefixes.length > 0 ? prefixes.join(", ") : "-";
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
function formatDeployApps(
|
|
73
|
+
deploy?: { apps: string[]; capabilities: string[] },
|
|
74
|
+
): string {
|
|
75
|
+
if (!deploy?.apps?.length) return "-";
|
|
76
|
+
return deploy.apps.join(", ");
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
function parsePermission(value: string): ApiKeyPermission {
|
|
62
80
|
if (value === "read" || value === "write" || value === "admin") {
|
|
63
81
|
return value;
|
|
@@ -107,6 +125,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
|
|
|
107
125
|
name: apiKey.name,
|
|
108
126
|
permission: apiKey.scope.permission,
|
|
109
127
|
prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
|
|
128
|
+
deployApps: formatDeployApps(apiKey.scope.deploy),
|
|
110
129
|
status: apiKey.status,
|
|
111
130
|
lastUsedAt: formatMaybe(apiKey.lastUsedAt),
|
|
112
131
|
expiresAt: formatMaybe(apiKey.expiresAt),
|
|
@@ -119,6 +138,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
|
|
|
119
138
|
...rows.map((row) => row.permission.length),
|
|
120
139
|
);
|
|
121
140
|
const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
|
|
141
|
+
const deployWidth = Math.max(6, ...rows.map((row) => row.deployApps.length));
|
|
122
142
|
const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
|
|
123
143
|
const lastUsedWidth = Math.max(
|
|
124
144
|
11,
|
|
@@ -134,6 +154,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
|
|
|
134
154
|
"NAME".padEnd(nameWidth),
|
|
135
155
|
"PERMISSION".padEnd(permissionWidth),
|
|
136
156
|
"PREFIXES".padEnd(prefixesWidth),
|
|
157
|
+
"DEPLOY".padEnd(deployWidth),
|
|
137
158
|
"STATUS".padEnd(statusWidth),
|
|
138
159
|
"LAST USED".padEnd(lastUsedWidth),
|
|
139
160
|
"EXPIRES".padEnd(expiresWidth),
|
|
@@ -147,6 +168,7 @@ function renderApiKeysTable(apiKeys: ApiKeyMetadata[]): void {
|
|
|
147
168
|
row.name.padEnd(nameWidth),
|
|
148
169
|
row.permission.padEnd(permissionWidth),
|
|
149
170
|
row.prefixes.padEnd(prefixesWidth),
|
|
171
|
+
row.deployApps.padEnd(deployWidth),
|
|
150
172
|
row.status.padEnd(statusWidth),
|
|
151
173
|
row.lastUsedAt.padEnd(lastUsedWidth),
|
|
152
174
|
row.expiresAt.padEnd(expiresWidth),
|
|
@@ -163,32 +185,42 @@ export function registerApiKeysCommand(program: Command): Command {
|
|
|
163
185
|
|
|
164
186
|
apiKeys
|
|
165
187
|
.command("create")
|
|
166
|
-
.description(
|
|
188
|
+
.description(
|
|
189
|
+
"Create a new API key (vault secrets and/or scoped deploy via --deploy-app)",
|
|
190
|
+
)
|
|
167
191
|
.requiredOption("--name <label>", "Human-readable label for the API key")
|
|
168
192
|
.option(
|
|
169
193
|
"--scope <prefix>",
|
|
170
|
-
"Allowed prefix (repeatable)",
|
|
194
|
+
"Allowed secret prefix (repeatable)",
|
|
195
|
+
collectRepeatedOption,
|
|
196
|
+
[],
|
|
197
|
+
)
|
|
198
|
+
.option(
|
|
199
|
+
"--deploy-app <id>",
|
|
200
|
+
"Deploy app id/slug allowed for publish (repeatable)",
|
|
171
201
|
collectRepeatedOption,
|
|
172
202
|
[],
|
|
173
203
|
)
|
|
174
204
|
.option(
|
|
175
205
|
"--permission <level>",
|
|
176
|
-
"
|
|
206
|
+
"Secret permission level: read | write | admin (required when --scope is set)",
|
|
177
207
|
"read",
|
|
178
208
|
)
|
|
179
209
|
.option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
|
|
180
210
|
.action(async (opts: CreateApiKeyOptions) => {
|
|
181
211
|
try {
|
|
182
|
-
if (opts.scope.length === 0) {
|
|
212
|
+
if (opts.scope.length === 0 && opts.deployApp.length === 0) {
|
|
183
213
|
console.error(
|
|
184
|
-
chalk.red(
|
|
214
|
+
chalk.red(
|
|
215
|
+
"Error: provide at least one --scope <prefix> and/or --deploy-app <id>.",
|
|
216
|
+
),
|
|
185
217
|
);
|
|
186
218
|
process.exit(1);
|
|
187
219
|
}
|
|
188
220
|
|
|
189
221
|
const permission = parsePermission(opts.permission);
|
|
190
222
|
const expiresAt = parseExpires(opts.expires);
|
|
191
|
-
const token = await
|
|
223
|
+
const token = await requireCognitoForApiKeys("api-keys create");
|
|
192
224
|
const companyUid = await getCompanyUid(
|
|
193
225
|
token,
|
|
194
226
|
(apiKeys.opts() as ApiKeysGroupOptions).company,
|
|
@@ -201,8 +233,20 @@ export function registerApiKeysCommand(program: Command): Command {
|
|
|
201
233
|
body: {
|
|
202
234
|
companyUid,
|
|
203
235
|
name: opts.name,
|
|
204
|
-
|
|
205
|
-
|
|
236
|
+
...(opts.scope.length > 0
|
|
237
|
+
? { allowedPrefixes: opts.scope, permission }
|
|
238
|
+
: {}),
|
|
239
|
+
...(opts.deployApp.length > 0
|
|
240
|
+
? {
|
|
241
|
+
deploy: {
|
|
242
|
+
apps: opts.deployApp,
|
|
243
|
+
capabilities: ["deploy:write"],
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: {}),
|
|
247
|
+
...(opts.scope.length === 0 && opts.deployApp.length > 0
|
|
248
|
+
? { permission }
|
|
249
|
+
: {}),
|
|
206
250
|
...(expiresAt ? { expiresAt } : {}),
|
|
207
251
|
},
|
|
208
252
|
});
|
|
@@ -225,10 +269,42 @@ export function registerApiKeysCommand(program: Command): Command {
|
|
|
225
269
|
console.log(
|
|
226
270
|
` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`,
|
|
227
271
|
);
|
|
272
|
+
console.log(
|
|
273
|
+
` Deploy apps: ${formatDeployApps(data.apiKey.scope.deploy)}`,
|
|
274
|
+
);
|
|
228
275
|
console.log(` Status: ${data.apiKey.status}`);
|
|
229
276
|
console.log(` Created: ${data.apiKey.createdAt}`);
|
|
230
277
|
console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
|
|
231
278
|
console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
|
|
279
|
+
console.log("");
|
|
280
|
+
console.log(chalk.bold("Usage"));
|
|
281
|
+
console.log(
|
|
282
|
+
" This key acts as you (Cognito identity), limited to the scopes above.",
|
|
283
|
+
);
|
|
284
|
+
console.log(" Export it for automation (never falls back to a session):");
|
|
285
|
+
console.log(`\n export HQ_API_KEY='${data.key.value}'\n`);
|
|
286
|
+
console.log(" Vault secrets:");
|
|
287
|
+
console.log(" hq secrets get <NAME> --reveal");
|
|
288
|
+
console.log(" hq secrets exec --only <NAME> -- <command>");
|
|
289
|
+
console.log(
|
|
290
|
+
" Or HTTP: POST /v1/keys/secrets/fetch with Authorization: Bearer <key>",
|
|
291
|
+
);
|
|
292
|
+
if (data.apiKey.scope.deploy?.apps?.length) {
|
|
293
|
+
console.log(" Deploy (scoped apps only):");
|
|
294
|
+
console.log(
|
|
295
|
+
" Authorization: Bearer <key> against the hq-deploy API",
|
|
296
|
+
);
|
|
297
|
+
console.log(
|
|
298
|
+
chalk.dim(
|
|
299
|
+
" Cannot change access-mode, password, or mint hqd_ keys.",
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
console.log(
|
|
304
|
+
chalk.dim(
|
|
305
|
+
" Unsupported under HQ_API_KEY: secrets list/set/share/acl (use a Cognito session).",
|
|
306
|
+
),
|
|
307
|
+
);
|
|
232
308
|
} catch (err) {
|
|
233
309
|
console.error(
|
|
234
310
|
chalk.red("Error:"),
|
|
@@ -243,7 +319,7 @@ export function registerApiKeysCommand(program: Command): Command {
|
|
|
243
319
|
.description("List API keys for a company")
|
|
244
320
|
.action(async () => {
|
|
245
321
|
try {
|
|
246
|
-
const token = await
|
|
322
|
+
const token = await requireCognitoForApiKeys("api-keys list");
|
|
247
323
|
const companyUid = await getCompanyUid(
|
|
248
324
|
token,
|
|
249
325
|
(apiKeys.opts() as ApiKeysGroupOptions).company,
|
|
@@ -280,7 +356,7 @@ export function registerApiKeysCommand(program: Command): Command {
|
|
|
280
356
|
.description("Revoke an API key")
|
|
281
357
|
.action(async (keyId: string) => {
|
|
282
358
|
try {
|
|
283
|
-
const token = await
|
|
359
|
+
const token = await requireCognitoForApiKeys("api-keys revoke");
|
|
284
360
|
const res = await vaultApiFetch({
|
|
285
361
|
token,
|
|
286
362
|
path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
listPendingInvites,
|
|
44
44
|
registerMembersCommand,
|
|
45
45
|
resendInvite,
|
|
46
|
+
resolveRoleChangeTarget,
|
|
46
47
|
resolveRevokeTargetToMembershipKey,
|
|
47
48
|
revokeInvite,
|
|
48
49
|
} from "./members.js";
|
|
@@ -1092,7 +1093,22 @@ describe("changeMemberRole", () => {
|
|
|
1092
1093
|
|
|
1093
1094
|
describe("registerMembersCommand promote", () => {
|
|
1094
1095
|
it("resolves an email target and promotes to a full-set role the old set-role couldn't (admin)", async () => {
|
|
1095
|
-
fetchSpy
|
|
1096
|
+
fetchSpy
|
|
1097
|
+
.mockResolvedValueOnce(
|
|
1098
|
+
jsonResponse(200, {
|
|
1099
|
+
members: [
|
|
1100
|
+
{
|
|
1101
|
+
membershipKey: "prs_alice#cmp_acme",
|
|
1102
|
+
personUid: "prs_alice",
|
|
1103
|
+
companyUid: "cmp_acme",
|
|
1104
|
+
role: "member",
|
|
1105
|
+
status: "active",
|
|
1106
|
+
personEmail: "Alice@Example.com",
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1109
|
+
}),
|
|
1110
|
+
)
|
|
1111
|
+
.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
1096
1112
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
1097
1113
|
|
|
1098
1114
|
await buildMembersProgram().parseAsync(
|
|
@@ -1100,14 +1116,18 @@ describe("registerMembersCommand promote", () => {
|
|
|
1100
1116
|
{ from: "user" },
|
|
1101
1117
|
);
|
|
1102
1118
|
|
|
1103
|
-
|
|
1119
|
+
expect(String(fetchSpy.mock.calls[0][0])).toMatch(
|
|
1120
|
+
/\/membership\/company\/cmp_acme$/,
|
|
1121
|
+
);
|
|
1122
|
+
const call = fetchSpy.mock.calls[1];
|
|
1104
1123
|
expect(String(call[0])).toMatch(/\/membership\/role$/);
|
|
1105
1124
|
const body = JSON.parse((call[1]?.body as string) ?? "{}");
|
|
1106
1125
|
expect(body).toEqual({
|
|
1107
1126
|
companyUid: "cmp_acme",
|
|
1108
|
-
membershipKey: "
|
|
1127
|
+
membershipKey: "prs_alice#cmp_acme",
|
|
1109
1128
|
newRole: "admin",
|
|
1110
1129
|
});
|
|
1130
|
+
expect(body.membershipKey).not.toContain("email:");
|
|
1111
1131
|
expect(logSpy).toHaveBeenCalledWith(
|
|
1112
1132
|
expect.stringContaining("Updated role for 'alice@example.com' to admin"),
|
|
1113
1133
|
);
|
|
@@ -1123,6 +1143,43 @@ describe("registerMembersCommand promote", () => {
|
|
|
1123
1143
|
);
|
|
1124
1144
|
});
|
|
1125
1145
|
|
|
1146
|
+
it("fails clearly when no active member matches an email and never posts a role change", async () => {
|
|
1147
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
1148
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1149
|
+
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
1150
|
+
throw new Error(`__EXIT__:${code ?? 0}`);
|
|
1151
|
+
}) as never);
|
|
1152
|
+
|
|
1153
|
+
await expect(
|
|
1154
|
+
buildMembersProgram().parseAsync(
|
|
1155
|
+
[
|
|
1156
|
+
"members",
|
|
1157
|
+
"--company",
|
|
1158
|
+
"acme",
|
|
1159
|
+
"promote",
|
|
1160
|
+
"missing@example.com",
|
|
1161
|
+
"admin",
|
|
1162
|
+
],
|
|
1163
|
+
{ from: "user" },
|
|
1164
|
+
),
|
|
1165
|
+
).rejects.toThrow("__EXIT__:1");
|
|
1166
|
+
|
|
1167
|
+
const errorOutput = errSpy.mock.calls
|
|
1168
|
+
.flatMap((call) => call.map(String))
|
|
1169
|
+
.join(" ");
|
|
1170
|
+
expect(errorOutput).toContain(
|
|
1171
|
+
"No active member has email 'missing@example.com'",
|
|
1172
|
+
);
|
|
1173
|
+
expect(errorOutput).toContain("hq members list");
|
|
1174
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
1175
|
+
expect(
|
|
1176
|
+
fetchSpy.mock.calls.some(
|
|
1177
|
+
([url, init]) =>
|
|
1178
|
+
String(url).endsWith("/membership/role") && init?.method === "POST",
|
|
1179
|
+
),
|
|
1180
|
+
).toBe(false);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1126
1183
|
it("forwards a personUid target and the guest role (beyond set-role's admin|member cap)", async () => {
|
|
1127
1184
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
1128
1185
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
@@ -1189,6 +1246,141 @@ describe("registerMembersCommand promote", () => {
|
|
|
1189
1246
|
});
|
|
1190
1247
|
});
|
|
1191
1248
|
|
|
1249
|
+
describe("resolveRoleChangeTarget", () => {
|
|
1250
|
+
it("passes through a full membership key without fetching members", async () => {
|
|
1251
|
+
await expect(
|
|
1252
|
+
resolveRoleChangeTarget(
|
|
1253
|
+
"test-token",
|
|
1254
|
+
"cmp_acme",
|
|
1255
|
+
"prs_alice#cmp_acme",
|
|
1256
|
+
),
|
|
1257
|
+
).resolves.toBe("prs_alice#cmp_acme");
|
|
1258
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("wraps bare person and agent uids without fetching members", async () => {
|
|
1262
|
+
await expect(
|
|
1263
|
+
resolveRoleChangeTarget("test-token", "cmp_acme", "prs_alice"),
|
|
1264
|
+
).resolves.toBe("prs_alice#cmp_acme");
|
|
1265
|
+
await expect(
|
|
1266
|
+
resolveRoleChangeTarget("test-token", "cmp_acme", "agt_ops"),
|
|
1267
|
+
).resolves.toBe("agt_ops#cmp_acme");
|
|
1268
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it("maps agent machine emails to agt_…#companyUid without fetching members", async () => {
|
|
1272
|
+
await expect(
|
|
1273
|
+
resolveRoleChangeTarget(
|
|
1274
|
+
"test-token",
|
|
1275
|
+
"cmp_acme",
|
|
1276
|
+
"agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
1277
|
+
),
|
|
1278
|
+
).resolves.toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_acme");
|
|
1279
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
// ---------------------------------------------------------------------------
|
|
1284
|
+
// registerMembersCommand invite — MEMBERSHIP_ALREADY_EXISTS messaging
|
|
1285
|
+
// ---------------------------------------------------------------------------
|
|
1286
|
+
|
|
1287
|
+
describe("registerMembersCommand invite 409 messaging", () => {
|
|
1288
|
+
function mockCallerIdentity(): void {
|
|
1289
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1290
|
+
jsonResponse(200, {
|
|
1291
|
+
memberships: [
|
|
1292
|
+
{
|
|
1293
|
+
membershipKey: "prs_admin#cmp_acme",
|
|
1294
|
+
personUid: "prs_admin",
|
|
1295
|
+
companyUid: "cmp_acme",
|
|
1296
|
+
role: "owner",
|
|
1297
|
+
status: "active",
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
}),
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
it("guides --resend when the conflict is a pending invite", async () => {
|
|
1305
|
+
mockCallerIdentity();
|
|
1306
|
+
fetchSpy
|
|
1307
|
+
.mockResolvedValueOnce(
|
|
1308
|
+
jsonResponse(409, {
|
|
1309
|
+
error: "Membership already exists",
|
|
1310
|
+
code: "MEMBERSHIP_ALREADY_EXISTS",
|
|
1311
|
+
}),
|
|
1312
|
+
)
|
|
1313
|
+
.mockResolvedValueOnce(
|
|
1314
|
+
jsonResponse(200, {
|
|
1315
|
+
pending: [
|
|
1316
|
+
{
|
|
1317
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
1318
|
+
inviteeEmail: "alice@example.com",
|
|
1319
|
+
companyUid: "cmp_acme",
|
|
1320
|
+
role: "member",
|
|
1321
|
+
status: "pending",
|
|
1322
|
+
invitedBy: "prs_admin",
|
|
1323
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
1324
|
+
},
|
|
1325
|
+
],
|
|
1326
|
+
}),
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
1330
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1331
|
+
|
|
1332
|
+
await buildMembersProgram().parseAsync(
|
|
1333
|
+
["members", "--company", "acme", "invite", "Alice@Example.com"],
|
|
1334
|
+
{ from: "user" },
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1338
|
+
expect(output).toContain("already has a pending invite");
|
|
1339
|
+
expect(output).toContain(
|
|
1340
|
+
"hq members invite alice@example.com --company acme --resend",
|
|
1341
|
+
);
|
|
1342
|
+
expect(output).not.toContain("already a member of this company");
|
|
1343
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it("keeps the already-member note when the conflict is an active member", async () => {
|
|
1347
|
+
mockCallerIdentity();
|
|
1348
|
+
fetchSpy
|
|
1349
|
+
.mockResolvedValueOnce(
|
|
1350
|
+
jsonResponse(409, {
|
|
1351
|
+
error: "Membership already exists",
|
|
1352
|
+
code: "MEMBERSHIP_ALREADY_EXISTS",
|
|
1353
|
+
}),
|
|
1354
|
+
)
|
|
1355
|
+
.mockResolvedValueOnce(jsonResponse(200, { pending: [] }))
|
|
1356
|
+
.mockResolvedValueOnce(
|
|
1357
|
+
jsonResponse(200, {
|
|
1358
|
+
members: [
|
|
1359
|
+
{
|
|
1360
|
+
membershipKey: "prs_alice#cmp_acme",
|
|
1361
|
+
personUid: "prs_alice",
|
|
1362
|
+
companyUid: "cmp_acme",
|
|
1363
|
+
role: "member",
|
|
1364
|
+
status: "active",
|
|
1365
|
+
personEmail: "alice@example.com",
|
|
1366
|
+
},
|
|
1367
|
+
],
|
|
1368
|
+
}),
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
1372
|
+
|
|
1373
|
+
await buildMembersProgram().parseAsync(
|
|
1374
|
+
["members", "--company", "acme", "invite", "alice@example.com"],
|
|
1375
|
+
{ from: "user" },
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1379
|
+
expect(output).toContain("already a member of this company");
|
|
1380
|
+
expect(output).not.toContain("pending invite");
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1192
1384
|
// ---------------------------------------------------------------------------
|
|
1193
1385
|
// resolveRevokeTargetToMembershipKey
|
|
1194
1386
|
// ---------------------------------------------------------------------------
|